diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml index b2feedc86e..109f026cb4 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml @@ -20,6 +20,14 @@ body: attributes: label: What version of Codex is running? description: Copy the output of `codex --version` + validations: + required: true + - type: input + id: plan + attributes: + label: What subscription do you have? + validations: + required: true - type: input id: model attributes: @@ -32,11 +40,18 @@ body: description: | For MacOS and Linux: copy the output of `uname -mprs` For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console + - type: textarea + id: actual + attributes: + label: What issue are you seeing? + description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. + validations: + required: true - type: textarea id: steps attributes: label: What steps can reproduce the bug? - description: Explain the bug and provide a code snippet that can reproduce it. + description: Explain the bug and provide a code snippet that can reproduce it. Please include session id, token limit usage, context window usage if applicable. validations: required: true - type: textarea @@ -44,11 +59,6 @@ body: attributes: label: What is the expected behavior? description: If possible, please provide text instead of a screenshot. - - type: textarea - id: actual - attributes: - label: What do you see instead? - description: If possible, please provide text instead of a screenshot. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml b/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml index f2ba251a1d..52da6a7cad 100644 --- a/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml +++ b/.github/ISSUE_TEMPLATE/5-vs-code-extension.yml @@ -14,11 +14,21 @@ body: id: version attributes: label: What version of the VS Code extension are you using? + validations: + required: true + - type: input + id: plan + attributes: + label: What subscription do you have? + validations: + required: true - type: input id: ide attributes: label: Which IDE are you using? description: Like `VS Code`, `Cursor`, `Windsurf`, etc. + validations: + required: true - type: input id: platform attributes: @@ -26,11 +36,18 @@ body: description: | For MacOS and Linux: copy the output of `uname -mprs` For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console + - type: textarea + id: actual + attributes: + label: What issue are you seeing? + description: Please include the full error messages and prompts with PII redacted. If possible, please provide text instead of a screenshot. + validations: + required: true - type: textarea id: steps attributes: label: What steps can reproduce the bug? - description: Explain the bug and provide a code snippet that can reproduce it. + description: Explain the bug and provide a code snippet that can reproduce it. Please include session id, token limit usage, context window usage if applicable. validations: required: true - type: textarea @@ -38,11 +55,6 @@ body: attributes: label: What is the expected behavior? description: If possible, please provide text instead of a screenshot. - - type: textarea - id: actual - attributes: - label: What do you see instead? - description: If possible, please provide text instead of a screenshot. - type: textarea id: notes attributes: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 846408f3a8..cbf62f72f0 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -148,15 +148,26 @@ jobs: targets: ${{ matrix.target }} components: clippy - - uses: actions/cache@v4 + # Explicit cache restore: split cargo home vs target, so we can + # avoid caching the large target dir on the gnu-dev job. + - 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/ - ${{ github.workspace }}/codex-rs/target/ - key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Restore target cache (except gnu-dev) + id: cache_target_restore + if: ${{ !(matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release') }} + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/codex-rs/target/ + key: cargo-target-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -194,6 +205,31 @@ jobs: 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 + 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') }} + + - 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') + 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') }} + # Fail the job if any of the previous steps failed. - name: verify all steps passed if: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index aebdef5948..94143acb49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -47,7 +47,7 @@ jobs: build: needs: tag-check - name: ${{ matrix.runner }} - ${{ matrix.target }} + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 defaults: @@ -94,11 +94,181 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools run: | - sudo apt install -y musl-tools pkg-config + sudo apt-get update + sudo apt-get install -y musl-tools pkg-config - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + - if: ${{ matrix.runner == 'macos-14' }} + name: Configure Apple code signing + shell: bash + env: + KEYCHAIN_PASSWORD: actions + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + + if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then + echo "APPLE_CERTIFICATE is required for macOS signing" + exit 1 + fi + + if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then + echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing" + exit 1 + fi + + cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12" + echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path" + + keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path" + + keychain_args=() + cleanup_keychain() { + if ((${#keychain_args[@]} > 0)); then + security list-keychains -s "${keychain_args[@]}" || true + security default-keychain -s "${keychain_args[0]}" || true + else + security list-keychains -s || true + fi + if [[ -f "$keychain_path" ]]; then + security delete-keychain "$keychain_path" || true + fi + } + + while IFS= read -r keychain; do + [[ -n "$keychain" ]] && keychain_args+=("$keychain") + done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') + + if ((${#keychain_args[@]} > 0)); then + security list-keychains -s "$keychain_path" "${keychain_args[@]}" + else + security list-keychains -s "$keychain_path" + fi + + security default-keychain -s "$keychain_path" + security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null + + codesign_hashes=() + while IFS= read -r hash; do + [[ -n "$hash" ]] && codesign_hashes+=("$hash") + done < <(security find-identity -v -p codesigning "$keychain_path" \ + | sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \ + | sort -u) + + if ((${#codesign_hashes[@]} == 0)); then + echo "No signing identities found in $keychain_path" + cleanup_keychain + rm -f "$cert_path" + exit 1 + fi + + if ((${#codesign_hashes[@]} > 1)); then + echo "Multiple signing identities found in $keychain_path:" + printf ' %s\n' "${codesign_hashes[@]}" + cleanup_keychain + rm -f "$cert_path" + exit 1 + fi + + APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}" + + rm -f "$cert_path" + + echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV" + echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" + echo "::add-mask::$APPLE_CODESIGN_IDENTITY" + + - if: ${{ matrix.runner == 'macos-14' }} + name: Sign macOS binaries + shell: bash + run: | + set -euo pipefail + + if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then + echo "APPLE_CODESIGN_IDENTITY is required for macOS signing" + exit 1 + fi + + keychain_args=() + if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then + keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}") + fi + + for binary in codex codex-responses-api-proxy; do + path="target/${{ matrix.target }}/release/${binary}" + codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" + done + + - if: ${{ matrix.runner == 'macos-14' }} + name: Notarize macOS binaries + shell: bash + env: + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + + for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do + if [[ -z "${!var:-}" ]]; then + echo "$var is required for notarization" + exit 1 + fi + done + + notary_key_path="${RUNNER_TEMP}/notarytool.key.p8" + echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" + cleanup_notary() { + rm -f "$notary_key_path" + } + trap cleanup_notary EXIT + + notarize_binary() { + local binary="$1" + local source_path="target/${{ matrix.target }}/release/${binary}" + local archive_path="${RUNNER_TEMP}/${binary}.zip" + + if [[ ! -f "$source_path" ]]; then + echo "Binary $source_path not found" + exit 1 + fi + + rm -f "$archive_path" + ditto -c -k --keepParent "$source_path" "$archive_path" + + submission_json=$(xcrun notarytool submit "$archive_path" \ + --key "$notary_key_path" \ + --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ + --output-format json \ + --wait) + + status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"') + submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""') + + if [[ -z "$submission_id" ]]; then + echo "Failed to retrieve submission ID for $binary" + exit 1 + fi + + echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}" + + if [[ "$status" != "Accepted" ]]; then + echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})" + exit 1 + fi + } + + notarize_binary "codex" + notarize_binary "codex-responses-api-proxy" + - name: Stage artifacts shell: bash run: | @@ -157,6 +327,29 @@ jobs: zstd -T0 -19 --rm "$dest/$base" done + - name: Remove signing keychain + if: ${{ always() && matrix.runner == 'macos-14' }} + shell: bash + env: + APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }} + run: | + set -euo pipefail + if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then + keychain_args=() + while IFS= read -r keychain; do + [[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue + [[ -n "$keychain" ]] && keychain_args+=("$keychain") + done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g') + if ((${#keychain_args[@]} > 0)); then + security list-keychains -s "${keychain_args[@]}" + security default-keychain -s "${keychain_args[0]}" + fi + + if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then + security delete-keychain "$APPLE_CODESIGN_KEYCHAIN" + fi + fi + - uses: actions/upload-artifact@v4 with: name: ${{ matrix.target }} diff --git a/.gitignore b/.gitignore index a264d91822..178239c0a0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ result # cli tools CLAUDE.md .claude/ +AGENTS.override.md # caches .cache/ 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 f94322fcff..ea2abfb0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ In the codex-rs folder where the rust code lives: - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate. - Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if - 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: diff --git a/README.md b/README.md index c9cc4e1fd4..722048b30b 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: @@ -75,6 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [CLI usage](./docs/getting-started.md#cli-usage) - [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) - [**Sandbox & approvals**](./docs/sandbox.md) 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-cli/bin/codex.js b/codex-cli/bin/codex.js old mode 100755 new mode 100644 index 17dd98a8e8..805be85af8 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -80,6 +80,32 @@ function getUpdatedPath(newDirs) { return updatedPath; } +/** + * Use heuristics to detect the package manager that was used to install Codex + * in order to give the user a hint about how to update it. + */ +function detectPackageManager() { + const userAgent = process.env.npm_config_user_agent || ""; + if (/\bbun\//.test(userAgent)) { + return "bun"; + } + + const execPath = process.env.npm_execpath || ""; + if (execPath.includes("bun")) { + return "bun"; + } + + if ( + process.env.BUN_INSTALL || + process.env.BUN_INSTALL_GLOBAL_DIR || + process.env.BUN_INSTALL_BIN_DIR + ) { + return "bun"; + } + + return userAgent ? "npm" : null; +} + const additionalDirs = []; const pathDir = path.join(archRoot, "path"); if (existsSync(pathDir)) { @@ -87,9 +113,16 @@ if (existsSync(pathDir)) { } const updatedPath = getUpdatedPath(additionalDirs); +const env = { ...process.env, PATH: updatedPath }; +const packageManagerEnvVar = + detectPackageManager() === "bun" + ? "CODEX_MANAGED_BY_BUN" + : "CODEX_MANAGED_BY_NPM"; +env[packageManagerEnvVar] = "1"; + const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", - env: { ...process.env, PATH: updatedPath, CODEX_MANAGED_BY_NPM: "1" }, + env, }); child.on("error", (err) => { 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 8c992a6f8e..46955c4342 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -861,9 +861,11 @@ name = "codex-app-server-protocol" version = "0.0.0" dependencies = [ "anyhow", + "clap", "codex-protocol", "paste", "pretty_assertions", + "schemars 0.8.22", "serde", "serde_json", "strum_macros 0.27.2", @@ -899,6 +901,16 @@ 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" @@ -958,6 +970,7 @@ dependencies = [ "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-stdio-to-uds", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -1037,11 +1050,13 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", + "codex-async-utils", "codex-file-search", "codex-mcp-client", "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-pty", "codex-utils-string", "core-foundation 0.9.4", "core_test_support", @@ -1058,7 +1073,6 @@ dependencies = [ "mcp-types", "openssl-sys", "os_info", - "portable-pty", "predicates", "pretty_assertions", "rand 0.9.2", @@ -1073,6 +1087,7 @@ dependencies = [ "similar", "strum_macros 0.27.2", "tempfile", + "test-log", "thiserror 2.0.16", "time", "tokio", @@ -1144,6 +1159,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" @@ -1308,6 +1334,7 @@ dependencies = [ "icu_locale_core", "mcp-types", "mime_guess", + "schemars 0.8.22", "serde", "serde_json", "serde_with", @@ -1352,7 +1379,9 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "codex-protocol", "dirs", + "escargot", "futures", "keyring", "mcp-types", @@ -1362,6 +1391,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tiny_http", @@ -1371,6 +1401,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" @@ -1387,6 +1428,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-git-tooling", "codex-login", @@ -1421,6 +1463,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -1441,6 +1484,15 @@ 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" @@ -1576,10 +1628,12 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "notify", "regex-lite", "serde_json", "tempfile", "tokio", + "walkdir", "wiremock", ] @@ -1820,6 +1874,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" @@ -2298,6 +2362,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" @@ -2370,6 +2446,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.31" @@ -2657,6 +2742,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" @@ -3056,6 +3152,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3256,6 +3372,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lalrpop" version = "0.19.12" @@ -3455,6 +3591,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" name = "mcp-types" version = "0.0.0" dependencies = [ + "schemars 0.8.22", "serde", "serde_json", "ts-rs", @@ -3655,6 +3792,30 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -4829,6 +4990,15 @@ 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" @@ -5143,6 +5313,120 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.226" @@ -5775,6 +6059,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" @@ -6350,6 +6656,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" @@ -6409,6 +6724,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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ca40b1a536..c0493c39d6 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", @@ -27,11 +29,13 @@ members = [ "protocol-ts", "rmcp-client", "responses-api-proxy", + "stdio-to-uds", "otel", "tui", "git-apply", "utils/json-to-toml", "utils/readiness", + "utils/pty", "utils/string", ] resolver = "2" @@ -52,10 +56,12 @@ 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-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-linux-sandbox = { path = "linux-sandbox" } @@ -69,9 +75,11 @@ 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-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } +codex-utils-pty = { path = "utils/pty" } codex-utils-string = { path = "utils/string" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } @@ -83,8 +91,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" @@ -122,6 +130,7 @@ log = "0.4" maplit = "1.0.2" mime_guess = "2.0.5" multimap = "0.10.0" +notify = "8.2.0" nucleo-matcher = "0.3.1" openssl-sys = "*" opentelemetry = "0.30.0" @@ -146,6 +155,7 @@ reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" @@ -160,6 +170,7 @@ 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" time = "0.3" @@ -179,6 +190,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" diff --git a/codex-rs/README.md b/codex-rs/README.md index a05a8d48b1..e7dfadb789 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: diff --git a/codex-rs/ansi-escape/src/lib.rs b/codex-rs/ansi-escape/src/lib.rs index 68ea5e9aa9..b47cf14f8e 100644 --- a/codex-rs/ansi-escape/src/lib.rs +++ b/codex-rs/ansi-escape/src/lib.rs @@ -3,11 +3,30 @@ use ansi_to_tui::IntoText; use ratatui::text::Line; use ratatui::text::Text; +// Expand tabs in a best-effort way for transcript rendering. +// Tabs can interact poorly with left-gutter prefixes in our TUI and CLI +// transcript views (e.g., `nl` separates line numbers from content with a tab). +// Replacing tabs with spaces avoids odd visual artifacts without changing +// semantics for our use cases. +fn expand_tabs(s: &str) -> std::borrow::Cow<'_, str> { + if s.contains('\t') { + // Keep it simple: replace each tab with 4 spaces. + // We do not try to align to tab stops since most usages (like `nl`) + // look acceptable with a fixed substitution and this avoids stateful math + // across spans. + std::borrow::Cow::Owned(s.replace('\t', " ")) + } else { + std::borrow::Cow::Borrowed(s) + } +} + /// This function should be used when the contents of `s` are expected to match /// a single line. If multiple lines are found, a warning is logged and only the /// first line is returned. pub fn ansi_escape_line(s: &str) -> Line<'static> { - let text = ansi_escape(s); + // Normalize tabs to spaces to avoid odd gutter collisions in transcript mode. + let s = expand_tabs(s); + let text = ansi_escape(&s); match text.lines.as_slice() { [] => "".into(), [only] => only.clone(), diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index b18028fbe4..58b0b9d607 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -11,8 +11,11 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } codex-protocol = { 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..21ba3facc4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/export.rs @@ -0,0 +1,404 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export_client_response_schemas; +use crate::export_client_responses; +use crate::export_server_response_schemas; +use crate::export_server_responses; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use schemars::JsonSchema; +use schemars::schema::RootSchema; +use schemars::schema_for; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::BTreeMap; +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"; + +macro_rules! for_each_schema_type { + ($macro:ident) => { + $macro!(crate::RequestId); + $macro!(crate::JSONRPCMessage); + $macro!(crate::JSONRPCRequest); + $macro!(crate::JSONRPCNotification); + $macro!(crate::JSONRPCResponse); + $macro!(crate::JSONRPCError); + $macro!(crate::JSONRPCErrorError); + $macro!(crate::AddConversationListenerParams); + $macro!(crate::AddConversationSubscriptionResponse); + $macro!(crate::ApplyPatchApprovalParams); + $macro!(crate::ApplyPatchApprovalResponse); + $macro!(crate::ArchiveConversationParams); + $macro!(crate::ArchiveConversationResponse); + $macro!(crate::AuthMode); + $macro!(crate::AuthStatusChangeNotification); + $macro!(crate::CancelLoginChatGptParams); + $macro!(crate::CancelLoginChatGptResponse); + $macro!(crate::ClientInfo); + $macro!(crate::ClientNotification); + $macro!(crate::ClientRequest); + $macro!(crate::ConversationSummary); + $macro!(crate::ExecCommandApprovalParams); + $macro!(crate::ExecCommandApprovalResponse); + $macro!(crate::ExecOneOffCommandParams); + $macro!(crate::ExecOneOffCommandResponse); + $macro!(crate::FuzzyFileSearchParams); + $macro!(crate::FuzzyFileSearchResponse); + $macro!(crate::FuzzyFileSearchResult); + $macro!(crate::GetAuthStatusParams); + $macro!(crate::GetAuthStatusResponse); + $macro!(crate::GetUserAgentResponse); + $macro!(crate::GetUserSavedConfigResponse); + $macro!(crate::GitDiffToRemoteParams); + $macro!(crate::GitDiffToRemoteResponse); + $macro!(crate::GitSha); + $macro!(crate::InitializeParams); + $macro!(crate::InitializeResponse); + $macro!(crate::InputItem); + $macro!(crate::InterruptConversationParams); + $macro!(crate::InterruptConversationResponse); + $macro!(crate::ListConversationsParams); + $macro!(crate::ListConversationsResponse); + $macro!(crate::LoginApiKeyParams); + $macro!(crate::LoginApiKeyResponse); + $macro!(crate::LoginChatGptCompleteNotification); + $macro!(crate::LoginChatGptResponse); + $macro!(crate::LogoutChatGptParams); + $macro!(crate::LogoutChatGptResponse); + $macro!(crate::NewConversationParams); + $macro!(crate::NewConversationResponse); + $macro!(crate::Profile); + $macro!(crate::RemoveConversationListenerParams); + $macro!(crate::RemoveConversationSubscriptionResponse); + $macro!(crate::ResumeConversationParams); + $macro!(crate::ResumeConversationResponse); + $macro!(crate::SandboxSettings); + $macro!(crate::SendUserMessageParams); + $macro!(crate::SendUserMessageResponse); + $macro!(crate::SendUserTurnParams); + $macro!(crate::SendUserTurnResponse); + $macro!(crate::ServerNotification); + $macro!(crate::ServerRequest); + $macro!(crate::SessionConfiguredNotification); + $macro!(crate::SetDefaultModelParams); + $macro!(crate::SetDefaultModelResponse); + $macro!(crate::Tools); + $macro!(crate::UserInfoResponse); + $macro!(crate::UserSavedConfig); + $macro!(codex_protocol::protocol::EventMsg); + $macro!(codex_protocol::protocol::FileChange); + $macro!(codex_protocol::parse_command::ParsedCommand); + $macro!(codex_protocol::protocol::SandboxPolicy); + }; +} + +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<()> { + ensure_dir(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)?; + + let ts_files = ts_files_in(out_dir)?; + for file in &ts_files { + prepend_header_if_missing(file)?; + } + + 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 mut bundle: BTreeMap = BTreeMap::new(); + + macro_rules! add_schema { + ($ty:path) => {{ + let name = type_basename(stringify!($ty)); + let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?; + bundle.insert(name, schema); + }}; + } + + for_each_schema_type!(add_schema); + + export_client_response_schemas(out_dir)?; + export_server_response_schemas(out_dir)?; + + let mut definitions = Map::new(); + + const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "FileChange", + "InputItem", + "ParsedCommand", + "SandboxPolicy", + "ServerNotification", + "ServerRequest", + ]; + + for (name, schema) in bundle { + let mut schema_value = serde_json::to_value(schema)?; + if let Value::Object(ref mut obj) = schema_value { + if let Some(defs) = obj.remove("definitions") + && let Value::Object(defs_obj) = defs + { + for (def_name, def_schema) in defs_obj { + if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + definitions.insert(def_name, def_schema); + } + } + } + + if let Some(Value::Array(one_of)) = obj.get_mut("oneOf") { + for variant in one_of.iter_mut() { + if let Some(variant_name) = variant_definition_name(&name, variant) + && let Value::Object(variant_obj) = variant + { + variant_obj.insert("title".into(), Value::String(variant_name)); + } + } + } + } + definitions.insert(name, schema_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)); + + write_pretty_json( + out_dir.join("codex_app_server_protocol.schemas.json"), + &Value::Object(root), + )?; + + Ok(()) +} + +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); + write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema) + .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + Ok(schema) +} + +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result<()> +where + T: JsonSchema, +{ + write_json_schema_with_return::(out_dir, name).map(|_| ()) +} + +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(()) +} +fn type_basename(type_path: &str) -> String { + type_path + .rsplit_once("::") + .map(|(_, name)| name) + .unwrap_or(type_path) + .trim() + .to_string() +} + +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(|value| value.get("enum")) + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .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 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 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")); + } + + 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) +} diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index 9d6d6da06f..c0b88b0215 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,7 +29,7 @@ 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, @@ -37,7 +38,7 @@ pub struct JSONRPCRequest { } /// 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")] @@ -45,20 +46,20 @@ pub struct JSONRPCNotification { } /// 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")] diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 517702bbd8..cc2310bc85 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -1,5 +1,9 @@ +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::*; diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 845a2431f4..9c10d23360 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -5,10 +5,12 @@ use crate::JSONRPCNotification; use crate::JSONRPCRequest; use crate::RequestId; 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::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; @@ -16,13 +18,14 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use paste::paste; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[ts(type = "string")] pub struct GitSha(pub String); @@ -32,7 +35,7 @@ impl GitSha { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum AuthMode { ApiKey, @@ -54,7 +57,7 @@ macro_rules! client_request_definitions { ),* $(,)? ) => { /// Request from the client to the server. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { $( @@ -76,6 +79,15 @@ macro_rules! client_request_definitions { )* Ok(()) } + + pub fn export_client_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + $( + crate::export::write_json_schema::<$response>(out_dir, stringify!($response))?; + )* + Ok(()) + } }; } @@ -173,13 +185,13 @@ client_request_definitions! { }, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ClientInfo { pub name: String, @@ -188,13 +200,13 @@ pub struct ClientInfo { pub version: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationParams { /// Optional override for the model name (e.g. "o3", "o4-mini"). @@ -237,7 +249,7 @@ pub struct NewConversationParams { pub include_apply_patch_tool: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct NewConversationResponse { pub conversation_id: ConversationId, @@ -248,7 +260,7 @@ pub struct NewConversationResponse { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationResponse { pub conversation_id: ConversationId, @@ -257,7 +269,7 @@ pub struct ResumeConversationResponse { pub initial_messages: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { /// Optional page size; defaults to a reasonable server-side value. @@ -268,7 +280,7 @@ pub struct ListConversationsParams { pub cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ConversationSummary { pub conversation_id: ConversationId, @@ -279,7 +291,7 @@ pub struct ConversationSummary { pub timestamp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsResponse { pub items: Vec, @@ -289,7 +301,7 @@ pub struct ListConversationsResponse { pub next_cursor: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationParams { /// Absolute path to the rollout JSONL file. @@ -299,78 +311,81 @@ pub struct ResumeConversationParams { pub overrides: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationSubscriptionResponse { + #[schemars(with = "String")] pub subscription_id: Uuid, } /// The [`ConversationId`] must match the `rollout_path`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ArchiveConversationResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct RemoveConversationSubscriptionResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginApiKeyResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptResponse { + #[schemars(with = "String")] 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)] +#[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, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GitDiffToRemoteParams { pub cwd: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptParams {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LogoutChatGptResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusParams { /// If true, include the current auth token (if available) in the response. @@ -381,7 +396,7 @@ pub struct GetAuthStatusParams { pub refresh_token: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandParams { /// Command argv to execute. @@ -397,7 +412,7 @@ pub struct ExecOneOffCommandParams { pub sandbox_policy: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecOneOffCommandResponse { pub exit_code: i32, @@ -405,7 +420,7 @@ pub struct ExecOneOffCommandResponse { pub stderr: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -420,13 +435,13 @@ pub struct GetAuthStatusResponse { pub requires_openai_auth: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserInfoResponse { /// Note: `alleged_user_email` is not currently verified. We read it from @@ -436,13 +451,13 @@ pub struct UserInfoResponse { pub alleged_user_email: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct GetUserSavedConfigResponse { pub config: UserSavedConfig, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SetDefaultModelParams { /// If set to None, this means `model` should be cleared in config.toml. @@ -454,14 +469,14 @@ pub struct SetDefaultModelParams { pub reasoning_effort: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, 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)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UserSavedConfig { /// Approvals @@ -472,6 +487,11 @@ pub struct UserSavedConfig { #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forced_chatgpt_workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forced_login_method: Option, + /// Model-specific configuration #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -494,7 +514,7 @@ pub struct UserSavedConfig { } /// MCP representation of a [`codex_core::config_profile::ConfigProfile`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Profile { pub model: Option, @@ -508,7 +528,7 @@ pub struct Profile { pub chatgpt_base_url: Option, } /// MCP representation of a [`codex_core::config::ToolsToml`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct Tools { #[serde(skip_serializing_if = "Option::is_none")] @@ -518,7 +538,7 @@ pub struct Tools { } /// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SandboxSettings { #[serde(default)] @@ -531,14 +551,14 @@ pub struct SandboxSettings { pub exclude_slash_tmp: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnParams { pub conversation_id: ConversationId, @@ -552,39 +572,40 @@ pub struct SendUserTurnParams { pub summary: ReasoningSummary, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserTurnResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct InterruptConversationResponse { pub abort_reason: TurnAbortReason, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageResponse {} -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AddConversationListenerParams { pub conversation_id: ConversationId, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[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, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(tag = "type", content = "data")] pub enum InputItem { @@ -616,7 +637,7 @@ macro_rules! server_request_definitions { ) => { paste! { /// Request initiated from the server and sent to the client. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ServerRequest { $( @@ -629,7 +650,7 @@ macro_rules! server_request_definitions { )* } - #[derive(Debug, Clone, PartialEq)] + #[derive(Debug, Clone, PartialEq, JsonSchema)] pub enum ServerRequestPayload { $( $variant([<$variant Params>]), )* } @@ -651,6 +672,15 @@ macro_rules! server_request_definitions { } Ok(()) } + + pub fn export_server_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result<()> { + paste! { + $(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?;)* + } + Ok(()) + } }; } @@ -669,7 +699,7 @@ server_request_definitions! { ExecCommandApproval, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, @@ -686,7 +716,7 @@ pub struct ApplyPatchApprovalParams { pub grant_root: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, @@ -697,19 +727,20 @@ pub struct ExecCommandApprovalParams { pub cwd: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + pub parsed_cmd: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ExecCommandApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] pub struct FuzzyFileSearchParams { @@ -721,7 +752,7 @@ pub struct FuzzyFileSearchParams { } /// Superset of [`codex_file_search::FileMatch`] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResult { pub root: String, pub path: String, @@ -731,21 +762,22 @@ pub struct FuzzyFileSearchResult { pub indices: Option>, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResponse { pub files: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptCompleteNotification { + #[schemars(with = "String")] pub login_id: Uuid, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct SessionConfiguredNotification { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -773,7 +805,7 @@ pub struct SessionConfiguredNotification { pub rollout_path: PathBuf, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct AuthStatusChangeNotification { /// Current authentication method; omitted if signed out. @@ -782,7 +814,7 @@ pub struct AuthStatusChangeNotification { } /// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ServerNotification { @@ -815,7 +847,7 @@ impl TryFrom for ServerNotification { } /// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum ClientNotification { @@ -904,6 +936,9 @@ mod tests { 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), @@ -920,6 +955,12 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", + "parsedCmd": [ + { + "type": "unknown", + "cmd": "echo hello" + } + ] } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cb894a51ab..0d9fe4b089 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -84,6 +84,7 @@ use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use codex_protocol::ConversationId; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; @@ -243,6 +244,19 @@ impl CodexMessageProcessor { } async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "API key login is disabled. Use ChatGPT login instead.".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + { let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { @@ -278,9 +292,23 @@ impl CodexMessageProcessor { async fn login_chatgpt(&mut self, request_id: RequestId) { let config = self.config.as_ref(); + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "ChatGPT login is disabled. Use API key login instead.".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + let opts = LoginServerOptions { open_browser: false, - ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string()) + ..LoginServerOptions::new( + config.codex_home.clone(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + ) }; enum LoginChatGptReply { @@ -603,6 +631,7 @@ impl CodexMessageProcessor { env, with_escalated_permissions: None, justification: None, + arg0: None, }; let effective_policy = params @@ -1284,6 +1313,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }) => { let params = ExecCommandApprovalParams { conversation_id, @@ -1291,6 +1321,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) @@ -1351,6 +1382,7 @@ async fn derive_config_from_params( include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index f45a27fda0..aa6cc1bb7d 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,6 +5,7 @@ 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; @@ -57,6 +58,19 @@ sandbox_mode = "danger-full-access" ) } +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) { let request_id = mcp .send_login_api_key_request(LoginApiKeyParams { @@ -221,3 +235,38 @@ async fn get_auth_status_with_api_key_no_include_token() { assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert!(status.auth_token.is_none(), "token must be omitted"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_api_key_rejected_when_forced_chatgpt() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_login(codex_home.path(), "chatgpt") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + 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 request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: "sk-test-key".to_string(), + }) + .await + .expect("send loginApiKey"); + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginApiKey error timeout") + .expect("loginApiKey error"); + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); +} 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 4dff2a1575..27731f8430 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 @@ -27,6 +27,7 @@ use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; 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; @@ -311,6 +312,9 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { ], cwd: working_directory.clone(), reason: None, + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "python3 -c 'print(42)'".to_string() + }], }, params ); diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 577eeb388d..2809d68ca4 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -11,6 +11,7 @@ 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; @@ -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"] @@ -92,6 +95,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), @@ -149,6 +154,8 @@ async fn get_config_toml_empty() { 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, diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index 6dcbde1125..220769b7d6 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -7,6 +7,7 @@ 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; @@ -144,3 +145,97 @@ async fn login_and_cancel_chatgpt() { eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel"); } } + +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() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_login(codex_home.path(), "api") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + 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 request_id = mcp + .send_login_chat_gpt_request() + .await + .expect("send loginChatGpt"); + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginChatGpt error timeout") + .expect("loginChatGpt error"); + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_chatgpt_includes_forced_workspace_query_param() { + let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); + create_config_toml_forced_workspace(codex_home.path(), "ws-forced") + .unwrap_or_else(|err| panic!("write config.toml: {err}")); + + 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 request_id = mcp + .send_login_chat_gpt_request() + .await + .expect("send loginChatGpt"); + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .expect("loginChatGpt timeout") + .expect("loginChatGpt response"); + + let login: LoginChatGptResponse = to_response(resp).expect("deserialize login resp"); + assert!( + login.auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); +} 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/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 453ab80771..d66678687b 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,12 +30,11 @@ 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 } @@ -47,8 +48,8 @@ tokio = { workspace = true, features = [ ] } [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/login.rs b/codex-rs/cli/src/login.rs index 5e69ede69d..2e2f3f074d 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -9,12 +9,20 @@ 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, +) -> std::io::Result<()> { + let opts = ServerOptions::new( + codex_home, + CLIENT_ID.to_string(), + forced_chatgpt_workspace_id, + ); let server = run_login_server(opts)?; eprintln!( @@ -28,7 +36,14 @@ 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).await { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -46,6 +61,11 @@ pub async fn run_login_with_api_key( ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + 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) { Ok(_) => { eprintln!("Successfully logged in"); @@ -92,9 +112,15 @@ 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, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d889823298..3993503412 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -19,6 +19,7 @@ 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 owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -26,6 +27,8 @@ use supports_color::Stream; mod mcp_cmd; use crate::mcp_cmd::McpCli; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; /// Codex CLI /// @@ -39,12 +42,16 @@ use crate::mcp_cmd::McpCli; // 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)] pub config_overrides: CliConfigOverrides, + #[clap(flatten)] + pub feature_toggles: FeatureToggles, + #[clap(flatten)] interactive: TuiCli, @@ -97,6 +104,13 @@ enum Subcommand { /// Internal: run the responses API proxy. #[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), } #[derive(Debug, Parser)] @@ -157,7 +171,7 @@ struct LoginCommand { )] api_key: Option, - #[arg(long = "use-device-code")] + #[arg(long = "device-auth")] use_device_code: bool, /// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced) @@ -196,10 +210,18 @@ 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, conversation_id, + .. } = exit_info; if token_usage.is_zero() { @@ -224,11 +246,79 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> { + let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } + if let Some(action) = update_action { + run_update_action(action)?; + } + Ok(()) +} + +/// Run the update action and print the result. +fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { + println!(); + 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()?; + if !status.success() { + anyhow::bail!("`{cmd_str}` failed with status {status}"); + } + println!(); + println!("🎉 Update ran successfully! Please restart Codex."); + Ok(()) +} + +#[derive(Debug, Default, Parser, Clone)] +struct FeatureToggles { + /// Enable a feature (repeatable). Equivalent to `-c features.=true`. + #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + enable: Vec, + + /// Disable a feature (repeatable). Equivalent to `-c features.=false`. + #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + disable: Vec, +} + +impl FeatureToggles { + fn to_overrides(&self) -> Vec { + let mut v = Vec::new(); + for k in &self.enable { + v.push(format!("features.{k}=true")); + } + for k in &self.disable { + v.push(format!("features.{k}=false")); + } + v + } +} + +#[derive(Debug, Parser)] +struct FeaturesCli { + #[command(subcommand)] + sub: FeaturesSubcommand, +} + +#[derive(Debug, Parser)] +enum FeaturesSubcommand { + /// List known features with their stage and effective state. + List, +} + +fn stage_str(stage: codex_core::features::Stage) -> &'static str { + use codex_core::features::Stage; + match stage { + Stage::Experimental => "experimental", + Stage::Beta => "beta", + Stage::Stable => "stable", + Stage::Deprecated => "deprecated", + Stage::Removed => "removed", + } } /// As early as possible in the process lifecycle, apply hardening measures. We @@ -248,11 +338,17 @@ fn main() -> anyhow::Result<()> { async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let MultitoolCli { - config_overrides: root_config_overrides, + config_overrides: mut root_config_overrides, + feature_toggles, mut interactive, subcommand, } = MultitoolCli::parse(); + // Fold --enable/--disable into config overrides so they flow to all subcommands. + root_config_overrides + .raw_overrides + .extend(feature_toggles.to_overrides()); + match subcommand { None => { prepend_config_flags( @@ -260,7 +356,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags( @@ -293,7 +389,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() config_overrides, ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( @@ -378,9 +474,38 @@ 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 + .parse_overrides() + .map_err(|e| anyhow::anyhow!(e))?; + + // 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() + }; + + let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; + for def in codex_core::features::FEATURES.iter() { + let name = def.key; + let stage = stage_str(def.stage); + let enabled = config.features.enabled(def.id); + println!("{name}\t{stage}\t{enabled}"); + } + } + }, } Ok(()) @@ -455,6 +580,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); } @@ -484,6 +612,7 @@ mod tests { interactive, config_overrides: root_overrides, subcommand, + feature_toggles: _, } = cli; let Subcommand::Resume(ResumeCommand { @@ -509,6 +638,7 @@ mod tests { conversation_id: conversation .map(ConversationId::from_string) .map(Result::unwrap), + update_action: None, } } @@ -517,6 +647,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + update_action: None, }; let lines = format_exit_messages(exit_info, false); assert!(lines.is_empty()); diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0cf3c0e228..50274f3ee5 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -6,6 +6,7 @@ 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::find_codex_home; @@ -13,8 +14,12 @@ 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::features::Feature; +use codex_core::mcp::auth::compute_auth_statuses; +use codex_core::protocol::McpAuthStatus; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; +use codex_rmcp_client::supports_oauth_login; /// [experimental] Launch Codex as an MCP server or manage configured MCP servers. /// @@ -187,7 +192,10 @@ impl McpCli { async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. - config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await + .context("failed to load configuration")?; let AddArgs { name, @@ -220,20 +228,29 @@ 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 { - streamable_http: Some(streamable_http), + streamable_http: + Some(AddMcpStreamableHttpArgs { + url, + bearer_token_env_var, + }), .. } => McpServerTransportConfig::StreamableHttp { - url: streamable_http.url, - bearer_token_env_var: streamable_http.bearer_token_env_var, + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, }, AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; let new_entry = McpServerConfig { - transport, + transport: transport.clone(), + enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, }; @@ -245,6 +262,26 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re println!("Added global MCP server '{name}'."); + 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, + http_headers.clone(), + env_http_headers.clone(), + ) + .await?; + println!("Successfully logged in."); + } + Ok(()) } @@ -282,7 +319,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) .await .context("failed to load configuration")?; - if !config.use_experimental_use_rmcp_client { + if !config.features.enabled(Feature::RmcpClient) { bail!( "OAuth login is only supported when experimental_use_rmcp_client is true in config.toml." ); @@ -294,12 +331,24 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) 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, + ) + .await?; println!("Successfully logged in to MCP server '{name}'."); Ok(()) } @@ -339,32 +388,54 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let mut entries: Vec<_> = config.mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let auth_statuses = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { + let auth_status = auth_statuses + .get(name.as_str()) + .copied() + .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, }) } }; serde_json::json!({ "name": name, + "enabled": cfg.enabled, "transport": transport, "startup_timeout_sec": cfg .startup_timeout_sec @@ -372,6 +443,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "tool_timeout_sec": cfg .tool_timeout_sec .map(|timeout| timeout.as_secs_f64()), + "auth_status": auth_status, }) }) .collect(); @@ -385,47 +457,85 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 4]> = Vec::new(); - let mut http_rows: Vec<[String; 3]> = 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 { + "disabled".to_string() }; - stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); + let auth_status = auth_statuses + .get(name.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); + stdio_rows.push([ + name.clone(), + command.clone(), + args_display, + env_display, + cwd_display, + status, + auth_status, + ]); } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + .. } => { + let status = if cfg.enabled { + "enabled".to_string() + } else { + "disabled".to_string() + }; + let auth_status = auth_statuses + .get(name.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); http_rows.push([ name.clone(), url.clone(), bearer_token_env_var.clone().unwrap_or("-".to_string()), + status, + auth_status, ]); } } } if !stdio_rows.is_empty() { - let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; + let mut widths = [ + "Name".len(), + "Command".len(), + "Args".len(), + "Env".len(), + "Cwd".len(), + "Status".len(), + "Auth".len(), + ]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -433,28 +543,40 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: } if !http_rows.is_empty() { - let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()]; + let mut widths = [ + "Name".len(), + "Url".len(), + "Bearer Token Env Var".len(), + "Status".len(), + "Auth".len(), + ]; for row in &http_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -472,24 +600,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: 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, "startup_timeout_sec": server .startup_timeout_sec @@ -539,8 +688,15 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re } println!("{}", get_args.name); + println!(" enabled: {}", server.enabled); 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() { @@ -549,10 +705,27 @@ 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 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 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 @@ -561,17 +734,22 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re .collect::>() .join(", ") } + _ => "-".to_string(), }; - println!(" env: {env_display}"); - } - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - } => { - 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}"); + 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, v)| format!("{k}={v}")) + .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/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 9ccb9f73d0..7a6c2daaff 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -28,13 +28,22 @@ 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:?}"), } + assert!(docs.enabled); let mut remove_cmd = codex_command(codex_home.path())?; remove_cmd @@ -90,6 +99,7 @@ async fn add_with_env_preserves_key_order_and_values() -> Result<()> { assert_eq!(env.len(), 2); assert_eq!(env.get("FOO"), Some(&"bar".to_string())); assert_eq!(env.get("ALPHA"), Some(&"beta".to_string())); + assert!(envy.enabled); Ok(()) } @@ -110,12 +120,17 @@ 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:?}"), } + assert!(github.enabled); assert!(!codex_home.path().join(".credentials.json").exists()); assert!(!codex_home.path().join(".env").exists()); @@ -147,12 +162,17 @@ 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:?}"), } + assert!(issues.enabled); Ok(()) } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 6c83de19fa..ea0d6fc1d9 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,10 @@ use std::path::Path; use anyhow::Result; +use codex_core::config::load_global_mcp_servers; +use codex_core::config::write_global_mcp_servers; +use codex_core::config_types::McpServerTransportConfig; +use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; @@ -26,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())?; @@ -45,6 +49,18 @@ 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:?}"), + } + write_global_mcp_servers(codex_home.path(), &servers)?; + let mut list_cmd = codex_command(codex_home.path())?; let list_output = list_cmd.args(["mcp", "list"]).output()?; assert!(list_output.status.success()); @@ -53,6 +69,12 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); + assert!(stdout.contains("Status")); + assert!(stdout.contains("Auth")); + assert!(stdout.contains("enabled")); + assert!(stdout.contains("Unsupported")); let mut list_json_cmd = codex_command(codex_home.path())?; let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?; @@ -64,6 +86,7 @@ fn list_and_get_render_expected_output() -> Result<()> { json!([ { "name": "docs", + "enabled": true, "transport": { "type": "stdio", "command": "docs-server", @@ -73,10 +96,16 @@ 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 + "tool_timeout_sec": null, + "auth_status": "unsupported" } ] ) @@ -91,6 +120,9 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); assert!(stdout.contains("env: TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); + assert!(stdout.contains("enabled: true")); assert!(stdout.contains("remove: codex mcp remove docs")); let mut get_json_cmd = codex_command(codex_home.path())?; @@ -98,7 +130,7 @@ fn list_and_get_render_expected_output() -> Result<()> { .args(["mcp", "get", "docs", "--json"]) .assert() .success() - .stdout(contains("\"name\": \"docs\"")); + .stdout(contains("\"name\": \"docs\"").and(contains("\"enabled\": true"))); 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/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 81125aeb1c..4122aeff68 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -1,3 +1,4 @@ +use clap::Args; use clap::Parser; use codex_common::CliConfigOverrides; @@ -6,4 +7,43 @@ use codex_common::CliConfigOverrides; pub struct Cli { #[clap(skip)] pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, clap::Subcommand)] +pub enum Command { + /// Submit a new Codex Cloud task without launching the TUI. + Exec(ExecCommand), +} + +#[derive(Debug, Args)] +pub struct ExecCommand { + /// Task prompt to run in Codex Cloud. + #[arg(value_name = "QUERY")] + pub query: Option, + + /// Target environment identifier (see `codex cloud` to browse). + #[arg(long = "env", value_name = "ENV_ID")] + pub environment: String, + + /// Number of assistant attempts (best-of-N). + #[arg( + long = "attempts", + default_value_t = 1usize, + value_parser = parse_attempts + )] + pub attempts: usize, +} + +fn parse_attempts(input: &str) -> Result { + let value: usize = input + .parse() + .map_err(|_| "attempts must be an integer between 1 and 4".to_string())?; + if (1..=4).contains(&value) { + Ok(value) + } else { + Err("attempts must be between 1 and 4".to_string()) + } } diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 69490e1c9a..6087cbea5b 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -7,7 +7,9 @@ mod ui; pub mod util; pub use cli::Cli; +use anyhow::anyhow; use std::io::IsTerminal; +use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -23,6 +25,175 @@ struct ApplyJob { diff_override: Option, } +struct BackendContext { + backend: Arc, + base_url: String, +} + +async fn init_backend(user_agent_suffix: &str) -> anyhow::Result { + let use_mock = matches!( + std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), + Some("mock") | Some("MOCK") + ); + let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); + + set_user_agent_suffix(user_agent_suffix); + + if use_mock { + return Ok(BackendContext { + backend: Arc::new(codex_cloud_tasks_client::MockClient), + base_url, + }); + } + + let ua = codex_core::default_client::get_codex_user_agent(); + let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); + let style = if base_url.contains("/backend-api") { + "wham" + } else { + "codex-api" + }; + append_error_log(format!("startup: base_url={base_url} path_style={style}")); + + let auth = match codex_core::config::find_codex_home() + .ok() + .map(|home| codex_login::AuthManager::new(home, false)) + .and_then(|am| am.auth()) + { + Some(auth) => auth, + None => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + }; + + if let Some(acc) = auth.get_account_id() { + append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); + } + + let token = match auth.get_token().await { + Ok(t) if !t.is_empty() => t, + _ => { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } + }; + + http = http.with_bearer_token(token.clone()); + if let Some(acc) = auth + .get_account_id() + .or_else(|| util::extract_chatgpt_account_id(&token)) + { + append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); + http = http.with_chatgpt_account_id(acc); + } + + Ok(BackendContext { + backend: Arc::new(http), + base_url, + }) +} + +async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> { + let crate::cli::ExecCommand { + query, + environment, + attempts, + } = args; + let ctx = init_backend("codex_cloud_tasks_exec").await?; + let prompt = resolve_query_input(query)?; + let env_id = resolve_environment_id(&ctx, &environment).await?; + let created = codex_cloud_tasks_client::CloudBackend::create_task( + &*ctx.backend, + &env_id, + &prompt, + "main", + false, + attempts, + ) + .await?; + let url = util::task_url(&ctx.base_url, &created.id.0); + println!("{url}"); + Ok(()) +} + +async fn resolve_environment_id(ctx: &BackendContext, requested: &str) -> anyhow::Result { + let trimmed = requested.trim(); + if trimmed.is_empty() { + return Err(anyhow!("environment id must not be empty")); + } + let normalized = util::normalize_base_url(&ctx.base_url); + let headers = util::build_chatgpt_headers().await; + let environments = crate::env_detect::list_environments(&normalized, &headers).await?; + if environments.is_empty() { + return Err(anyhow!( + "no cloud environments are available for this workspace" + )); + } + + if let Some(row) = environments.iter().find(|row| row.id == trimmed) { + return Ok(row.id.clone()); + } + + let label_matches = environments + .iter() + .filter(|row| { + row.label + .as_deref() + .map(|label| label.eq_ignore_ascii_case(trimmed)) + .unwrap_or(false) + }) + .collect::>(); + match label_matches.as_slice() { + [] => Err(anyhow!( + "environment '{trimmed}' not found; run `codex cloud` to list available environments" + )), + [single] => Ok(single.id.clone()), + [first, rest @ ..] => { + let first_id = &first.id; + if rest.iter().all(|row| row.id == *first_id) { + Ok(first_id.clone()) + } else { + Err(anyhow!( + "environment label '{trimmed}' is ambiguous; run `codex cloud` to pick the desired environment id" + )) + } + } + } +} + +fn resolve_query_input(query_arg: Option) -> anyhow::Result { + match query_arg { + Some(q) if q != "-" => Ok(q), + maybe_dash => { + let force_stdin = matches!(maybe_dash.as_deref(), Some("-")); + if std::io::stdin().is_terminal() && !force_stdin { + return Err(anyhow!( + "no query provided. Pass one as an argument or pipe it via stdin." + )); + } + if !force_stdin { + eprintln!("Reading query from stdin..."); + } + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .map_err(|e| anyhow!("failed to read query from stdin: {e}"))?; + if buffer.trim().is_empty() { + return Err(anyhow!( + "no query provided via stdin (received empty input)." + )); + } + Ok(buffer) + } + } +} + fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { match status { codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, @@ -148,7 +319,14 @@ fn spawn_apply( // (no standalone patch summarizer needed – UI displays raw diffs) /// Entry point for the `codex cloud` subcommand. -pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { +pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { + if let Some(command) = cli.command { + return match command { + crate::cli::Command::Exec(args) => run_exec_command(args).await, + }; + } + let Cli { .. } = cli; + // Very minimal logging setup; mirrors other crates' pattern. let default_level = "error"; let _ = tracing_subscriber::fmt() @@ -162,72 +340,8 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a .try_init(); info!("Launching Cloud Tasks list UI"); - set_user_agent_suffix("codex_cloud_tasks_tui"); - - // Default to online unless explicitly configured to use mock. - let use_mock = matches!( - std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), - Some("mock") | Some("MOCK") - ); - - let backend: Arc = if use_mock { - Arc::new(codex_cloud_tasks_client::MockClient) - } else { - // Build an HTTP client against the configured (or default) base URL. - let base_url = std::env::var("CODEX_CLOUD_TASKS_BASE_URL") - .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()); - let ua = codex_core::default_client::get_codex_user_agent(); - let mut http = - codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); - // Log which base URL and path style we're going to use. - let style = if base_url.contains("/backend-api") { - "wham" - } else { - "codex-api" - }; - append_error_log(format!("startup: base_url={base_url} path_style={style}")); - - // Require ChatGPT login (SWIC). Exit with a clear message if missing. - let _token = match codex_core::config::find_codex_home() - .ok() - .map(|home| codex_login::AuthManager::new(home, false)) - .and_then(|am| am.auth()) - { - Some(auth) => { - // Log account context for debugging workspace selection. - if let Some(acc) = auth.get_account_id() { - append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); - } - match auth.get_token().await { - Ok(t) if !t.is_empty() => { - // Attach token and ChatGPT-Account-Id header if available - http = http.with_bearer_token(t.clone()); - if let Some(acc) = auth - .get_account_id() - .or_else(|| util::extract_chatgpt_account_id(&t)) - { - append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); - http = http.with_chatgpt_account_id(acc); - } - t - } - _ => { - eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." - ); - std::process::exit(1); - } - } - } - None => { - eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." - ); - std::process::exit(1); - } - }; - Arc::new(http) - }; + let BackendContext { backend, .. } = init_backend("codex_cloud_tasks_tui").await?; + let backend = backend; // Terminal setup use crossterm::ExecutableCommand; diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 8003a02f1e..5d160e54fa 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -91,3 +91,18 @@ pub async fn build_chatgpt_headers() -> HeaderMap { } headers } + +/// Construct a browser-friendly task URL for the given backend base URL. +pub fn task_url(base_url: &str, task_id: &str) -> String { + let normalized = normalize_base_url(base_url); + if let Some(root) = normalized.strip_suffix("/backend-api") { + return format!("{root}/codex/tasks/{task_id}"); + } + if let Some(root) = normalized.strip_suffix("/api/codex") { + return format!("{root}/codex/tasks/{task_id}"); + } + if normalized.ends_with("/codex") { + return format!("{normalized}/tasks/{task_id}"); + } + format!("{normalized}/codex/tasks/{task_id}") +} 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..640be30783 --- /dev/null +++ b/codex-rs/common/src/format_env_display.rs @@ -0,0 +1,66 @@ +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, value)| format!("{key}={value}")), + ); + } + + if !env_vars.is_empty() { + parts.extend(env_vars.iter().map(|var| format!("{var}=${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=one, B=two"); + } + + #[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=$TOKEN, PATH=$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=/tmp, TOKEN=$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/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..fdc1136f08 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,7 +26,9 @@ codex-mcp-client = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-async-utils = { workspace = true } codex-utils-string = { workspace = true } +codex-utils-pty = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } @@ -36,7 +38,6 @@ indexmap = { workspace = true } 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"] } @@ -47,6 +48,7 @@ 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", 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/src/auth.rs b/codex-rs/core/src/auth.rs index 4eea313e66..92074c66ee 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -2,6 +2,8 @@ use chrono::DateTime; use chrono::Utc; use serde::Deserialize; use serde::Serialize; +#[cfg(test)] +use serial_test::serial; use std::env; use std::fs::File; use std::fs::OpenOptions; @@ -16,7 +18,9 @@ use std::sync::Mutex; use std::time::Duration; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; +use crate::config::Config; use crate::token_data::PlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; @@ -135,6 +139,10 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.account_id) } + pub fn get_account_email(&self) -> Option { + self.get_current_token_data().and_then(|t| t.id_token.email) + } + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() .and_then(|t| t.id_token.chatgpt_plan_type) @@ -229,6 +237,74 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( write_auth_json(&get_auth_file(codex_home), &auth_dot_json) } +pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { + let Some(auth) = load_auth(&config.codex_home, true)? 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); + } + } + + 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." + ), + ); + } + }; + + // 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); + } + } + + Ok(()) +} + +fn logout_with_message(codex_home: &Path, message: String) -> std::io::Result<()> { + match logout(codex_home) { + 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, @@ -245,9 +321,8 @@ fn load_auth( 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); - } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), }; let AuthDotJson { @@ -399,17 +474,19 @@ struct CachedAuth { #[cfg(test)] mod tests { use super::*; + 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 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() { let codex_home = tempdir().unwrap(); @@ -417,6 +494,7 @@ mod tests { AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) @@ -456,6 +534,13 @@ mod tests { assert!(auth.tokens.is_none(), "tokens should be cleared"); } + #[test] + fn missing_auth_json_returns_none() { + let dir = tempdir().unwrap(); + let auth = CodexAuth::from_codex_home(dir.path()).expect("call should succeed"); + assert_eq!(auth, None); + } + #[tokio::test] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); @@ -463,6 +548,7 @@ mod tests { AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) @@ -480,6 +566,10 @@ mod tests { 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, @@ -487,20 +577,17 @@ mod tests { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), chatgpt_plan_type: Some(PlanType::Known(KnownPlan::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] @@ -539,6 +626,7 @@ mod tests { 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 { @@ -553,15 +641,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)?); @@ -576,12 +670,159 @@ 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").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] + 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] + 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").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.") + ); + } } /// Central manager providing a single source of truth for auth.json derived diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index d6f394fb86..aeea012948 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -5,11 +5,13 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; 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; @@ -309,7 +311,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 +356,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; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3ea2ca79b5..a88ad778ed 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::AuthManager; use crate::auth::CodexAuth; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use bytes::Bytes; @@ -43,12 +45,15 @@ 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 chrono::DateTime; +use chrono::Utc; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -68,7 +73,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)] @@ -107,10 +112,12 @@ impl ModelClient { } } - 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 { @@ -123,8 +130,16 @@ impl ModelClient { /// 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(crate) async fn stream_with_task_kind( + &self, + prompt: &Prompt, + task_kind: TaskKind, + ) -> Result { match self.provider.wire_api { - WireApi::Responses => self.stream_responses(prompt).await, + WireApi::Responses => self.stream_responses(prompt, task_kind).await, WireApi::Chat => { // Create the raw streaming connection first. let response_stream = stream_chat_completions( @@ -165,7 +180,11 @@ impl ModelClient { } /// Implementation for the OpenAI *Responses* experimental API. - async fn stream_responses(&self, prompt: &Prompt) -> Result { + async fn stream_responses( + &self, + prompt: &Prompt, + task_kind: TaskKind, + ) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { // short circuit for tests warn!(path, "Streaming from fixture"); @@ -244,7 +263,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) + .attempt_stream_responses(attempt, &payload_json, &auth_manager, task_kind) .await { Ok(stream) => { @@ -272,6 +291,7 @@ 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 +314,7 @@ impl ModelClient { .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() @@ -336,7 +357,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, @@ -397,10 +423,12 @@ 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)); @@ -416,7 +444,9 @@ impl ModelClient { request_id, }) } - Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), + Err(e) => Err(StreamAttemptError::RetryableTransportError( + CodexErr::ConnectionFailed(ConnectionFailedError { source: e }), + )), } } @@ -514,11 +544,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 { @@ -541,12 +571,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]) { @@ -581,14 +611,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 }) @@ -603,17 +633,17 @@ fn parse_rate_limit_window( 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_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, }) }) } @@ -625,8 +655,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> { @@ -1015,6 +1045,7 @@ mod tests { "test", "test", None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), @@ -1365,7 +1396,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); @@ -1379,20 +1410,20 @@ 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 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))); @@ -1401,13 +1432,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..397b09dd7c 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -281,7 +281,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 356e25ed3a..170e00c71e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,6 +8,7 @@ use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::event_mapping::map_response_item_to_event_messages; use crate::function_tool::FunctionCallError; +use crate::parse_command::parse_command; use crate::review_format::format_review_findings_block; use crate::terminal; use crate::user_notification::UserNotifier; @@ -17,6 +18,7 @@ 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::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -27,10 +29,17 @@ 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; @@ -48,21 +57,14 @@ use crate::conversation_history::ConversationHistory; 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; @@ -75,13 +77,9 @@ 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::ReviewDecision; use crate::protocol::ReviewOutputEvent; @@ -98,18 +96,23 @@ use crate::rollout::RolloutRecorderParams; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; +use crate::state::SessionState; +use crate::state::TaskKind; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::ReviewTask; 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::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; @@ -158,7 +161,7 @@ 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, @@ -167,13 +170,13 @@ impl Codex { base_instructions: config.base_instructions.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), }; // Generate a unique ID for the lifetime of this Codex session. - let (session, turn_context) = Session::new( - configure_session, + let session = Session::new( + session_configuration, config.clone(), auth_manager.clone(), tx_event.clone(), @@ -188,7 +191,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, @@ -232,8 +235,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. @@ -262,6 +263,7 @@ pub(crate) struct TurnContext { 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, } impl TurnContext { @@ -272,8 +274,8 @@ impl TurnContext { } } -/// Configure the model session. -struct ConfigureSession { +#[derive(Clone)] +pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, @@ -294,8 +296,6 @@ struct ConfigureSession { /// 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** @@ -304,32 +304,119 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + + // TODO(pakrym): Remove config from here + original_config_do_not_use: Arc, +} + +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, + ) -> 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, + ); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &config.features, + }); + + TurnContext { + client, + cwd: session_configuration.cwd.clone(), + base_instructions: session_configuration.base_instructions.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, + is_review_mode: false, + final_output_json_schema: None, + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + } + } + 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 { @@ -339,7 +426,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, - user_instructions.clone(), + session_configuration.user_instructions.clone(), session_source, ), ) @@ -363,23 +450,38 @@ impl Session { let mcp_fut = McpConnectionManager::new( config.mcp_servers.clone(), - config.use_experimental_use_rmcp_client, + config + .features + .enabled(crate::features::Feature::RmcpClient), config.mcp_oauth_credentials_store_mode, ); let default_shell_fut = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); + let auth_statuses_fut = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ); // Join all independent futures. - let (rollout_recorder, mcp_res, default_shell, (history_log_id, history_entry_count)) = - tokio::join!(rollout_fut, mcp_fut, default_shell_fut, history_meta_fut); + let ( + rollout_recorder, + mcp_res, + default_shell, + (history_log_id, history_entry_count), + auth_statuses, + ) = tokio::join!( + rollout_fut, + mcp_fut, + default_shell_fut, + history_meta_fut, + auth_statuses_fut + ); let rollout_recorder = rollout_recorder.map_err(|e| { error!("failed to initialize rollout recorder: {e:#}"); 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 { @@ -398,11 +500,27 @@ impl Session { // Surface individual client start-up failures to the user. if !failed_clients.is_empty() { for (server_name, err) in failed_clients { - let message = format!("MCP client for `{server_name}` failed to start: {err:#}"); - error!("{message}"); + let auth_status = auth_statuses.get(&server_name); + let requires_login = match auth_status { + Some(McpAuthStatus::NotLoggedIn) => true, + Some(McpAuthStatus::OAuth) => is_mcp_client_auth_required_error(&err), + _ => false, + }; + let log_message = + format!("MCP client for `{server_name}` failed to start: {err:#}"); + error!("{log_message}"); + let display_message = if requires_login { + 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 { id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { message }), + msg: EventMsg::Error(ErrorEvent { + message: display_message, + }), }); } } @@ -412,6 +530,7 @@ impl Session { config.model.as_str(), config.model_family.slug.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), + auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), config.otel.log_user_prompt, terminal::user_agent(), @@ -430,50 +549,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, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, - }), - 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 { @@ -488,15 +576,14 @@ 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; + sess.record_initial_history(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, @@ -508,7 +595,7 @@ impl Session { sess.send_event(event).await; } - Ok((sess, turn_context)) + Ok(sess) } pub(crate) fn get_tx_event(&self) -> Sender { @@ -522,15 +609,12 @@ 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); + let items = self.build_initial_context(&turn_context); self.record_conversation_items(&items).await; } InitialHistory::Resumed(_) | InitialHistory::Forked(_) => { @@ -539,7 +623,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; } @@ -552,6 +636,51 @@ impl Session { } } + 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 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, + ); + 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) { // Persist the event into rollout (recorder filters as needed) @@ -592,6 +721,7 @@ impl Session { warn!("Overwriting existing pending approval for sub_id: {event_id}"); } + let parsed_cmd = parse_command(&command); let event = Event { id: event_id, msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -599,6 +729,7 @@ impl Session { command, cwd, reason, + parsed_cmd, }), }; self.send_event(event).await; @@ -821,157 +952,6 @@ impl Session { } } - async fn on_exec_command_begin( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - exec_command_context: ExecCommandContext, - ) { - 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) - .into_iter() - .map(Into::into) - .collect(), - }), - }; - let event = Event { - id: sub_id.to_string(), - msg, - }; - self.send_event(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, - ) { - 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; - } - } - } - /// 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(); - - self.on_exec_command_begin(turn_diff_tracker.clone(), context.clone()) - .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. @@ -1029,6 +1009,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, @@ -1047,61 +1060,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 } @@ -1115,20 +1078,8 @@ 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"); @@ -1144,186 +1095,58 @@ 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()) + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + sandbox_policy, + model, + reasoning_effort: effort, + reasoning_summary: summary, + ..Default::default() }; + sess.update_settings(updates).await; + } - // 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, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, - }); - - 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( + Op::UserInput { .. } | Op::UserTurn { .. } => { + let (items, updates) = match sub.op { + Op::UserTurn { cwd, approval_policy, sandbox_policy, - // Shell is not configurable from turn to turn - None, - ))]) - .await; - } - } - 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::UserTurn { - items, - cwd, - approval_policy, - sandbox_policy, - model, - 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(); - - // 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()); - - // 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, + model, effort, summary, - sess.conversation_id, - ); - - let fresh_turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config - .use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config - .use_experimental_unified_exec_tool, - }), - 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)) + 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!(), + }; + let current_context = sess.new_turn(updates).await; + current_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 { + if let Some(env_item) = sess + .build_environment_update_item(previous_context.as_ref(), ¤t_context) + { + sess.record_conversation_items(std::slice::from_ref(&env_item)) .await; for msg in map_response_item_to_event_messages( - &env_response_item, + &env_item, sess.show_raw_agent_reasoning(), ) { let event = Event { @@ -1334,12 +1157,9 @@ async fn submission_loop( } } - // 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) + sess.spawn_task(Arc::clone(¤t_context), sub.id, items, RegularTask) .await; + previous_context = Some(current_context); } } Op::ExecApproval { id, decision } => match decision { @@ -1403,10 +1223,25 @@ async fn submission_loop( // This is a cheap lookup from the connection manager's cache. let tools = sess.services.mcp_connection_manager.list_all_tools(); + let (auth_statuses, 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 event = Event { id: sub_id, msg: EventMsg::McpListToolsResponse( - crate::protocol::McpListToolsResponseEvent { tools }, + crate::protocol::McpListToolsResponseEvent { + tools, + resources, + resource_templates, + auth_statuses, + }, ), }; sess.send_event(event).await; @@ -1430,6 +1265,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Compact => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; // Attempt to inject input into current task if let Err(items) = sess .inject_input(vec![InputItem::Text { @@ -1499,6 +1335,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Review { review_request } => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; spawn_review_thread( sess.clone(), config.clone(), @@ -1527,14 +1364,15 @@ 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. + 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); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, - include_plan_tool: false, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &review_features, }); let base_instructions = REVIEW_PROMPT.to_string(); @@ -1583,6 +1421,7 @@ async fn spawn_review_thread( 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(), }; // Seed the child task with the review prompt as the initial user message. @@ -1625,6 +1464,8 @@ pub(crate) async fn run_task( turn_context: Arc, sub_id: String, input: Vec, + task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> Option { if input.is_empty() { return None; @@ -1708,6 +1549,8 @@ pub(crate) async fn run_task( Arc::clone(&turn_diff_tracker), sub_id.clone(), turn_input, + task_kind, + cancellation_token.child_token(), ) .await { @@ -1724,7 +1567,7 @@ 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(); @@ -1860,6 +1703,7 @@ pub(crate) async fn run_task( ); sess.notifier() .notify(&UserNotification::AgentTurnComplete { + thread_id: sess.conversation_id.to_string(), turn_id: sub_id.clone(), input_messages: turn_input_messages, last_assistant_message: last_agent_message.clone(), @@ -1868,6 +1712,10 @@ pub(crate) async fn run_task( } continue; } + Err(CodexErr::TurnAborted) => { + // Aborted turn is reported via a different event. + break; + } Err(e) => { info!("Turn error: {e:#}"); let event = Event { @@ -1933,6 +1781,8 @@ async fn run_turn( 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( @@ -1962,10 +1812,13 @@ async fn run_turn( Arc::clone(&turn_diff_tracker), &sub_id, &prompt, + task_kind, + cancellation_token.child_token(), ) .await { Ok(output) => return Ok(output), + Err(CodexErr::TurnAborted) => return Err(CodexErr::TurnAborted), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(e @ CodexErr::Fatal(_)) => return Err(e), @@ -1999,9 +1852,7 @@ async fn run_turn( // at a seemingly frozen screen. sess.notify_stream_error( &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), + format!("Re-connecting... {retries}/{max_retries}"), ) .await; @@ -2030,6 +1881,7 @@ struct TurnRunResult { total_token_usage: Option, } +#[allow(clippy::too_many_arguments)] async fn try_run_turn( router: Arc, sess: Arc, @@ -2037,6 +1889,8 @@ async fn try_run_turn( 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 @@ -2102,7 +1956,12 @@ async fn try_run_turn( summary: turn_context.client.get_reasoning_summary(), }); sess.persist_rollout_items(&[rollout_item]).await; - let mut stream = turn_context.client.clone().stream(&prompt).await?; + let mut stream = turn_context + .client + .clone() + .stream_with_task_kind(prompt.as_ref(), task_kind) + .or_cancel(&cancellation_token) + .await??; let tool_runtime = ToolCallRuntime::new( Arc::clone(&router), @@ -2118,7 +1977,8 @@ async fn try_run_turn( // 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 = stream.next().or_cancel(&cancellation_token).await?; + let event = match event { Some(res) => res?, None => { @@ -2183,7 +2043,8 @@ 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 { @@ -2222,7 +2083,10 @@ async fn try_run_turn( sess.update_token_usage_info(sub_id, turn_context.as_ref(), token_usage.as_ref()) .await; - let processed_items: Vec = output.try_collect().await?; + let processed_items = output + .try_collect() + .or_cancel(&cancellation_token) + .await??; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; @@ -2431,10 +2295,11 @@ pub(crate) async fn exit_review_mode( .await; } -use crate::executor::errors::ExecError; -use crate::executor::linkers::PreparedExec; -use crate::tools::context::ApplyPatchCommandContext; -use crate::tools::context::ExecCommandContext; +fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { + // StreamableHttpError::AuthRequired from the MCP SDK. + error.to_string().contains("Auth required") +} + #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2443,6 +2308,8 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::exec::ExecToolCallOutput; + use crate::tools::format_exec_output_str; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; @@ -2460,6 +2327,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use std::time::Duration; + use tokio::time::sleep; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -2469,8 +2338,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() { @@ -2487,14 +2354,13 @@ 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() }); assert_eq!(expected, actual); @@ -2505,9 +2371,7 @@ 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() }); assert_eq!(expected, actual); @@ -2712,6 +2576,7 @@ mod tests { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), @@ -2730,58 +2595,51 @@ 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 tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, - }); - let turn_context = TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + 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, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.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), }; + + 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 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), }; + + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + ); (session, turn_context) } @@ -2803,68 +2661,64 @@ 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 tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, - }); - let turn_context = Arc::new(TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + 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, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.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), + }; + + 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 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), }); + + 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, + )); (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( @@ -2873,20 +2727,26 @@ mod tests { _ctx: Arc, _sub_id: String, _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 { + if let TaskKind::Review = self.kind { 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(); @@ -2897,7 +2757,41 @@ mod tests { 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 sub_id = "sub-regular".to_string(); + let input = vec![InputItem::Text { + text: "hello".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + sub_id.clone(), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; @@ -2911,7 +2805,7 @@ 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(); @@ -2922,18 +2816,27 @@ mod tests { Arc::clone(&tc), sub_id.clone(), input, - NeverEndingTask(TaskKind::Review), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: false, + }, ) .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let first = rx.recv().await.expect("first event"); + let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for first event") + .expect("first event"); match first.msg { EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), other => panic!("unexpected first event: {other:?}"), } - let second = rx.recv().await.expect("second event"); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for second event") + .expect("second event"); match second.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected second event: {other:?}"), @@ -3132,6 +3035,7 @@ mod tests { env: HashMap::new(), with_escalated_permissions: Some(true), justification: Some("test".to_string()), + arg0: None, }; let params2 = ExecParams { diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index d35df99c8d..93bbfa79c6 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -16,6 +16,7 @@ use crate::protocol::InputItem; use crate::protocol::InputMessageKind; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; +use crate::state::TaskKind; use crate::truncate::truncate_middle; use crate::util::backoff; use askama::Template; @@ -136,9 +137,7 @@ async fn run_compact_task_inner( let delay = backoff(retries); sess.notify_stream_error( &sub_id, - format!( - "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" - ), + format!("Re-connecting... {retries}/{max_retries}"), ) .await; tokio::time::sleep(delay).await; @@ -260,7 +259,11 @@ async fn drain_to_completed( sub_id: &str, prompt: &Prompt, ) -> CodexResult<()> { - let mut stream = turn_context.client.clone().stream(prompt).await?; + let mut stream = turn_context + .client + .clone() + .stream_with_task_kind(prompt, TaskKind::Compact) + .await?; loop { let maybe_event = stream.next().await; let Some(event) = maybe_event else { diff --git a/codex-rs/core/src/command_safety/is_safe_command.rs b/codex-rs/core/src/command_safety/is_safe_command.rs index c1273caa94..f076744906 100644 --- a/codex-rs/core/src/command_safety/is_safe_command.rs +++ b/codex-rs/core/src/command_safety/is_safe_command.rs @@ -1,15 +1,25 @@ use crate::bash::parse_bash_lc_plain_commands; pub fn is_known_safe_command(command: &[String]) -> bool { + let command: Vec = command + .iter() + .map(|s| { + if s == "zsh" { + "bash".to_string() + } else { + s.clone() + } + }) + .collect(); #[cfg(target_os = "windows")] { use super::windows_safe_commands::is_safe_command_windows; - if is_safe_command_windows(command) { + if is_safe_command_windows(&command) { return true; } } - if is_safe_to_call_with_exec(command) { + if is_safe_to_call_with_exec(&command) { return true; } @@ -19,7 +29,7 @@ pub fn is_known_safe_command(command: &[String]) -> bool { // introduce side effects ( "&&", "||", ";", and "|" ). If every // individual command in the script is itself a known‑safe command, then // the composite expression is considered safe. - if let Some(all_commands) = parse_bash_lc_plain_commands(command) + if let Some(all_commands) = parse_bash_lc_plain_commands(&command) && !all_commands.is_empty() && all_commands .iter() @@ -31,9 +41,14 @@ pub fn is_known_safe_command(command: &[String]) -> bool { } fn is_safe_to_call_with_exec(command: &[String]) -> bool { - let cmd0 = command.first().map(String::as_str); + let Some(cmd0) = command.first().map(String::as_str) else { + return false; + }; - match cmd0 { + match std::path::Path::new(&cmd0) + .file_name() + .and_then(|osstr| osstr.to_str()) + { #[rustfmt::skip] Some( "cat" | @@ -103,13 +118,12 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { // Rust Some("cargo") if command.get(1).map(String::as_str) == Some("check") => true, - // Special-case `sed -n {N|M,N}p FILE` + // Special-case `sed -n {N|M,N}p` Some("sed") if { - command.len() == 4 + command.len() <= 4 && command.get(1).map(String::as_str) == Some("-n") && is_valid_sed_n_arg(command.get(2).map(String::as_str)) - && command.get(3).map(String::is_empty) == Some(false) } => { true diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index d0437163fc..93d0ad4ba0 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -7,6 +7,7 @@ use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; use crate::config_types::McpServerConfig; use crate::config_types::McpServerTransportConfig; +use crate::config_types::Notice; use crate::config_types::Notifications; use crate::config_types::OtelConfig; use crate::config_types::OtelConfigToml; @@ -17,6 +18,10 @@ use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; +use crate::features::Feature; +use crate::features::FeatureOverrides; +use crate::features::Features; +use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; use crate::model_family::ModelFamily; use crate::model_family::derive_default_model_family; @@ -24,18 +29,23 @@ use crate::model_family::find_family_for_model; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; +use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; +use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use anyhow::Context; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; +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_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; +use dunce::canonicalize; use serde::Deserialize; +use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -75,10 +85,10 @@ pub struct Config { pub model_family: ModelFamily, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -94,6 +104,10 @@ pub struct Config { pub sandbox_policy: SandboxPolicy, + /// True if the user passed in an override or set a value in config.toml + /// for either of approval_policy or sandbox_mode. + pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, + pub shell_environment_policy: ShellEnvironmentPolicy, /// When `true`, `AgentReasoning` events emitted by the backend will be @@ -196,6 +210,12 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// When set, restricts ChatGPT login to a specific workspace identifier. + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + pub forced_login_method: Option, + /// Include an experimental plan tool that the model can use to update its current plan and status of each step. pub include_plan_tool: bool, @@ -218,12 +238,22 @@ pub struct Config { /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, + /// Centralized feature flags; source of truth for feature gating. + pub features: Features, + /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, + /// The currently active project config, resolved by checking if cwd: + /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd + pub active_project: ProjectConfig, + /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: bool, + /// Collection of various notices we show the user + pub notices: Notice, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -366,7 +396,13 @@ pub fn write_global_mcp_servers( let mut entry = TomlTable::new(); entry.set_implicit(false); match &config.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { entry["command"] = toml_edit::value(command.clone()); if !args.is_empty() { @@ -389,18 +425,57 @@ pub fn write_global_mcp_servers( } entry["env"] = TomlItem::Table(env_table); } + + if !env_vars.is_empty() { + entry["env_vars"] = + TomlItem::Value(env_vars.iter().collect::().into()); + } + + if let Some(cwd) = cwd { + entry["cwd"] = toml_edit::value(cwd.to_string_lossy().to_string()); + } } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { entry["url"] = toml_edit::value(url.clone()); if let Some(env_var) = bearer_token_env_var { entry["bearer_token_env_var"] = toml_edit::value(env_var.clone()); } + if let Some(headers) = http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["http_headers"] = TomlItem::Table(table); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["env_http_headers"] = TomlItem::Table(table); + } } } + if !config.enabled { + entry["enabled"] = toml_edit::value(false); + } + if let Some(timeout) = config.startup_timeout_sec { entry["startup_timeout_sec"] = toml_edit::value(timeout.as_secs_f64()); } @@ -535,6 +610,54 @@ pub fn set_windows_wsl_setup_acknowledged( Ok(()) } +/// Persist the acknowledgement flag for the full access warning prompt. +pub fn set_hide_full_access_warning(codex_home: &Path, acknowledged: bool) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match std::fs::read_to_string(config_path.clone()) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + let notices_table = load_or_create_top_level_table(&mut doc, Notice::TABLE_KEY)?; + + notices_table["hide_full_access_warning"] = toml_edit::value(acknowledged); + + std::fs::create_dir_all(codex_home)?; + let tmp_file = NamedTempFile::new_in(codex_home)?; + std::fs::write(tmp_file.path(), doc.to_string())?; + tmp_file.persist(config_path)?; + + Ok(()) +} + +fn load_or_create_top_level_table<'a>( + doc: &'a mut DocumentMut, + key: &str, +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut created_table = false; + + let root = doc.as_table_mut(); + let needs_table = + !root.contains_key(key) || root.get(key).and_then(|item| item.as_table()).is_none(); + if needs_table { + root.insert(key, toml_edit::table()); + created_table = true; + } + + let Some(table) = doc[key].as_table_mut() else { + return Err(anyhow::anyhow!(format!( + "table [{key}] missing after initialization" + ))); + }; + + if created_table { + table.set_implicit(true); + } + + Ok(table) +} + fn ensure_profile_table<'a>( doc: &'a mut DocumentMut, profile_name: &str, @@ -701,10 +824,10 @@ pub struct ConfigToml { pub model_provider: Option, /// Size of the context window for the model, in tokens. - pub model_context_window: Option, + pub model_context_window: Option, /// Maximum number of output tokens. - pub model_max_output_tokens: Option, + pub model_max_output_tokens: Option, /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -728,6 +851,14 @@ pub struct ConfigToml { /// System instructions. pub instructions: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] pub mcp_servers: HashMap, @@ -790,19 +921,15 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, - /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. - pub experimental_instructions_file: Option, - - pub experimental_use_exec_command_tool: Option, - pub experimental_use_unified_exec_tool: Option, - pub experimental_use_rmcp_client: Option, - pub experimental_use_freeform_apply_patch: Option, - pub projects: Option>, /// Nested tools section for feature toggles pub tools: Option, + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + pub features: Option, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -813,6 +940,17 @@ pub struct ConfigToml { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: Option, + + /// Collection of in-product notices (different from notifications) + /// See [`crate::config_types::Notices`] for more details + pub notice: Option, + + /// Legacy, now use features + pub experimental_instructions_file: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_rmcp_client: Option, + pub experimental_use_freeform_apply_patch: Option, } impl From for UserSavedConfig { @@ -827,6 +965,8 @@ impl From for UserSavedConfig { approval_policy: config_toml.approval_policy, sandbox_mode: config_toml.sandbox_mode, sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_login_method: config_toml.forced_login_method, model: config_toml.model, model_reasoning_effort: config_toml.model_reasoning_effort, model_reasoning_summary: config_toml.model_reasoning_summary, @@ -843,6 +983,15 @@ pub struct ProjectConfig { pub trust_level: Option, } +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + match &self.trust_level { + Some(trust_level) => trust_level == "trusted", + None => false, + } + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] @@ -864,9 +1013,23 @@ impl From for Tools { impl ConfigToml { /// Derive the effective sandbox policy from the configuration. - fn derive_sandbox_policy(&self, sandbox_mode_override: Option) -> SandboxPolicy { + fn derive_sandbox_policy( + &self, + sandbox_mode_override: Option, + resolved_cwd: &Path, + ) -> SandboxPolicy { let resolved_sandbox_mode = sandbox_mode_override .or(self.sandbox_mode) + .or_else(|| { + // if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite + self.get_active_project(resolved_cwd).and_then(|p| { + if p.is_trusted() { + Some(SandboxMode::WorkspaceWrite) + } else { + None + } + }) + }) .unwrap_or_default(); match resolved_sandbox_mode { SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), @@ -888,30 +1051,26 @@ impl ConfigToml { } } - pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool { + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or a git repo for cwd + pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { let projects = self.projects.clone().unwrap_or_default(); - let is_path_trusted = |path: &Path| { - let path_str = path.to_string_lossy().to_string(); - projects - .get(&path_str) - .map(|p| p.trust_level.as_deref() == Some("trusted")) - .unwrap_or(false) - }; - - // Fast path: exact cwd match - if is_path_trusted(resolved_cwd) { - return true; + if let Some(project_config) = projects.get(&resolved_cwd.to_string_lossy().to_string()) { + return Some(project_config.clone()); } - // If cwd lives inside a git worktree, check whether the root git project + // If cwd lives inside a git repo/worktree, check whether the root git project // (the primary repository working directory) is trusted. This lets // worktrees inherit trust from the main project. - if let Some(root_project) = resolve_root_git_project_for_trust(resolved_cwd) { - return is_path_trusted(&root_project); + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) + && let Some(project_config_for_root) = + projects.get(&repo_root.to_string_lossy().to_string_lossy().to_string()) + { + return Some(project_config_for_root.clone()); } - false + None } pub fn get_config_profile( @@ -953,6 +1112,8 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + /// Additional directories that should be treated as writable roots for this session. + pub additional_writable_roots: Vec, } impl Config { @@ -970,17 +1131,18 @@ impl Config { model, review_model: override_review_model, cwd, - approval_policy, + approval_policy: approval_policy_override, sandbox_mode, model_provider, config_profile: config_profile_key, codex_linux_sandbox_exe, base_instructions, - include_plan_tool, - include_apply_patch_tool, - include_view_image_tool, + include_plan_tool: include_plan_tool_override, + include_apply_patch_tool: include_apply_patch_tool_override, + include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + additional_writable_roots, } = overrides; let active_profile_name = config_profile_key @@ -1001,7 +1163,77 @@ impl Config { None => ConfigProfile::default(), }; - let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode); + let feature_overrides = FeatureOverrides { + include_plan_tool: include_plan_tool_override, + include_apply_patch_tool: include_apply_patch_tool_override, + include_view_image_tool: include_view_image_tool_override, + web_search_request: override_tools_web_search_request, + }; + + let features = Features::from_config(&cfg, &config_profile, feature_overrides); + + let resolved_cwd = { + use std::env; + + match cwd { + None => { + tracing::info!("cwd not set, using current dir"); + env::current_dir()? + } + Some(p) if p.is_absolute() => p, + Some(p) => { + // Resolve relative path against the current working directory. + tracing::info!("cwd is relative, resolving against current dir"); + let mut current = env::current_dir()?; + current.push(p); + current + } + } + }; + let additional_writable_roots: Vec = additional_writable_roots + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + resolved_cwd.join(path) + }; + match canonicalize(&absolute) { + Ok(canonical) => canonical, + Err(_) => absolute, + } + }) + .collect(); + let active_project = cfg + .get_active_project(&resolved_cwd) + .unwrap_or(ProjectConfig { trust_level: None }); + + let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == &path) { + writable_roots.push(path); + } + } + } + let mut approval_policy = approval_policy_override + .or(config_profile.approval_policy) + .or(cfg.approval_policy) + .unwrap_or_else(|| { + if active_project.is_trusted() { + // If no explicit approval policy is set, but we trust cwd, default to OnRequest + AskForApproval::OnRequest + } else { + AskForApproval::default() + } + }); + let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override + .is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some() + // TODO(#3034): profile.sandbox_mode is not implemented + || sandbox_mode.is_some() + || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -1025,34 +1257,27 @@ impl Config { let shell_environment_policy = cfg.shell_environment_policy.into(); - let resolved_cwd = { - use std::env; - - match cwd { - None => { - tracing::info!("cwd not set, using current dir"); - env::current_dir()? - } - Some(p) if p.is_absolute() => p, - Some(p) => { - // Resolve relative path against the current working directory. - tracing::info!("cwd is relative, resolving against current dir"); - let mut current = env::current_dir()?; - current.push(p); - current - } - } - }; - let history = cfg.history.unwrap_or_default(); - let tools_web_search_request = override_tools_web_search_request - .or(cfg.tools.as_ref().and_then(|t| t.web_search)) - .unwrap_or(false); + let include_plan_tool_flag = features.enabled(Feature::PlanTool); + let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); + let include_view_image_tool_flag = features.enabled(Feature::ViewImageTool); + let tools_web_search_request = features.enabled(Feature::WebSearchRequest); + let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell); + let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); - let include_view_image_tool = include_view_image_tool - .or(cfg.tools.as_ref().and_then(|t| t.view_image)) - .unwrap_or(true); + let forced_chatgpt_workspace_id = + cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + let forced_login_method = cfg.forced_login_method; let model = model .or(config_profile.model) @@ -1100,6 +1325,10 @@ impl Config { .or(cfg.review_model) .unwrap_or_else(default_review_model); + if features.enabled(Feature::ApproveAll) { + approval_policy = AskForApproval::OnRequest; + } + let config = Self { model, review_model, @@ -1110,11 +1339,9 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, - approval_policy: approval_policy - .or(config_profile.approval_policy) - .or(cfg.approval_policy) - .unwrap_or_else(AskForApproval::default), + approval_policy, sandbox_policy, + did_user_set_custom_approval_policy_or_sandbox_mode, shell_environment_policy, notify: cfg.notify, user_instructions, @@ -1160,21 +1387,20 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), - include_plan_tool: include_plan_tool.unwrap_or(false), - include_apply_patch_tool: include_apply_patch_tool - .or(cfg.experimental_use_freeform_apply_patch) - .unwrap_or(false), + forced_chatgpt_workspace_id, + forced_login_method, + include_plan_tool: include_plan_tool_flag, + include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - use_experimental_streamable_shell_tool: cfg - .experimental_use_exec_command_tool - .unwrap_or(false), - use_experimental_unified_exec_tool: cfg - .experimental_use_unified_exec_tool - .unwrap_or(false), - use_experimental_use_rmcp_client: cfg.experimental_use_rmcp_client.unwrap_or(false), - include_view_image_tool, + use_experimental_streamable_shell_tool, + use_experimental_unified_exec_tool, + use_experimental_use_rmcp_client, + include_view_image_tool: include_view_image_tool_flag, + features, active_profile: active_profile_name, + active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), + notices: cfg.notice.unwrap_or_default(), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg .tui @@ -1199,20 +1425,18 @@ impl Config { } fn load_instructions(codex_dir: Option<&Path>) -> Option { - let mut p = match codex_dir { - Some(p) => p.to_path_buf(), - None => return None, - }; - - p.push("AGENTS.md"); - std::fs::read_to_string(&p).ok().and_then(|s| { - let s = s.trim(); - if s.is_empty() { - None - } else { - Some(s.to_string()) + let base = codex_dir?; + for candidate in [LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME] { + let mut path = base.to_path_buf(); + path.push(candidate); + if let Ok(contents) = std::fs::read_to_string(&path) { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } } - }) + } + None } fn get_base_instructions( @@ -1305,6 +1529,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { mod tests { use crate::config_types::HistoryPersistence; use crate::config_types::Notifications; + use crate::features::Feature; use super::*; use pretty_assertions::assert_eq; @@ -1370,7 +1595,8 @@ network_access = false # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::DangerFullAccess, - sandbox_full_access_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_full_access_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_read_only = r#" @@ -1385,7 +1611,8 @@ network_access = true # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::ReadOnly, - sandbox_read_only_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_read_only_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_workspace_write = r#" @@ -1409,8 +1636,97 @@ exclude_slash_tmp = true exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, - sandbox_workspace_write_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); + + let sandbox_workspace_write = r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "/my/workspace", +] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true + +[projects."/tmp/test"] +trust_level = "trusted" +"#; + + let sandbox_workspace_write_cfg = toml::from_str::(sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + assert_eq!( + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("/my/workspace")], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + ); + } + + #[test] + fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let frontend = temp_dir.path().join("frontend"); + let backend = temp_dir.path().join("backend"); + std::fs::create_dir_all(&frontend)?; + std::fs::create_dir_all(&backend)?; + + let overrides = ConfigOverrides { + cwd: Some(frontend), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()], + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + temp_dir.path().to_path_buf(), + )?; + + let expected_backend = canonicalize(&backend).expect("canonicalize backend directory"); + match config.sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_backend) + .count(), + 1, + "expected single writable root entry for {}", + expected_backend.display() + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) + } + + #[test] + fn approve_all_feature_forces_on_request_policy() -> std::io::Result<()> { + let cfg = r#" +[features] +approve_all = true +"#; + let parsed = toml::from_str::(cfg) + .expect("TOML deserialization should succeed for approve_all feature"); + let temp_dir = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + parsed, + ConfigOverrides::default(), + temp_dir.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::ApproveAll)); + assert_eq!(config.approval_policy, AskForApproval::OnRequest); + Ok(()) } #[test] @@ -1432,6 +1748,93 @@ exclude_slash_tmp = true Ok(()) } + #[test] + fn profile_legacy_toggles_override_base() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut profiles = HashMap::new(); + profiles.insert( + "work".to_string(), + ConfigProfile { + include_plan_tool: Some(true), + include_view_image_tool: Some(false), + ..Default::default() + }, + ); + let cfg = ConfigToml { + profiles, + profile: Some("work".to_string()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::PlanTool)); + assert!(!config.features.enabled(Feature::ViewImageTool)); + assert!(config.include_plan_tool); + assert!(!config.include_view_image_tool); + + Ok(()) + } + + #[test] + fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert("plan_tool".to_string(), false); + entries.insert("apply_patch_freeform".to_string(), false); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.features.enabled(Feature::PlanTool)); + assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(!config.include_plan_tool); + assert!(!config.include_apply_patch_tool); + + Ok(()) + } + + #[test] + fn legacy_toggles_map_to_features() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + experimental_use_exec_command_tool: Some(true), + experimental_use_unified_exec_tool: Some(true), + experimental_use_rmcp_client: Some(true), + experimental_use_freeform_apply_patch: Some(true), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(config.features.enabled(Feature::StreamableShell)); + assert!(config.features.enabled(Feature::UnifiedExec)); + assert!(config.features.enabled(Feature::RmcpClient)); + + assert!(config.include_apply_patch_tool); + assert!(config.use_experimental_streamable_shell_tool); + assert!(config.use_experimental_unified_exec_tool); + assert!(config.use_experimental_use_rmcp_client); + + Ok(()) + } + #[test] fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -1514,7 +1917,10 @@ exclude_slash_tmp = true command: "echo".to_string(), args: vec!["hello".to_string()], env: None, + env_vars: Vec::new(), + cwd: None, }, + enabled: true, startup_timeout_sec: Some(Duration::from_secs(3)), tool_timeout_sec: Some(Duration::from_secs(5)), }, @@ -1526,15 +1932,24 @@ exclude_slash_tmp = true assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); 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:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); + assert!(docs.enabled); let empty = BTreeMap::new(); write_global_mcp_servers(codex_home.path(), &empty)?; @@ -1638,7 +2053,10 @@ bearer_token = "secret" ("ZIG_VAR".to_string(), "3".to_string()), ("ALPHA_VAR".to_string(), "1".to_string()), ])), + env_vars: Vec::new(), + cwd: None, }, + enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, }, @@ -1663,7 +2081,13 @@ ZIG_VAR = "3" let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "docs-server"); assert_eq!(args, &vec!["--verbose".to_string()]); let env = env @@ -1671,6 +2095,8 @@ ZIG_VAR = "3" .expect("env should be preserved for stdio transport"); assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport {other:?}"), } @@ -1679,16 +2105,103 @@ ZIG_VAR = "3" } #[tokio::test] - async fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { + async fn write_global_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { let codex_home = TempDir::new()?; - let mut servers = BTreeMap::from([( + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: vec!["ALPHA".to_string(), "BETA".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#), + "serialized config missing env_vars field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(cwd_path.clone()), + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"cwd = "/tmp/codex-mcp""#), + "serialized config missing cwd field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> + { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, }, + enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), tool_timeout_sec: None, }, @@ -1713,21 +2226,129 @@ startup_timeout_sec = 2.0 McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_serializes_custom_headers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 2.0 + +[mcp_servers.docs.http_headers] +X-Doc = "42" + +[mcp_servers.docs.env_http_headers] +X-Auth = "DOCS_AUTH" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_removes_optional_sections() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let mut servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + let serialized_with_optional = std::fs::read_to_string(&config_path)?; + assert!(serialized_with_optional.contains("bearer_token_env_var = \"MCP_TOKEN\"")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.http_headers]")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.env_http_headers]")); + servers.insert( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.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, }, @@ -1748,13 +2369,152 @@ url = "https://example.com/mcp" 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:?}"), } + assert!(docs.startup_timeout_sec.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_isolates_headers_between_servers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let servers = BTreeMap::from([ + ( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([( + "X-Doc".to_string(), + "42".to_string(), + )])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + ), + ( + "logs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "logs-server".to_string(), + args: vec!["--follow".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + ), + ]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("[mcp_servers.docs.http_headers]"), + "serialized config missing docs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.http_headers]"), + "serialized config should not add logs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.env_http_headers]"), + "serialized config should not add logs env headers section:\n{serialized}" + ); + assert!( + !serialized.contains("mcp_servers.logs.bearer_token_env_var"), + "serialized config should not add bearer token to logs:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + let logs = loaded.get("logs").expect("logs entry"); + match &logs.transport { + McpServerTransportConfig::Stdio { env, .. } => { + assert!(env.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: false, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("enabled = false"), + "serialized config missing disabled flag:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert!(!docs.enabled); + Ok(()) } @@ -2045,11 +2805,12 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2070,6 +2831,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2077,8 +2840,11 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("o3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2108,11 +2874,12 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), model_context_window: Some(16_385), model_max_output_tokens: Some(4_096), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2133,6 +2900,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2140,8 +2909,11 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("gpt3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2186,11 +2958,12 @@ model_verbosity = "high" model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), model_max_output_tokens: Some(100_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2211,6 +2984,8 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2218,8 +2993,11 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("zdr".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2250,11 +3028,12 @@ model_verbosity = "high" model_family: find_family_for_model("gpt-5").expect("known model slug"), model_context_window: Some(272_000), model_max_output_tokens: Some(128_000), - model_auto_compact_token_limit: None, + model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2275,6 +3054,8 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, @@ -2282,8 +3063,11 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("gpt5".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2294,6 +3078,24 @@ model_verbosity = "high" Ok(()) } + #[test] + fn test_did_user_set_custom_approval_policy_or_sandbox_mode_defaults_no() -> anyhow::Result<()> + { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + ..Default::default() + }, + fixture.codex_home(), + )?; + + assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode); + + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index da52176068..ba2201ed9c 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -20,6 +20,18 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub chatgpt_base_url: Option, pub experimental_instructions_file: Option, + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_rmcp_client: Option, + pub experimental_use_freeform_apply_patch: Option, + pub tools_web_search: Option, + pub tools_view_image: Option, + /// Optional feature toggles scoped to this profile. + #[serde(default)] + pub features: Option, } impl From for codex_app_server_protocol::Profile { diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 8940ac5c08..3da6108631 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -20,6 +20,10 @@ pub struct McpServerConfig { #[serde(flatten)] pub transport: McpServerTransportConfig, + /// When `false`, Codex skips initializing this MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Startup timeout in seconds for initializing MCP server & initially listing tools. #[serde( default, @@ -40,22 +44,34 @@ impl<'de> Deserialize<'de> for McpServerConfig { { #[derive(Deserialize)] struct RawMcpServerConfig { + // stdio command: Option, #[serde(default)] args: Option>, #[serde(default)] env: Option>, + #[serde(default)] + env_vars: Option>, + #[serde(default)] + cwd: Option, + http_headers: Option>, + #[serde(default)] + env_http_headers: Option>, + // streamable_http url: Option, bearer_token: Option, bearer_token_env_var: Option, + // shared #[serde(default)] startup_timeout_sec: Option, #[serde(default)] startup_timeout_ms: Option, #[serde(default, with = "option_duration_secs")] tool_timeout_sec: Option, + #[serde(default)] + enabled: Option, } let raw = RawMcpServerConfig::deserialize(deserializer)?; @@ -86,8 +102,12 @@ impl<'de> Deserialize<'de> for McpServerConfig { command: Some(command), args, env, + env_vars, + cwd, url, bearer_token_env_var, + http_headers, + env_http_headers, .. } => { throw_if_set("stdio", "url", url.as_ref())?; @@ -96,10 +116,14 @@ impl<'de> Deserialize<'de> for McpServerConfig { "bearer_token_env_var", bearer_token_env_var.as_ref(), )?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; McpServerTransportConfig::Stdio { command, args: args.unwrap_or_default(), env, + env_vars: env_vars.unwrap_or_default(), + cwd, } } RawMcpServerConfig { @@ -109,15 +133,26 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args, env, - .. + env_vars, + cwd, + http_headers, + env_http_headers, + startup_timeout_sec: _, + tool_timeout_sec: _, + startup_timeout_ms: _, + enabled: _, } => { throw_if_set("streamable_http", "command", command.as_ref())?; throw_if_set("streamable_http", "args", args.as_ref())?; throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } } _ => return Err(SerdeError::custom("invalid transport")), @@ -127,10 +162,15 @@ impl<'de> Deserialize<'de> for McpServerConfig { transport, startup_timeout_sec, tool_timeout_sec: raw.tool_timeout_sec, + enabled: raw.enabled.unwrap_or_else(default_enabled), }) } } +const fn default_enabled() -> bool { + true +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { @@ -141,6 +181,10 @@ pub enum McpServerTransportConfig { args: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, }, /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http StreamableHttp { @@ -150,6 +194,12 @@ pub enum McpServerTransportConfig { /// The actual secret value must be provided via the environment. #[serde(default, skip_serializing_if = "Option::is_none")] bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, }, } @@ -311,6 +361,20 @@ pub struct Tui { pub notifications: Notifications, } +/// Settings for notices we display to users via the tui and app-server clients +/// (primarily the Codex IDE extension). NOTE: these are different from +/// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct Notice { + /// Tracks whether the user has acknowledged the full access warning prompt. + pub hide_full_access_warning: Option, +} + +impl Notice { + /// used by set_hide_full_access_warning until we refactor config updates + pub(crate) const TABLE_KEY: &'static str = "notice"; +} + #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { #[serde(default)] @@ -457,9 +521,12 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec![], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); + assert!(cfg.enabled); } #[test] @@ -477,9 +544,12 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); + assert!(cfg.enabled); } #[test] @@ -498,9 +568,69 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])) + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, } ); + assert!(cfg.enabled); + } + + #[test] + fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".to_string(), "BAR".to_string()], + cwd: None, + } + ); + } + + #[test] + fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); + } + + #[test] + fn deserialize_disabled_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled = false + "#, + ) + .expect("should deserialize disabled server config"); + + assert!(!cfg.enabled); } #[test] @@ -516,9 +646,12 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, } ); + assert!(cfg.enabled); } #[test] @@ -535,7 +668,35 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("GITHUB_TOKEN".to_string()) + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); + } + + #[test] + fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), } ); } @@ -562,6 +723,25 @@ mod tests { .expect_err("should reject env for http transport"); } + #[test] + fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + } + #[test] fn deserialize_rejects_inline_bearer_token_field() { let err = toml::from_str::( diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 8f3292a226..e7b2e19ffb 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -93,6 +93,25 @@ impl EnvironmentContext { && self.network_access == *network_access && self.writable_roots == *writable_roots } + + pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + let cwd = if before.cwd != after.cwd { + Some(after.cwd.clone()) + } else { + None + }; + let approval_policy = if before.approval_policy != after.approval_policy { + Some(after.approval_policy) + } else { + None + }; + let sandbox_policy = if before.sandbox_policy != after.sandbox_policy { + Some(after.sandbox_policy.clone()) + } else { + None + }; + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None) + } } impl From<&TurnContext> for EnvironmentContext { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6fad448b44..459cc1752f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,6 +1,10 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; +use crate::truncate::truncate_middle; +use chrono::DateTime; +use chrono::Utc; +use codex_async_utils::CancelErr; use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; @@ -12,6 +16,9 @@ use tokio::task::JoinError; pub type Result = std::result::Result; +/// Limit UI error messages to a reasonable size while keeping useful context. +const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024; // 4 KiB + #[derive(Error, Debug)] pub enum SandboxErr { /// Error from sandbox execution @@ -46,6 +53,9 @@ pub enum SandboxErr { #[derive(Error, Debug)] pub enum CodexErr { + #[error("turn aborted")] + TurnAborted, + /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. /// @@ -87,6 +97,12 @@ pub enum CodexErr { #[error("{0}")] UsageLimitReached(UsageLimitReachedError), + #[error("{0}")] + ResponseStreamFailed(ResponseStreamFailed), + + #[error("{0}")] + ConnectionFailed(ConnectionFailedError), + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] @@ -122,9 +138,6 @@ pub enum CodexErr { #[error(transparent)] Io(#[from] io::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] Json(#[from] serde_json::Error), @@ -143,6 +156,43 @@ pub enum CodexErr { EnvVar(EnvVarError), } +impl From for CodexErr { + fn from(_: CancelErr) -> Self { + CodexErr::TurnAborted + } +} + +#[derive(Debug)] +pub struct ConnectionFailedError { + pub source: reqwest::Error, +} + +impl std::fmt::Display for ConnectionFailedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Connection failed: {}", self.source) + } +} + +#[derive(Debug)] +pub struct ResponseStreamFailed { + pub source: reqwest::Error, + pub request_id: Option, +} + +impl std::fmt::Display for ResponseStreamFailed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Error while reading the server response: {}{}", + self.source, + self.request_id + .as_ref() + .map(|id| format!(", request id: {id}")) + .unwrap_or_default() + ) + } +} + #[derive(Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, @@ -189,7 +239,7 @@ impl std::fmt::Display for RetryLimitReachedError { #[derive(Debug)] pub struct UsageLimitReachedError { pub(crate) plan_type: Option, - pub(crate) resets_in_seconds: Option, + pub(crate) resets_at: Option>, pub(crate) rate_limits: Option, } @@ -198,12 +248,12 @@ impl std::fmt::Display for UsageLimitReachedError { let message = match self.plan_type.as_ref() { Some(PlanType::Known(KnownPlan::Plus)) => format!( "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ), Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { format!( "You've hit your usage limit. To get more access now, send a request to your admin{}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ) } Some(PlanType::Known(KnownPlan::Free)) => { @@ -214,11 +264,11 @@ impl std::fmt::Display for UsageLimitReachedError { | Some(PlanType::Known(KnownPlan::Enterprise)) | Some(PlanType::Known(KnownPlan::Edu)) => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), Some(PlanType::Unknown(_)) | None => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), }; @@ -226,8 +276,8 @@ impl std::fmt::Display for UsageLimitReachedError { } } -fn retry_suffix(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" Try again in {reset_duration}.") } else { @@ -235,8 +285,8 @@ fn retry_suffix(resets_in_seconds: Option) -> String { } } -fn retry_suffix_after_or(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix_after_or(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" or try again in {reset_duration}.") } else { @@ -244,6 +294,29 @@ fn retry_suffix_after_or(resets_in_seconds: Option) -> String { } } +fn remaining_seconds(resets_at: Option<&DateTime>) -> Option { + let resets_at = resets_at.cloned()?; + let now = now_for_retry(); + let secs = resets_at.signed_duration_since(now).num_seconds(); + Some(if secs <= 0 { 0 } else { secs as u64 }) +} + +#[cfg(test)] +thread_local! { + static NOW_OVERRIDE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +fn now_for_retry() -> DateTime { + #[cfg(test)] + { + if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) { + return now; + } + } + Utc::now() +} + fn format_reset_duration(total_secs: u64) -> String { let days = total_secs / 86_400; let hours = (total_secs % 86_400) / 3_600; @@ -304,42 +377,86 @@ impl CodexErr { } pub fn get_error_message_ui(e: &CodexErr) -> String { - match e { - CodexErr::Sandbox(SandboxErr::Denied { output }) => output.stderr.text.clone(), + let message = match e { + CodexErr::Sandbox(SandboxErr::Denied { output }) => { + let aggregated = output.aggregated_output.text.trim(); + if !aggregated.is_empty() { + output.aggregated_output.text.clone() + } else { + let stderr = output.stderr.text.trim(); + let stdout = output.stdout.text.trim(); + match (stderr.is_empty(), stdout.is_empty()) { + (false, false) => format!("{stderr}\n{stdout}"), + (false, true) => output.stderr.text.clone(), + (true, false) => output.stdout.text.clone(), + (true, true) => format!( + "command failed inside sandbox with exit code {}", + output.exit_code + ), + } + } + } // Timeouts are not sandbox errors from a UX perspective; present them plainly - CodexErr::Sandbox(SandboxErr::Timeout { output }) => format!( - "error: command timed out after {} ms", - output.duration.as_millis() - ), + CodexErr::Sandbox(SandboxErr::Timeout { output }) => { + format!( + "error: command timed out after {} ms", + output.duration.as_millis() + ) + } _ => e.to_string(), - } + }; + + truncate_middle(&message, ERROR_MESSAGE_UI_MAX_BYTES).0 } #[cfg(test)] mod tests { use super::*; + use crate::exec::StreamOutput; + use chrono::DateTime; + use chrono::Duration as ChronoDuration; + use chrono::TimeZone; + use chrono::Utc; use codex_protocol::protocol::RateLimitWindow; + use pretty_assertions::assert_eq; fn rate_limit_snapshot() -> RateLimitSnapshot { + let primary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 1, 0, 0) + .unwrap() + .timestamp(); + let secondary_reset_at = Utc + .with_ymd_and_hms(2024, 1, 1, 2, 0, 0) + .unwrap() + .timestamp(); RateLimitSnapshot { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_in_seconds: Some(3600), + resets_at: Some(primary_reset_at), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_in_seconds: Some(7200), + resets_at: Some(secondary_reset_at), }), } } + fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { + NOW_OVERRIDE.with(|cell| { + *cell.borrow_mut() = Some(now); + let result = f(); + *cell.borrow_mut() = None; + result + }) + } + #[test] fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -348,11 +465,78 @@ mod tests { ); } + #[test] + fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { + let output = ExecToolCallOutput { + exit_code: 77, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new("aggregate detail".to_string()), + duration: Duration::from_millis(10), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + }); + assert_eq!(get_error_message_ui(&err), "aggregate detail"); + } + + #[test] + fn sandbox_denied_reports_both_streams_when_available() { + let output = ExecToolCallOutput { + exit_code: 9, + stdout: StreamOutput::new("stdout detail".to_string()), + stderr: StreamOutput::new("stderr detail".to_string()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(10), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + }); + assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail"); + } + + #[test] + fn sandbox_denied_reports_stdout_when_no_stderr() { + let output = ExecToolCallOutput { + exit_code: 11, + stdout: StreamOutput::new("stdout only".to_string()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(8), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + }); + assert_eq!(get_error_message_ui(&err), "stdout only"); + } + + #[test] + fn sandbox_denied_reports_exit_code_when_no_output_available() { + let output = ExecToolCallOutput { + exit_code: 13, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::from_millis(5), + timed_out: false, + }; + let err = CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + }); + assert_eq!( + get_error_message_ui(&err), + "command failed inside sandbox with exit code 13" + ); + } + #[test] fn usage_limit_reached_error_formats_free_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Free)), - resets_in_seconds: Some(3600), + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -365,7 +549,7 @@ mod tests { fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { plan_type: None, - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -376,22 +560,26 @@ mod tests { #[test] fn usage_limit_reached_error_formats_team_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Team)), - resets_in_seconds: Some(3600), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Team)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." + ); + }); } #[test] fn usage_limit_reached_error_formats_business_plan_without_reset() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Business)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -404,7 +592,7 @@ mod tests { fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Pro)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -415,53 +603,70 @@ mod tests { #[test] fn usage_limit_reached_includes_minutes_when_available() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 5 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_hours_and_minutes() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: Some(3 * 3600 + 32 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_days_hours_minutes() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = + base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." + ); + }); } #[test] fn usage_limit_reached_less_than_minute() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(30), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in less than a minute." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::seconds(30); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in less than a minute." + ); + }); } } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index d84bbc9fcb..d2fa9735d0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -18,13 +18,14 @@ use tokio::process::Child; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::landlock::spawn_command_under_linux_sandbox; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecOutputStream; use crate::protocol::SandboxPolicy; -use crate::seatbelt::spawn_command_under_seatbelt; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxManager; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; @@ -53,6 +54,7 @@ pub struct ExecParams { pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, + pub arg0: Option, } impl ExecParams { @@ -87,57 +89,85 @@ pub async fn process_exec_tool_call( codex_linux_sandbox_exe: &Option, stdout_stream: Option, ) -> Result { - let start = Instant::now(); + let ExecParams { + command, + cwd, + timeout_ms, + env, + with_escalated_permissions, + justification, + arg0: _, + } = params; - let timeout_duration = params.timeout_duration(); + let (program, args) = command.split_first().ok_or_else(|| { + CodexErr::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "command args are empty", + )) + })?; - let raw_output_result: std::result::Result = match sandbox_type - { - SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await, - SandboxType::MacosSeatbelt => { - let ExecParams { - command, - cwd: command_cwd, - env, - .. - } = params; - let child = spawn_command_under_seatbelt( - command, - command_cwd, - sandbox_policy, - sandbox_cwd, - StdioPolicy::RedirectForShellTool, - env, - ) - .await?; - consume_truncated_output(child, timeout_duration, stdout_stream.clone()).await - } - SandboxType::LinuxSeccomp => { - let ExecParams { - command, - cwd: command_cwd, - env, - .. - } = params; - - let codex_linux_sandbox_exe = codex_linux_sandbox_exe - .as_ref() - .ok_or(CodexErr::LandlockSandboxExecutableNotProvided)?; - let child = spawn_command_under_linux_sandbox( - codex_linux_sandbox_exe, - command, - command_cwd, - sandbox_policy, - sandbox_cwd, - StdioPolicy::RedirectForShellTool, - env, - ) - .await?; - - consume_truncated_output(child, timeout_duration, stdout_stream).await - } + let spec = CommandSpec { + program: program.clone(), + args: args.to_vec(), + cwd, + env, + timeout_ms, + with_escalated_permissions, + justification, }; + + let manager = SandboxManager::new(); + let exec_env = manager + .transform( + &spec, + sandbox_policy, + sandbox_type, + sandbox_cwd, + codex_linux_sandbox_exe.as_ref(), + ) + .map_err(CodexErr::from)?; + + // Route through the sandboxing module for a single, unified execution path. + crate::sandboxing::execute_env(&exec_env, sandbox_policy, stdout_stream).await +} + +pub(crate) async fn execute_exec_env( + env: ExecEnv, + sandbox_policy: &SandboxPolicy, + stdout_stream: Option, +) -> Result { + let ExecEnv { + command, + cwd, + env, + timeout_ms, + sandbox, + with_escalated_permissions, + justification, + arg0, + } = env; + + let params = ExecParams { + command, + cwd, + timeout_ms, + env, + with_escalated_permissions, + justification, + arg0, + }; + + let start = Instant::now(); + let raw_output_result = exec(params, sandbox_policy, stdout_stream).await; let duration = start.elapsed(); + finalize_exec_result(raw_output_result, sandbox, duration) +} + +fn finalize_exec_result( + raw_output_result: std::result::Result, + sandbox_type: SandboxType, + duration: Duration, +) -> Result { match raw_output_result { Ok(raw_output) => { #[allow(unused_mut)] @@ -177,7 +207,7 @@ pub async fn process_exec_tool_call( })); } - if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) { + if is_likely_sandbox_denied(sandbox_type, &exec_output) { return Err(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(exec_output), })); @@ -192,31 +222,89 @@ pub async fn process_exec_tool_call( } } +pub(crate) mod errors { + use super::CodexErr; + use crate::sandboxing::SandboxTransformError; + + impl From for CodexErr { + fn from(err: SandboxTransformError) -> Self { + match err { + SandboxTransformError::MissingLinuxSandboxExecutable => { + CodexErr::LandlockSandboxExecutableNotProvided + } + } + } + } +} + /// We don't have a fully deterministic way to tell if our command failed /// because of the sandbox - a command in the user's zshrc file might hit an /// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for 'command not found' (exit code 127), -/// and can add additional cases as necessary. -fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool { - if sandbox_type == SandboxType::None { +/// For now, we conservatively check for well known command failure exit codes and +/// also look for common sandbox denial keywords in the command output. +pub(crate) fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { return false; } // Quick rejects: well-known non-sandbox shell exit codes - // 127: command not found, 2: misuse of shell builtins - if exit_code == 127 { + // 2: misuse of shell builtins + // 126: permission denied + // 127: command not found + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { return false; } - // For all other cases, we assume the sandbox is the cause - true + #[cfg(unix)] + { + const SIGSYS_CODE: i32 = libc::SIGSYS; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE + { + return true; + } + } + + false } -#[derive(Debug)] -pub struct StreamOutput { +#[derive(Debug, Clone)] +pub struct StreamOutput { pub text: T, pub truncated_after_lines: Option, } + #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, @@ -249,7 +337,7 @@ fn append_all(dst: &mut Vec, src: &[u8]) { dst.extend_from_slice(src); } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ExecToolCallOutput { pub exit_code: i32, pub stdout: StreamOutput, @@ -266,7 +354,11 @@ async fn exec( ) -> Result { let timeout = params.timeout_duration(); let ExecParams { - command, cwd, env, .. + command, + cwd, + env, + arg0, + .. } = params; let (program, args) = command.split_first().ok_or_else(|| { @@ -275,11 +367,11 @@ async fn exec( "command args are empty", )) })?; - let arg0 = None; + let arg0_ref = arg0.as_deref(); let child = spawn_child_async( PathBuf::from(program), args.into(), - arg0, + arg0_ref, cwd, sandbox_policy, StdioPolicy::RedirectForShellTool, @@ -436,3 +528,77 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { #[expect(clippy::unwrap_used)] std::process::ExitStatus::from_raw(code.try_into().unwrap()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn make_exec_output( + exit_code: i32, + stdout: &str, + stderr: &str, + aggregated: &str, + ) -> ExecToolCallOutput { + ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(stdout.to_string()), + stderr: StreamOutput::new(stderr.to_string()), + aggregated_output: StreamOutput::new(aggregated.to_string()), + duration: Duration::from_millis(1), + timed_out: false, + } + } + + #[test] + fn sandbox_detection_requires_keywords() { + let output = make_exec_output(1, "", "", ""); + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); + } + + #[test] + fn sandbox_detection_identifies_keyword_in_stderr() { + let output = make_exec_output(1, "", "Operation not permitted", ""); + assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); + } + + #[test] + fn sandbox_detection_respects_quick_reject_exit_codes() { + let output = make_exec_output(127, "", "command not found", ""); + assert!(!is_likely_sandbox_denied( + SandboxType::LinuxSeccomp, + &output + )); + } + + #[test] + fn sandbox_detection_ignores_non_sandbox_mode() { + let output = make_exec_output(1, "", "Operation not permitted", ""); + assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); + } + + #[test] + fn sandbox_detection_uses_aggregated_output() { + let output = make_exec_output( + 101, + "", + "", + "cargo failed: Read-only file system when writing target", + ); + assert!(is_likely_sandbox_denied( + SandboxType::MacosSeatbelt, + &output + )); + } + + #[cfg(unix)] + #[test] + fn sandbox_detection_flags_sigsys_exit_code() { + let exit_code = EXIT_CODE_SIGNAL_BASE + libc::SIGSYS; + let output = make_exec_output(exit_code, "", "", ""); + assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); + } +} diff --git a/codex-rs/core/src/exec_command/exec_command_params.rs b/codex-rs/core/src/exec_command/exec_command_params.rs deleted file mode 100644 index 11a3fd4596..0000000000 --- a/codex-rs/core/src/exec_command/exec_command_params.rs +++ /dev/null @@ -1,57 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -use crate::exec_command::session_id::SessionId; - -#[derive(Debug, Clone, Deserialize)] -pub struct ExecCommandParams { - pub(crate) cmd: String, - - #[serde(default = "default_yield_time")] - pub(crate) yield_time_ms: u64, - - #[serde(default = "max_output_tokens")] - pub(crate) max_output_tokens: u64, - - #[serde(default = "default_shell")] - pub(crate) shell: String, - - #[serde(default = "default_login")] - pub(crate) login: bool, -} - -fn default_yield_time() -> u64 { - 10_000 -} - -fn max_output_tokens() -> u64 { - 10_000 -} - -fn default_login() -> bool { - true -} - -fn default_shell() -> String { - "/bin/bash".to_string() -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct WriteStdinParams { - pub(crate) session_id: SessionId, - pub(crate) chars: String, - - #[serde(default = "write_stdin_default_yield_time_ms")] - pub(crate) yield_time_ms: u64, - - #[serde(default = "write_stdin_default_max_output_tokens")] - pub(crate) max_output_tokens: u64, -} - -fn write_stdin_default_yield_time_ms() -> u64 { - 250 -} - -fn write_stdin_default_max_output_tokens() -> u64 { - 10_000 -} diff --git a/codex-rs/core/src/exec_command/exec_command_session.rs b/codex-rs/core/src/exec_command/exec_command_session.rs deleted file mode 100644 index 31b3c929b2..0000000000 --- a/codex-rs/core/src/exec_command/exec_command_session.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::sync::Mutex as StdMutex; - -use tokio::sync::broadcast; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; - -#[derive(Debug)] -pub(crate) struct ExecCommandSession { - /// Queue for writing bytes to the process stdin (PTY master write side). - writer_tx: mpsc::Sender>, - /// Broadcast stream of output chunks read from the PTY. New subscribers - /// receive only chunks emitted after they subscribe. - output_tx: broadcast::Sender>, - - /// Child killer handle for termination on drop (can signal independently - /// of a thread blocked in `.wait()`). - killer: StdMutex>>, - - /// JoinHandle for the blocking PTY reader task. - reader_handle: StdMutex>>, - - /// JoinHandle for the stdin writer task. - writer_handle: StdMutex>>, - - /// JoinHandle for the child wait task. - wait_handle: StdMutex>>, - - /// Tracks whether the underlying process has exited. - exit_status: std::sync::Arc, -} - -impl ExecCommandSession { - pub(crate) fn new( - writer_tx: mpsc::Sender>, - output_tx: broadcast::Sender>, - killer: Box, - reader_handle: JoinHandle<()>, - writer_handle: JoinHandle<()>, - wait_handle: JoinHandle<()>, - exit_status: std::sync::Arc, - ) -> (Self, broadcast::Receiver>) { - let initial_output_rx = output_tx.subscribe(); - ( - Self { - writer_tx, - output_tx, - killer: StdMutex::new(Some(killer)), - reader_handle: StdMutex::new(Some(reader_handle)), - writer_handle: StdMutex::new(Some(writer_handle)), - wait_handle: StdMutex::new(Some(wait_handle)), - exit_status, - }, - initial_output_rx, - ) - } - - pub(crate) fn writer_sender(&self) -> mpsc::Sender> { - self.writer_tx.clone() - } - - pub(crate) fn output_receiver(&self) -> broadcast::Receiver> { - self.output_tx.subscribe() - } - - pub(crate) fn has_exited(&self) -> bool { - self.exit_status.load(std::sync::atomic::Ordering::SeqCst) - } -} - -impl Drop for ExecCommandSession { - fn drop(&mut self) { - // Best-effort: terminate child first so blocking tasks can complete. - if let Ok(mut killer_opt) = self.killer.lock() - && let Some(mut killer) = killer_opt.take() - { - let _ = killer.kill(); - } - - // Abort background tasks; they may already have exited after kill. - if let Ok(mut h) = self.reader_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - if let Ok(mut h) = self.writer_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - if let Ok(mut h) = self.wait_handle.lock() - && let Some(handle) = h.take() - { - handle.abort(); - } - } -} diff --git a/codex-rs/core/src/exec_command/mod.rs b/codex-rs/core/src/exec_command/mod.rs deleted file mode 100644 index 2cfd022510..0000000000 --- a/codex-rs/core/src/exec_command/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod exec_command_params; -mod exec_command_session; -mod responses_api; -mod session_id; -mod session_manager; - -pub use exec_command_params::ExecCommandParams; -pub use exec_command_params::WriteStdinParams; -pub(crate) use exec_command_session::ExecCommandSession; -pub use responses_api::EXEC_COMMAND_TOOL_NAME; -pub use responses_api::WRITE_STDIN_TOOL_NAME; -pub use responses_api::create_exec_command_tool_for_responses_api; -pub use responses_api::create_write_stdin_tool_for_responses_api; -pub use session_manager::SessionManager as ExecSessionManager; diff --git a/codex-rs/core/src/exec_command/responses_api.rs b/codex-rs/core/src/exec_command/responses_api.rs deleted file mode 100644 index 24f6d35c57..0000000000 --- a/codex-rs/core/src/exec_command/responses_api.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::collections::BTreeMap; - -use crate::client_common::tools::ResponsesApiTool; -use crate::openai_tools::JsonSchema; - -pub const EXEC_COMMAND_TOOL_NAME: &str = "exec_command"; -pub const WRITE_STDIN_TOOL_NAME: &str = "write_stdin"; - -pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool { - let mut properties = BTreeMap::::new(); - properties.insert( - "cmd".to_string(), - JsonSchema::String { - description: Some("The shell command to execute.".to_string()), - }, - ); - properties.insert( - "yield_time_ms".to_string(), - JsonSchema::Number { - description: Some("The maximum time in milliseconds to wait for output.".to_string()), - }, - ); - properties.insert( - "max_output_tokens".to_string(), - JsonSchema::Number { - description: Some("The maximum number of tokens to output.".to_string()), - }, - ); - properties.insert( - "shell".to_string(), - JsonSchema::String { - description: Some("The shell to use. Defaults to \"/bin/bash\".".to_string()), - }, - ); - properties.insert( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the command as a login shell. Defaults to true.".to_string(), - ), - }, - ); - - ResponsesApiTool { - name: EXEC_COMMAND_TOOL_NAME.to_owned(), - description: r#"Execute shell commands on the local machine with streaming output."# - .to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["cmd".to_string()]), - additional_properties: Some(false.into()), - }, - } -} - -pub fn create_write_stdin_tool_for_responses_api() -> ResponsesApiTool { - let mut properties = BTreeMap::::new(); - properties.insert( - "session_id".to_string(), - JsonSchema::Number { - description: Some("The ID of the exec_command session.".to_string()), - }, - ); - properties.insert( - "chars".to_string(), - JsonSchema::String { - description: Some("The characters to write to stdin.".to_string()), - }, - ); - properties.insert( - "yield_time_ms".to_string(), - JsonSchema::Number { - description: Some( - "The maximum time in milliseconds to wait for output after writing.".to_string(), - ), - }, - ); - properties.insert( - "max_output_tokens".to_string(), - JsonSchema::Number { - description: Some("The maximum number of tokens to output.".to_string()), - }, - ); - - ResponsesApiTool { - name: WRITE_STDIN_TOOL_NAME.to_owned(), - description: r#"Write characters to an exec session's stdin. Returns all stdout+stderr received within yield_time_ms. -Can write control characters (\u0003 for Ctrl-C), or an empty string to just poll stdout+stderr."# - .to_string(), - strict: false, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["session_id".to_string(), "chars".to_string()]), - additional_properties: Some(false.into()), - }, - } -} diff --git a/codex-rs/core/src/exec_command/session_id.rs b/codex-rs/core/src/exec_command/session_id.rs deleted file mode 100644 index c97c5d5440..0000000000 --- a/codex-rs/core/src/exec_command/session_id.rs +++ /dev/null @@ -1,5 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub(crate) struct SessionId(pub u32); diff --git a/codex-rs/core/src/exec_command/session_manager.rs b/codex-rs/core/src/exec_command/session_manager.rs deleted file mode 100644 index cd1c5329a2..0000000000 --- a/codex-rs/core/src/exec_command/session_manager.rs +++ /dev/null @@ -1,506 +0,0 @@ -use std::collections::HashMap; -use std::io::ErrorKind; -use std::io::Read; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU32; - -use portable_pty::CommandBuilder; -use portable_pty::PtySize; -use portable_pty::native_pty_system; -use tokio::sync::Mutex; -use tokio::sync::mpsc; -use tokio::sync::oneshot; -use tokio::time::Duration; -use tokio::time::Instant; -use tokio::time::timeout; - -use crate::exec_command::exec_command_params::ExecCommandParams; -use crate::exec_command::exec_command_params::WriteStdinParams; -use crate::exec_command::exec_command_session::ExecCommandSession; -use crate::exec_command::session_id::SessionId; -use crate::truncate::truncate_middle; - -#[derive(Debug, Default)] -pub struct SessionManager { - next_session_id: AtomicU32, - sessions: Mutex>, -} - -#[derive(Debug)] -pub struct ExecCommandOutput { - wall_time: Duration, - exit_status: ExitStatus, - original_token_count: Option, - output: String, -} - -impl ExecCommandOutput { - pub(crate) fn to_text_output(&self) -> String { - let wall_time_secs = self.wall_time.as_secs_f32(); - let termination_status = match self.exit_status { - ExitStatus::Exited(code) => format!("Process exited with code {code}"), - ExitStatus::Ongoing(session_id) => { - format!("Process running with session ID {}", session_id.0) - } - }; - let truncation_status = match self.original_token_count { - Some(tokens) => { - format!("\nWarning: truncated output (original token count: {tokens})") - } - None => "".to_string(), - }; - format!( - r#"Wall time: {wall_time_secs:.3} seconds -{termination_status}{truncation_status} -Output: -{output}"#, - output = self.output - ) - } -} - -#[derive(Debug)] -pub enum ExitStatus { - Exited(i32), - Ongoing(SessionId), -} - -impl SessionManager { - /// Processes the request and is required to send a response via `outgoing`. - pub async fn handle_exec_command_request( - &self, - params: ExecCommandParams, - ) -> Result { - // Allocate a session id. - let session_id = SessionId( - self.next_session_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst), - ); - - let (session, mut output_rx, mut exit_rx) = create_exec_command_session(params.clone()) - .await - .map_err(|err| { - format!( - "failed to create exec command session for session id {}: {err}", - session_id.0 - ) - })?; - - // Insert into session map. - self.sessions.lock().await.insert(session_id, session); - - // Collect output until either timeout expires or process exits. - // Do not cap during collection; truncate at the end if needed. - // Use a modest initial capacity to avoid large preallocation. - let cap_bytes_u64 = params.max_output_tokens.saturating_mul(4); - let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; - let mut collected: Vec = Vec::with_capacity(4096); - - let start_time = Instant::now(); - let deadline = start_time + Duration::from_millis(params.yield_time_ms); - let mut exit_code: Option = None; - - loop { - if Instant::now() >= deadline { - break; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - tokio::select! { - biased; - exit = &mut exit_rx => { - exit_code = exit.ok(); - // Small grace period to pull remaining buffered output - let grace_deadline = Instant::now() + Duration::from_millis(25); - while Instant::now() < grace_deadline { - match timeout(Duration::from_millis(1), output_rx.recv()).await { - Ok(Ok(chunk)) => { - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; keep trying within grace period. - continue; - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, - Err(_) => break, - } - } - break; - } - chunk = timeout(remaining, output_rx.recv()) => { - match chunk { - Ok(Ok(chunk)) => { - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; continue collecting fresh output. - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => { break; } - Err(_) => { break; } - } - } - } - } - - let output = String::from_utf8_lossy(&collected).to_string(); - - let exit_status = if let Some(code) = exit_code { - ExitStatus::Exited(code) - } else { - ExitStatus::Ongoing(session_id) - }; - - // If output exceeds cap, truncate the middle and record original token estimate. - let (output, original_token_count) = truncate_middle(&output, cap_bytes); - Ok(ExecCommandOutput { - wall_time: Instant::now().duration_since(start_time), - exit_status, - original_token_count, - output, - }) - } - - /// Write characters to a session's stdin and collect combined output for up to `yield_time_ms`. - pub async fn handle_write_stdin_request( - &self, - params: WriteStdinParams, - ) -> Result { - let WriteStdinParams { - session_id, - chars, - yield_time_ms, - max_output_tokens, - } = params; - - // Grab handles without holding the sessions lock across await points. - let (writer_tx, mut output_rx) = { - let sessions = self.sessions.lock().await; - match sessions.get(&session_id) { - Some(session) => (session.writer_sender(), session.output_receiver()), - None => { - return Err(format!("unknown session id {}", session_id.0)); - } - } - }; - - // Write stdin if provided. - if !chars.is_empty() && writer_tx.send(chars.into_bytes()).await.is_err() { - return Err("failed to write to stdin".to_string()); - } - - // Collect output up to yield_time_ms, truncating to max_output_tokens bytes. - let mut collected: Vec = Vec::with_capacity(4096); - let start_time = Instant::now(); - let deadline = start_time + Duration::from_millis(yield_time_ms); - loop { - let now = Instant::now(); - if now >= deadline { - break; - } - let remaining = deadline - now; - match timeout(remaining, output_rx.recv()).await { - Ok(Ok(chunk)) => { - // Collect all output within the time budget; truncate at the end. - collected.extend_from_slice(&chunk); - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => { - // Skip missed messages; continue collecting fresh output. - } - Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break, - Err(_) => break, // timeout - } - } - - // Return structured output, truncating middle if over cap. - let output = String::from_utf8_lossy(&collected).to_string(); - let cap_bytes_u64 = max_output_tokens.saturating_mul(4); - let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; - let (output, original_token_count) = truncate_middle(&output, cap_bytes); - Ok(ExecCommandOutput { - wall_time: Instant::now().duration_since(start_time), - exit_status: ExitStatus::Ongoing(session_id), - original_token_count, - output, - }) - } -} - -/// Spawn PTY and child process per spawn_exec_command_session logic. -async fn create_exec_command_session( - params: ExecCommandParams, -) -> anyhow::Result<( - ExecCommandSession, - tokio::sync::broadcast::Receiver>, - oneshot::Receiver, -)> { - let ExecCommandParams { - cmd, - yield_time_ms: _, - max_output_tokens: _, - shell, - login, - } = params; - - // Use the native pty implementation for the system - let pty_system = native_pty_system(); - - // Create a new pty - let pair = pty_system.openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - })?; - - // Spawn a shell into the pty - let mut command_builder = CommandBuilder::new(shell); - let shell_mode_opt = if login { "-lc" } else { "-c" }; - command_builder.arg(shell_mode_opt); - command_builder.arg(cmd); - - let mut child = pair.slave.spawn_command(command_builder)?; - // Obtain a killer that can signal the process independently of `.wait()`. - let killer = child.clone_killer(); - - // Channel to forward write requests to the PTY writer. - let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - // Broadcast for streaming PTY output to readers: subscribers receive from subscription time. - let (output_tx, _) = tokio::sync::broadcast::channel::>(256); - // Reader task: drain PTY and forward chunks to output channel. - let mut reader = pair.master.try_clone_reader()?; - let output_tx_clone = output_tx.clone(); - let reader_handle = tokio::task::spawn_blocking(move || { - let mut buf = [0u8; 8192]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, // EOF - Ok(n) => { - // Forward to broadcast; best-effort if there are subscribers. - let _ = output_tx_clone.send(buf[..n].to_vec()); - } - Err(ref e) if e.kind() == ErrorKind::Interrupted => { - // Retry on EINTR - continue; - } - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - // We're in a blocking thread; back off briefly and retry. - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - } - } - }); - - // Writer task: apply stdin writes to the PTY writer. - let writer = pair.master.take_writer()?; - let writer = Arc::new(StdMutex::new(writer)); - let writer_handle = tokio::spawn({ - let writer = writer.clone(); - async move { - while let Some(bytes) = writer_rx.recv().await { - let writer = writer.clone(); - // Perform blocking write on a blocking thread. - let _ = tokio::task::spawn_blocking(move || { - if let Ok(mut guard) = writer.lock() { - use std::io::Write; - let _ = guard.write_all(&bytes); - let _ = guard.flush(); - } - }) - .await; - } - } - }); - - // Keep the child alive until it exits, then signal exit code. - let (exit_tx, exit_rx) = oneshot::channel::(); - let exit_status = Arc::new(AtomicBool::new(false)); - let wait_exit_status = exit_status.clone(); - let wait_handle = tokio::task::spawn_blocking(move || { - let code = match child.wait() { - Ok(status) => status.exit_code() as i32, - Err(_) => -1, - }; - wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst); - let _ = exit_tx.send(code); - }); - - // Create and store the session with channels. - let (session, initial_output_rx) = ExecCommandSession::new( - writer_tx, - output_tx, - killer, - reader_handle, - writer_handle, - wait_handle, - exit_status, - ); - Ok((session, initial_output_rx, exit_rx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::exec_command::session_id::SessionId; - - /// Test that verifies that [`SessionManager::handle_exec_command_request()`] - /// and [`SessionManager::handle_write_stdin_request()`] work as expected - /// in the presence of a process that never terminates (but produces - /// output continuously). - #[cfg(unix)] - #[allow(clippy::print_stderr)] - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn session_manager_streams_and_truncates_from_now() { - use crate::exec_command::exec_command_params::ExecCommandParams; - use crate::exec_command::exec_command_params::WriteStdinParams; - use tokio::time::sleep; - - let session_manager = SessionManager::default(); - // Long-running loop that prints an increasing counter every ~100ms. - // Use Python for a portable, reliable sleep across shells/PTYs. - let cmd = r#"python3 - <<'PY' -import sys, time -count = 0 -while True: - print(count) - sys.stdout.flush() - count += 100 - time.sleep(0.1) -PY"# - .to_string(); - - // Start the session and collect ~3s of output. - let params = ExecCommandParams { - cmd, - yield_time_ms: 3_000, - max_output_tokens: 1_000, // large enough to avoid truncation here - shell: "/bin/bash".to_string(), - login: false, - }; - let initial_output = match session_manager - .handle_exec_command_request(params.clone()) - .await - { - Ok(v) => v, - Err(e) => { - // PTY may be restricted in some sandboxes; skip in that case. - if e.contains("openpty") || e.contains("Operation not permitted") { - eprintln!("skipping test due to restricted PTY: {e}"); - return; - } - panic!("exec request failed unexpectedly: {e}"); - } - }; - eprintln!("initial output: {initial_output:?}"); - - // Should be ongoing (we launched a never-ending loop). - let session_id = match initial_output.exit_status { - ExitStatus::Ongoing(id) => id, - _ => panic!("expected ongoing session"), - }; - - // Parse the numeric lines and get the max observed value in the first window. - let first_nums = extract_monotonic_numbers(&initial_output.output); - assert!( - !first_nums.is_empty(), - "expected some output from first window" - ); - let first_max = *first_nums.iter().max().unwrap(); - - // Wait ~4s so counters progress while we're not reading. - sleep(Duration::from_millis(4_000)).await; - - // Now read ~3s of output "from now" only. - // Use a small token cap so truncation occurs and we test middle truncation. - let write_params = WriteStdinParams { - session_id, - chars: String::new(), - yield_time_ms: 3_000, - max_output_tokens: 16, // 16 tokens ~= 64 bytes -> likely truncation - }; - let second = session_manager - .handle_write_stdin_request(write_params) - .await - .expect("write stdin should succeed"); - - // Verify truncation metadata and size bound (cap is tokens*4 bytes). - assert!(second.original_token_count.is_some()); - let cap_bytes = (16u64 * 4) as usize; - assert!(second.output.len() <= cap_bytes); - // New middle marker should be present. - assert!( - second.output.contains("tokens truncated") && second.output.contains('…'), - "expected truncation marker in output, got: {}", - second.output - ); - - // Minimal freshness check: the earliest number we see in the second window - // should be significantly larger than the last from the first window. - let second_nums = extract_monotonic_numbers(&second.output); - assert!( - !second_nums.is_empty(), - "expected some numeric output from second window" - ); - let second_min = *second_nums.iter().min().unwrap(); - - // We slept 4 seconds (~40 ticks at 100ms/tick, each +100), so expect - // an increase of roughly 4000 or more. Allow a generous margin. - assert!( - second_min >= first_max + 2000, - "second_min={second_min} first_max={first_max}", - ); - } - - #[cfg(unix)] - fn extract_monotonic_numbers(s: &str) -> Vec { - s.lines() - .filter_map(|line| { - if !line.is_empty() - && line.chars().all(|c| c.is_ascii_digit()) - && let Ok(n) = line.parse::() - { - // Our generator increments by 100; ignore spurious fragments. - if n % 100 == 0 { - return Some(n); - } - } - None - }) - .collect() - } - - #[test] - fn to_text_output_exited_no_truncation() { - let out = ExecCommandOutput { - wall_time: Duration::from_millis(1234), - exit_status: ExitStatus::Exited(0), - original_token_count: None, - output: "hello".to_string(), - }; - let text = out.to_text_output(); - let expected = r#"Wall time: 1.234 seconds -Process exited with code 0 -Output: -hello"#; - assert_eq!(expected, text); - } - - #[test] - fn to_text_output_ongoing_with_truncation() { - let out = ExecCommandOutput { - wall_time: Duration::from_millis(500), - exit_status: ExitStatus::Ongoing(SessionId(42)), - original_token_count: Some(1000), - output: "abc".to_string(), - }; - let text = out.to_text_output(); - let expected = r#"Wall time: 0.500 seconds -Process running with session ID 42 -Warning: truncated output (original token count: 1000) -Output: -abc"#; - assert_eq!(expected, text); - } -} diff --git a/codex-rs/core/src/executor/backends.rs b/codex-rs/core/src/executor/backends.rs deleted file mode 100644 index 95cdb3cacb..0000000000 --- a/codex-rs/core/src/executor/backends.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::collections::HashMap; -use std::env; - -use async_trait::async_trait; - -use crate::CODEX_APPLY_PATCH_ARG1; -use crate::apply_patch::ApplyPatchExec; -use crate::exec::ExecParams; -use crate::function_tool::FunctionCallError; - -pub(crate) enum ExecutionMode { - Shell, - ApplyPatch(ApplyPatchExec), -} - -#[async_trait] -/// Backend-specific hooks that prepare and post-process execution requests for a -/// given [`ExecutionMode`]. -pub(crate) trait ExecutionBackend: Send + Sync { - fn prepare( - &self, - params: ExecParams, - // Required for downcasting the apply_patch. - mode: &ExecutionMode, - ) -> Result; - - fn stream_stdout(&self, _mode: &ExecutionMode) -> bool { - true - } -} - -static SHELL_BACKEND: ShellBackend = ShellBackend; -static APPLY_PATCH_BACKEND: ApplyPatchBackend = ApplyPatchBackend; - -pub(crate) fn backend_for_mode(mode: &ExecutionMode) -> &'static dyn ExecutionBackend { - match mode { - ExecutionMode::Shell => &SHELL_BACKEND, - ExecutionMode::ApplyPatch(_) => &APPLY_PATCH_BACKEND, - } -} - -struct ShellBackend; - -#[async_trait] -impl ExecutionBackend for ShellBackend { - fn prepare( - &self, - params: ExecParams, - mode: &ExecutionMode, - ) -> Result { - match mode { - ExecutionMode::Shell => Ok(params), - _ => Err(FunctionCallError::RespondToModel( - "shell backend invoked with non-shell mode".to_string(), - )), - } - } -} - -struct ApplyPatchBackend; - -#[async_trait] -impl ExecutionBackend for ApplyPatchBackend { - fn prepare( - &self, - params: ExecParams, - mode: &ExecutionMode, - ) -> Result { - match mode { - ExecutionMode::ApplyPatch(exec) => { - let path_to_codex = env::current_exe() - .ok() - .map(|p| p.to_string_lossy().to_string()) - .ok_or_else(|| { - FunctionCallError::RespondToModel( - "failed to determine path to codex executable".to_string(), - ) - })?; - - let patch = exec.action.patch.clone(); - Ok(ExecParams { - command: vec![path_to_codex, CODEX_APPLY_PATCH_ARG1.to_string(), patch], - cwd: exec.action.cwd.clone(), - timeout_ms: params.timeout_ms, - // Run apply_patch with a minimal environment for determinism and to - // avoid leaking host environment variables into the patch process. - env: HashMap::new(), - with_escalated_permissions: params.with_escalated_permissions, - justification: params.justification, - }) - } - ExecutionMode::Shell => Err(FunctionCallError::RespondToModel( - "apply_patch backend invoked without patch context".to_string(), - )), - } - } - - fn stream_stdout(&self, _mode: &ExecutionMode) -> bool { - false - } -} diff --git a/codex-rs/core/src/executor/cache.rs b/codex-rs/core/src/executor/cache.rs deleted file mode 100644 index 737ecb927c..0000000000 --- a/codex-rs/core/src/executor/cache.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::sync::Mutex; - -#[derive(Clone, Debug, Default)] -/// Thread-safe store of user approvals so repeated commands can reuse -/// previously granted trust. -pub(crate) struct ApprovalCache { - inner: Arc>>>, -} - -impl ApprovalCache { - pub(crate) fn insert(&self, command: Vec) { - if command.is_empty() { - return; - } - if let Ok(mut guard) = self.inner.lock() { - guard.insert(command); - } - } - - pub(crate) fn snapshot(&self) -> HashSet> { - self.inner.lock().map(|g| g.clone()).unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn insert_ignores_empty_and_dedupes() { - let cache = ApprovalCache::default(); - - // Empty should be ignored - cache.insert(vec![]); - assert!(cache.snapshot().is_empty()); - - // Insert a command and verify snapshot contains it - let cmd = vec!["foo".to_string(), "bar".to_string()]; - cache.insert(cmd.clone()); - let snap1 = cache.snapshot(); - assert!(snap1.contains(&cmd)); - - // Reinserting should not create duplicates - cache.insert(cmd); - let snap2 = cache.snapshot(); - assert_eq!(snap1, snap2); - } -} diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs deleted file mode 100644 index 97d7b29294..0000000000 --- a/codex-rs/core/src/executor/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod backends; -mod cache; -mod runner; -mod sandbox; - -pub(crate) use backends::ExecutionMode; -pub(crate) use runner::ExecutionRequest; -pub(crate) use runner::Executor; -pub(crate) use runner::ExecutorConfig; -pub(crate) use runner::normalize_exec_result; - -pub(crate) mod linkers { - use crate::exec::ExecParams; - use crate::exec::StdoutStream; - use crate::executor::backends::ExecutionMode; - use crate::executor::runner::ExecutionRequest; - use crate::tools::context::ExecCommandContext; - - pub struct PreparedExec { - pub(crate) context: ExecCommandContext, - pub(crate) request: ExecutionRequest, - } - - impl PreparedExec { - pub fn new( - context: ExecCommandContext, - params: ExecParams, - approval_command: Vec, - mode: ExecutionMode, - stdout_stream: Option, - use_shell_profile: bool, - ) -> Self { - let request = ExecutionRequest { - params, - approval_command, - mode, - stdout_stream, - use_shell_profile, - }; - - Self { context, request } - } - } -} - -pub mod errors { - use crate::error::CodexErr; - use crate::function_tool::FunctionCallError; - use thiserror::Error; - - #[derive(Debug, Error)] - pub enum ExecError { - #[error(transparent)] - Function(#[from] FunctionCallError), - #[error(transparent)] - Codex(#[from] CodexErr), - } - - impl ExecError { - pub(crate) fn rejection(msg: impl Into) -> Self { - FunctionCallError::RespondToModel(msg.into()).into() - } - } -} diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs deleted file mode 100644 index f475aad67e..0000000000 --- a/codex-rs/core/src/executor/runner.rs +++ /dev/null @@ -1,409 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::RwLock; -use std::time::Duration; - -use super::backends::ExecutionMode; -use super::backends::backend_for_mode; -use super::cache::ApprovalCache; -use crate::codex::Session; -use crate::error::CodexErr; -use crate::error::SandboxErr; -use crate::error::get_error_message_ui; -use crate::exec::ExecParams; -use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; -use crate::exec::StdoutStream; -use crate::exec::StreamOutput; -use crate::exec::process_exec_tool_call; -use crate::executor::errors::ExecError; -use crate::executor::sandbox::select_sandbox; -use crate::function_tool::FunctionCallError; -use crate::protocol::AskForApproval; -use crate::protocol::ReviewDecision; -use crate::protocol::SandboxPolicy; -use crate::shell; -use crate::tools::context::ExecCommandContext; -use codex_otel::otel_event_manager::ToolDecisionSource; - -#[derive(Clone, Debug)] -pub(crate) struct ExecutorConfig { - pub(crate) sandbox_policy: SandboxPolicy, - pub(crate) sandbox_cwd: PathBuf, - codex_linux_sandbox_exe: Option, -} - -impl ExecutorConfig { - pub(crate) fn new( - sandbox_policy: SandboxPolicy, - sandbox_cwd: PathBuf, - codex_linux_sandbox_exe: Option, - ) -> Self { - Self { - sandbox_policy, - sandbox_cwd, - codex_linux_sandbox_exe, - } - } -} - -/// Coordinates sandbox selection, backend-specific preparation, and command -/// execution for tool calls requested by the model. -pub(crate) struct Executor { - approval_cache: ApprovalCache, - config: Arc>, -} - -impl Executor { - pub(crate) fn new(config: ExecutorConfig) -> Self { - Self { - approval_cache: ApprovalCache::default(), - config: Arc::new(RwLock::new(config)), - } - } - - /// Updates the sandbox policy and working directory used for future - /// executions without recreating the executor. - pub(crate) fn update_environment(&self, sandbox_policy: SandboxPolicy, sandbox_cwd: PathBuf) { - if let Ok(mut cfg) = self.config.write() { - cfg.sandbox_policy = sandbox_policy; - cfg.sandbox_cwd = sandbox_cwd; - } - } - - /// Runs a prepared execution request end-to-end: prepares parameters, decides on - /// sandbox placement (prompting the user when necessary), launches the command, - /// and lets the backend post-process the final output. - pub(crate) async fn run( - &self, - mut request: ExecutionRequest, - session: &Session, - approval_policy: AskForApproval, - context: &ExecCommandContext, - ) -> Result { - if matches!(request.mode, ExecutionMode::Shell) { - request.params = - maybe_translate_shell_command(request.params, session, request.use_shell_profile); - } - - // Step 1: Normalise parameters via the selected backend. - let backend = backend_for_mode(&request.mode); - let stdout_stream = if backend.stream_stdout(&request.mode) { - request.stdout_stream.clone() - } else { - None - }; - request.params = backend - .prepare(request.params, &request.mode) - .map_err(ExecError::from)?; - - // Step 2: Snapshot sandbox configuration so it stays stable for this run. - let config = self - .config - .read() - .map_err(|_| ExecError::rejection("executor config poisoned"))? - .clone(); - - // Step 3: Decide sandbox placement, prompting for approval when needed. - let sandbox_decision = select_sandbox( - &request, - approval_policy, - self.approval_cache.snapshot(), - &config, - session, - &context.sub_id, - &context.call_id, - &context.otel_event_manager, - ) - .await?; - if sandbox_decision.record_session_approval { - self.approval_cache.insert(request.approval_command.clone()); - } - - // Step 4: Launch the command within the chosen sandbox. - let first_attempt = self - .spawn( - request.params.clone(), - sandbox_decision.initial_sandbox, - &config, - stdout_stream.clone(), - ) - .await; - - // Step 5: Handle sandbox outcomes, optionally escalating to an unsandboxed retry. - match first_attempt { - Ok(output) => Ok(output), - Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => { - Err(CodexErr::Sandbox(SandboxErr::Timeout { output }).into()) - } - Err(CodexErr::Sandbox(error)) => { - if sandbox_decision.escalate_on_failure { - self.retry_without_sandbox( - &request, - &config, - session, - context, - stdout_stream, - error, - ) - .await - } else { - let message = sandbox_failure_message(error); - Err(ExecError::rejection(message)) - } - } - Err(err) => Err(err.into()), - } - } - - /// Fallback path invoked when a sandboxed run is denied so the user can - /// approve rerunning without isolation. - async fn retry_without_sandbox( - &self, - request: &ExecutionRequest, - config: &ExecutorConfig, - session: &Session, - context: &ExecCommandContext, - stdout_stream: Option, - sandbox_error: SandboxErr, - ) -> Result { - session - .notify_background_event( - &context.sub_id, - format!("Execution failed: {sandbox_error}"), - ) - .await; - let decision = session - .request_command_approval( - context.sub_id.to_string(), - context.call_id.to_string(), - request.approval_command.clone(), - request.params.cwd.clone(), - Some("command failed; retry without sandbox?".to_string()), - ) - .await; - - context.otel_event_manager.tool_decision( - &context.tool_name, - &context.call_id, - decision, - ToolDecisionSource::User, - ); - match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { - if matches!(decision, ReviewDecision::ApprovedForSession) { - self.approval_cache.insert(request.approval_command.clone()); - } - session - .notify_background_event(&context.sub_id, "retrying command without sandbox") - .await; - - let retry_output = self - .spawn( - request.params.clone(), - SandboxType::None, - config, - stdout_stream, - ) - .await?; - - Ok(retry_output) - } - ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) - } - } - } - - async fn spawn( - &self, - params: ExecParams, - sandbox: SandboxType, - config: &ExecutorConfig, - stdout_stream: Option, - ) -> Result { - process_exec_tool_call( - params, - sandbox, - &config.sandbox_policy, - &config.sandbox_cwd, - &config.codex_linux_sandbox_exe, - stdout_stream, - ) - .await - } -} - -fn maybe_translate_shell_command( - params: ExecParams, - session: &Session, - use_shell_profile: bool, -) -> ExecParams { - let should_translate = - matches!(session.user_shell(), shell::Shell::PowerShell(_)) || use_shell_profile; - - if should_translate - && let Some(command) = session - .user_shell() - .format_default_shell_invocation(params.command.clone()) - { - return ExecParams { command, ..params }; - } - - params -} - -fn sandbox_failure_message(error: SandboxErr) -> String { - let codex_error = CodexErr::Sandbox(error); - let friendly = get_error_message_ui(&codex_error); - format!("failed in sandbox: {friendly}") -} - -pub(crate) struct ExecutionRequest { - pub params: ExecParams, - pub approval_command: Vec, - pub mode: ExecutionMode, - pub stdout_stream: Option, - pub use_shell_profile: bool, -} - -pub(crate) struct NormalizedExecOutput<'a> { - borrowed: Option<&'a ExecToolCallOutput>, - synthetic: Option, -} - -impl<'a> NormalizedExecOutput<'a> { - pub(crate) fn event_output(&'a self) -> &'a ExecToolCallOutput { - match (self.borrowed, self.synthetic.as_ref()) { - (Some(output), _) => output, - (None, Some(output)) => output, - (None, None) => unreachable!("normalized exec output missing data"), - } - } -} - -/// Converts a raw execution result into a uniform view that always exposes an -/// [`ExecToolCallOutput`], synthesizing error output when the command fails -/// before producing a response. -pub(crate) fn normalize_exec_result( - result: &Result, -) -> NormalizedExecOutput<'_> { - match result { - Ok(output) => NormalizedExecOutput { - borrowed: Some(output), - synthetic: None, - }, - Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => { - NormalizedExecOutput { - borrowed: Some(output.as_ref()), - synthetic: None, - } - } - Err(err) => { - let message = match err { - ExecError::Function(FunctionCallError::RespondToModel(msg)) => msg.clone(), - ExecError::Codex(e) => get_error_message_ui(e), - err => err.to_string(), - }; - let synthetic = ExecToolCallOutput { - exit_code: -1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(message.clone()), - aggregated_output: StreamOutput::new(message), - duration: Duration::default(), - timed_out: false, - }; - NormalizedExecOutput { - borrowed: None, - synthetic: Some(synthetic), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::CodexErr; - use crate::error::EnvVarError; - use crate::error::SandboxErr; - use crate::exec::StreamOutput; - use pretty_assertions::assert_eq; - - fn make_output(text: &str) -> ExecToolCallOutput { - ExecToolCallOutput { - exit_code: 1, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(text.to_string()), - duration: Duration::from_millis(123), - timed_out: false, - } - } - - #[test] - fn normalize_success_borrows() { - let out = make_output("ok"); - let result: Result = Ok(out); - let normalized = normalize_exec_result(&result); - assert_eq!(normalized.event_output().aggregated_output.text, "ok"); - } - - #[test] - fn normalize_timeout_borrows_embedded_output() { - let out = make_output("timed out payload"); - let err = CodexErr::Sandbox(SandboxErr::Timeout { - output: Box::new(out), - }); - let result: Result = Err(ExecError::Codex(err)); - let normalized = normalize_exec_result(&result); - assert_eq!( - normalized.event_output().aggregated_output.text, - "timed out payload" - ); - } - - #[test] - fn sandbox_failure_message_uses_denied_stderr() { - let output = ExecToolCallOutput { - exit_code: 101, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new("sandbox stderr".to_string()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::from_millis(10), - timed_out: false, - }; - let err = SandboxErr::Denied { - output: Box::new(output), - }; - let message = sandbox_failure_message(err); - assert_eq!(message, "failed in sandbox: sandbox stderr"); - } - - #[test] - fn normalize_function_error_synthesizes_payload() { - let err = FunctionCallError::RespondToModel("boom".to_string()); - let result: Result = Err(ExecError::Function(err)); - let normalized = normalize_exec_result(&result); - assert_eq!(normalized.event_output().aggregated_output.text, "boom"); - } - - #[test] - fn normalize_codex_error_synthesizes_user_message() { - // Use a simple EnvVar error which formats to a clear message - let e = CodexErr::EnvVar(EnvVarError { - var: "FOO".to_string(), - instructions: Some("set it".to_string()), - }); - let result: Result = Err(ExecError::Codex(e)); - let normalized = normalize_exec_result(&result); - assert!( - normalized - .event_output() - .aggregated_output - .text - .contains("Missing environment variable: `FOO`"), - "expected synthesized user-friendly message" - ); - } -} diff --git a/codex-rs/core/src/executor/sandbox.rs b/codex-rs/core/src/executor/sandbox.rs deleted file mode 100644 index 5c01ff69b4..0000000000 --- a/codex-rs/core/src/executor/sandbox.rs +++ /dev/null @@ -1,405 +0,0 @@ -use crate::apply_patch::ApplyPatchExec; -use crate::codex::Session; -use crate::exec::SandboxType; -use crate::executor::ExecutionMode; -use crate::executor::ExecutionRequest; -use crate::executor::ExecutorConfig; -use crate::executor::errors::ExecError; -use crate::safety::SafetyCheck; -use crate::safety::assess_command_safety; -use crate::safety::assess_patch_safety; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_otel::otel_event_manager::ToolDecisionSource; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ReviewDecision; -use std::collections::HashSet; - -/// Sandbox placement options selected for an execution run, including whether -/// to escalate after failures and whether approvals should persist. -pub(crate) struct SandboxDecision { - pub(crate) initial_sandbox: SandboxType, - pub(crate) escalate_on_failure: bool, - pub(crate) record_session_approval: bool, -} - -impl SandboxDecision { - fn auto(sandbox: SandboxType, escalate_on_failure: bool) -> Self { - Self { - initial_sandbox: sandbox, - escalate_on_failure, - record_session_approval: false, - } - } - - fn user_override(record_session_approval: bool) -> Self { - Self { - initial_sandbox: SandboxType::None, - escalate_on_failure: false, - record_session_approval, - } - } -} - -fn should_escalate_on_failure(approval: AskForApproval, sandbox: SandboxType) -> bool { - matches!( - (approval, sandbox), - ( - AskForApproval::UnlessTrusted | AskForApproval::OnFailure, - SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp - ) - ) -} - -/// Determines how a command should be sandboxed, prompting the user when -/// policy requires explicit approval. -#[allow(clippy::too_many_arguments)] -pub async fn select_sandbox( - request: &ExecutionRequest, - approval_policy: AskForApproval, - approval_cache: HashSet>, - config: &ExecutorConfig, - session: &Session, - sub_id: &str, - call_id: &str, - otel_event_manager: &OtelEventManager, -) -> Result { - match &request.mode { - ExecutionMode::Shell => { - select_shell_sandbox( - request, - approval_policy, - approval_cache, - config, - session, - sub_id, - call_id, - otel_event_manager, - ) - .await - } - ExecutionMode::ApplyPatch(exec) => { - select_apply_patch_sandbox(exec, approval_policy, config) - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn select_shell_sandbox( - request: &ExecutionRequest, - approval_policy: AskForApproval, - approved_snapshot: HashSet>, - config: &ExecutorConfig, - session: &Session, - sub_id: &str, - call_id: &str, - otel_event_manager: &OtelEventManager, -) -> Result { - let command_for_safety = if request.approval_command.is_empty() { - request.params.command.clone() - } else { - request.approval_command.clone() - }; - - let safety = assess_command_safety( - &command_for_safety, - approval_policy, - &config.sandbox_policy, - &approved_snapshot, - request.params.with_escalated_permissions.unwrap_or(false), - ); - - match safety { - SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved, - } => { - let mut decision = SandboxDecision::auto( - sandbox_type, - should_escalate_on_failure(approval_policy, sandbox_type), - ); - if user_explicitly_approved { - decision.record_session_approval = true; - } - let (decision_for_event, source) = if user_explicitly_approved { - (ReviewDecision::ApprovedForSession, ToolDecisionSource::User) - } else { - (ReviewDecision::Approved, ToolDecisionSource::Config) - }; - otel_event_manager.tool_decision("local_shell", call_id, decision_for_event, source); - Ok(decision) - } - SafetyCheck::AskUser => { - let decision = session - .request_command_approval( - sub_id.to_string(), - call_id.to_string(), - request.approval_command.clone(), - request.params.cwd.clone(), - request.params.justification.clone(), - ) - .await; - - otel_event_manager.tool_decision( - "local_shell", - call_id, - decision, - ToolDecisionSource::User, - ); - match decision { - ReviewDecision::Approved => Ok(SandboxDecision::user_override(false)), - ReviewDecision::ApprovedForSession => Ok(SandboxDecision::user_override(true)), - ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) - } - } - } - SafetyCheck::Reject { reason } => Err(ExecError::rejection(format!( - "exec command rejected: {reason}" - ))), - } -} - -fn select_apply_patch_sandbox( - exec: &ApplyPatchExec, - approval_policy: AskForApproval, - config: &ExecutorConfig, -) -> Result { - if exec.user_explicitly_approved_this_action { - return Ok(SandboxDecision::user_override(false)); - } - - match assess_patch_safety( - &exec.action, - approval_policy, - &config.sandbox_policy, - &config.sandbox_cwd, - ) { - SafetyCheck::AutoApprove { sandbox_type, .. } => Ok(SandboxDecision::auto( - sandbox_type, - should_escalate_on_failure(approval_policy, sandbox_type), - )), - SafetyCheck::AskUser => Err(ExecError::rejection( - "patch requires approval but none was recorded", - )), - SafetyCheck::Reject { reason } => { - Err(ExecError::rejection(format!("patch rejected: {reason}"))) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codex::make_session_and_context; - use crate::exec::ExecParams; - use crate::function_tool::FunctionCallError; - use crate::protocol::SandboxPolicy; - use codex_apply_patch::ApplyPatchAction; - use pretty_assertions::assert_eq; - - #[tokio::test] - async fn select_apply_patch_user_override_when_explicit() { - let (session, ctx) = make_session_and_context(); - let tmp = tempfile::tempdir().expect("tmp"); - let p = tmp.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: true, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // Explicit user override runs without sandbox - assert_eq!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, false); - } - - #[tokio::test] - async fn select_apply_patch_autoapprove_in_danger() { - let (session, ctx) = make_session_and_context(); - let tmp = tempfile::tempdir().expect("tmp"); - let p = tmp.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: false, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // On platforms with a sandbox, DangerFullAccess still prefers it - let expected = crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None); - assert_eq!(decision.initial_sandbox, expected); - assert_eq!(decision.escalate_on_failure, false); - } - - #[tokio::test] - async fn select_apply_patch_requires_approval_on_unless_trusted() { - let (session, ctx) = make_session_and_context(); - let tempdir = tempfile::tempdir().expect("tmpdir"); - let p = tempdir.path().join("a.txt"); - let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); - let exec = ApplyPatchExec { - action, - user_explicitly_approved_this_action: false, - }; - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["apply_patch".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["apply_patch".into()], - mode: ExecutionMode::ApplyPatch(exec), - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let result = select_sandbox( - &request, - AskForApproval::UnlessTrusted, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await; - match result { - Ok(_) => panic!("expected error"), - Err(ExecError::Function(FunctionCallError::RespondToModel(msg))) => { - assert!(msg.contains("requires approval")) - } - Err(other) => panic!("unexpected error: {other:?}"), - } - } - - #[tokio::test] - async fn select_shell_autoapprove_in_danger_mode() { - let (session, ctx) = make_session_and_context(); - let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - command: vec!["some-unknown".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["some-unknown".into()], - mode: ExecutionMode::Shell, - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnRequest, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - assert_eq!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, false); - } - - #[cfg(any(target_os = "macos", target_os = "linux"))] - #[tokio::test] - async fn select_shell_escalates_on_failure_with_platform_sandbox() { - let (session, ctx) = make_session_and_context(); - let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None); - let request = ExecutionRequest { - params: ExecParams { - // Unknown command => untrusted but not flagged dangerous - command: vec!["some-unknown".into()], - cwd: std::env::temp_dir(), - timeout_ms: None, - env: std::collections::HashMap::new(), - with_escalated_permissions: None, - justification: None, - }, - approval_command: vec!["some-unknown".into()], - mode: ExecutionMode::Shell, - stdout_stream: None, - use_shell_profile: false, - }; - let otel_event_manager = ctx.client.get_otel_event_manager(); - let decision = select_sandbox( - &request, - AskForApproval::OnFailure, - Default::default(), - &cfg, - &session, - "sub", - "call", - &otel_event_manager, - ) - .await - .expect("ok"); - // On macOS/Linux we should have a platform sandbox and escalate on failure - assert_ne!(decision.initial_sandbox, SandboxType::None); - assert_eq!(decision.escalate_on_failure, true); - } -} diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs new file mode 100644 index 0000000000..e1a8928214 --- /dev/null +++ b/codex-rs/core/src/features.rs @@ -0,0 +1,258 @@ +//! Centralized feature flags and metadata. +//! +//! This module defines a small set of toggles that gate experimental and +//! optional behavior across the codebase. Instead of wiring individual +//! booleans through multiple types, call sites consult a single `Features` +//! container attached to `Config`. + +use crate::config::ConfigToml; +use crate::config_profile::ConfigProfile; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +mod legacy; +pub(crate) use legacy::LegacyFeatureToggles; + +/// High-level lifecycle stage for a feature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stage { + Experimental, + Beta, + Stable, + Deprecated, + Removed, +} + +/// Unique features toggled via configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Feature { + /// Use the single unified PTY-backed exec tool. + UnifiedExec, + /// Use the streamable exec-command/write-stdin tool pair. + StreamableShell, + /// Use the official Rust MCP client (rmcp). + RmcpClient, + /// Include the plan tool. + PlanTool, + /// Include the freeform apply_patch tool. + ApplyPatchFreeform, + /// Include the view_image tool. + ViewImageTool, + /// Allow the model to request web searches. + WebSearchRequest, + /// Automatically approve all approval requests from the harness. + ApproveAll, +} + +impl Feature { + pub fn key(self) -> &'static str { + self.info().key + } + + pub fn stage(self) -> Stage { + self.info().stage + } + + pub fn default_enabled(self) -> bool { + self.info().default_enabled + } + + fn info(self) -> &'static FeatureSpec { + FEATURES + .iter() + .find(|spec| spec.id == self) + .unwrap_or_else(|| unreachable!("missing FeatureSpec for {:?}", self)) + } +} + +/// Holds the effective set of enabled features. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Features { + enabled: BTreeSet, +} + +#[derive(Debug, Clone, Default)] +pub struct FeatureOverrides { + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub web_search_request: Option, +} + +impl FeatureOverrides { + fn apply(self, features: &mut Features) { + LegacyFeatureToggles { + include_plan_tool: self.include_plan_tool, + include_apply_patch_tool: self.include_apply_patch_tool, + include_view_image_tool: self.include_view_image_tool, + tools_web_search: self.web_search_request, + ..Default::default() + } + .apply(features); + } +} + +impl Features { + /// Starts with built-in defaults. + pub fn with_defaults() -> Self { + let mut set = BTreeSet::new(); + for spec in FEATURES { + if spec.default_enabled { + set.insert(spec.id); + } + } + Self { enabled: set } + } + + pub fn enabled(&self, f: Feature) -> bool { + self.enabled.contains(&f) + } + + pub fn enable(&mut self, f: Feature) { + self.enabled.insert(f); + } + + pub fn disable(&mut self, f: Feature) { + self.enabled.remove(&f); + } + + /// Apply a table of key -> bool toggles (e.g. from TOML). + pub fn apply_map(&mut self, m: &BTreeMap) { + for (k, v) in m { + match feature_for_key(k) { + Some(feat) => { + if *v { + self.enable(feat); + } else { + self.disable(feat); + } + } + None => { + tracing::warn!("unknown feature key in config: {k}"); + } + } + } + } + + pub fn from_config( + cfg: &ConfigToml, + config_profile: &ConfigProfile, + overrides: FeatureOverrides, + ) -> Self { + let mut features = Features::with_defaults(); + + let base_legacy = LegacyFeatureToggles { + experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, + experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool, + experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, + experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, + tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search), + tools_view_image: cfg.tools.as_ref().and_then(|t| t.view_image), + ..Default::default() + }; + base_legacy.apply(&mut features); + + if let Some(base_features) = cfg.features.as_ref() { + features.apply_map(&base_features.entries); + } + + let profile_legacy = LegacyFeatureToggles { + include_plan_tool: config_profile.include_plan_tool, + include_apply_patch_tool: config_profile.include_apply_patch_tool, + include_view_image_tool: config_profile.include_view_image_tool, + experimental_use_freeform_apply_patch: config_profile + .experimental_use_freeform_apply_patch, + experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool, + experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool, + experimental_use_rmcp_client: config_profile.experimental_use_rmcp_client, + tools_web_search: config_profile.tools_web_search, + tools_view_image: config_profile.tools_view_image, + }; + profile_legacy.apply(&mut features); + if let Some(profile_features) = config_profile.features.as_ref() { + features.apply_map(&profile_features.entries); + } + + overrides.apply(&mut features); + + features + } +} + +/// Keys accepted in `[features]` tables. +fn feature_for_key(key: &str) -> Option { + for spec in FEATURES { + if spec.key == key { + return Some(spec.id); + } + } + legacy::feature_for_key(key) +} + +/// Deserializable features table for TOML. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +pub struct FeaturesToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +/// Single, easy-to-read registry of all feature definitions. +#[derive(Debug, Clone, Copy)] +pub struct FeatureSpec { + pub id: Feature, + pub key: &'static str, + pub stage: Stage, + pub default_enabled: bool, +} + +pub const FEATURES: &[FeatureSpec] = &[ + FeatureSpec { + id: Feature::UnifiedExec, + key: "unified_exec", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::StreamableShell, + key: "streamable_shell", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::RmcpClient, + key: "rmcp_client", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::PlanTool, + key: "plan_tool", + stage: Stage::Stable, + default_enabled: false, + }, + FeatureSpec { + id: Feature::ApplyPatchFreeform, + key: "apply_patch_freeform", + stage: Stage::Beta, + default_enabled: false, + }, + FeatureSpec { + id: Feature::ViewImageTool, + key: "view_image_tool", + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::WebSearchRequest, + key: "web_search_request", + stage: Stage::Stable, + default_enabled: false, + }, + FeatureSpec { + id: Feature::ApproveAll, + key: "approve_all", + stage: Stage::Experimental, + default_enabled: false, + }, +]; diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs new file mode 100644 index 0000000000..3becb07e7c --- /dev/null +++ b/codex-rs/core/src/features/legacy.rs @@ -0,0 +1,158 @@ +use super::Feature; +use super::Features; +use tracing::info; + +#[derive(Clone, Copy)] +struct Alias { + legacy_key: &'static str, + feature: Feature, +} + +const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "experimental_use_unified_exec_tool", + feature: Feature::UnifiedExec, + }, + Alias { + legacy_key: "experimental_use_exec_command_tool", + feature: Feature::StreamableShell, + }, + Alias { + legacy_key: "experimental_use_rmcp_client", + feature: Feature::RmcpClient, + }, + Alias { + legacy_key: "experimental_use_freeform_apply_patch", + feature: Feature::ApplyPatchFreeform, + }, + Alias { + legacy_key: "include_apply_patch_tool", + feature: Feature::ApplyPatchFreeform, + }, + Alias { + legacy_key: "include_plan_tool", + feature: Feature::PlanTool, + }, + Alias { + legacy_key: "include_view_image_tool", + feature: Feature::ViewImageTool, + }, + Alias { + legacy_key: "web_search", + feature: Feature::WebSearchRequest, + }, +]; + +pub(crate) fn feature_for_key(key: &str) -> Option { + ALIASES + .iter() + .find(|alias| alias.legacy_key == key) + .map(|alias| { + log_alias(alias.legacy_key, alias.feature); + alias.feature + }) +} + +#[derive(Debug, Default)] +pub struct LegacyFeatureToggles { + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_rmcp_client: Option, + pub tools_web_search: Option, + pub tools_view_image: Option, +} + +impl LegacyFeatureToggles { + pub fn apply(self, features: &mut Features) { + set_if_some( + features, + Feature::PlanTool, + self.include_plan_tool, + "include_plan_tool", + ); + set_if_some( + features, + Feature::ApplyPatchFreeform, + self.include_apply_patch_tool, + "include_apply_patch_tool", + ); + set_if_some( + features, + Feature::ApplyPatchFreeform, + self.experimental_use_freeform_apply_patch, + "experimental_use_freeform_apply_patch", + ); + set_if_some( + features, + Feature::StreamableShell, + self.experimental_use_exec_command_tool, + "experimental_use_exec_command_tool", + ); + set_if_some( + features, + Feature::UnifiedExec, + self.experimental_use_unified_exec_tool, + "experimental_use_unified_exec_tool", + ); + set_if_some( + features, + Feature::RmcpClient, + self.experimental_use_rmcp_client, + "experimental_use_rmcp_client", + ); + set_if_some( + features, + Feature::WebSearchRequest, + self.tools_web_search, + "tools.web_search", + ); + set_if_some( + features, + Feature::ViewImageTool, + self.include_view_image_tool, + "include_view_image_tool", + ); + set_if_some( + features, + Feature::ViewImageTool, + self.tools_view_image, + "tools.view_image", + ); + } +} + +fn set_if_some( + features: &mut Features, + feature: Feature, + maybe_value: Option, + alias_key: &'static str, +) { + if let Some(enabled) = maybe_value { + set_feature(features, feature, enabled); + log_alias(alias_key, feature); + } +} + +fn set_feature(features: &mut Features, feature: Feature, enabled: bool) { + if enabled { + features.enable(feature); + } else { + features.disable(feature); + } +} + +fn log_alias(alias: &str, feature: Feature) { + let canonical = feature.key(); + if alias == canonical { + return; + } + info!( + %alias, + canonical, + "legacy feature toggle detected; prefer `[features].{canonical}`" + ); +} diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs index 240e04361c..9d24165569 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -4,6 +4,9 @@ use thiserror::Error; pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), + #[error("{0}")] + #[allow(dead_code)] // TODO(jif) fix in a follow-up PR + Denied(String), #[error("LocalShellCall without call_id or id")] MissingLocalShellCallId, #[error("Fatal error: {0}")] diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 264ea747ca..340aebff2c 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -40,7 +40,7 @@ where } /// Converts the sandbox policy into the CLI invocation for `codex-linux-sandbox`. -fn create_linux_sandbox_command_args( +pub(crate) fn create_linux_sandbox_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, @@ -56,7 +56,9 @@ fn create_linux_sandbox_command_args( serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON"); let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), sandbox_policy_cwd, + "--sandbox-policy".to_string(), sandbox_policy_json, // Separator so that command arguments starting with `-` are not parsed as // options of the helper itself. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 08baa2bdc6..fa307b1ea7 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,7 +13,6 @@ mod client; mod client_common; pub mod codex; mod codex_conversation; -pub mod token_data; pub use codex_conversation::CodexConversation; mod command_safety; pub mod config; @@ -26,17 +25,19 @@ pub mod custom_prompts; mod environment_context; pub mod error; pub mod exec; -mod exec_command; pub mod exec_env; -pub mod executor; +pub mod features; mod flags; pub mod git_info; pub mod landlock; +pub mod mcp; mod mcp_connection_manager; mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +pub mod sandboxing; +pub mod token_data; mod truncate; mod unified_exec; mod user_instructions; @@ -57,7 +58,6 @@ pub use auth::CodexAuth; pub mod default_client; pub mod model_family; mod openai_model_info; -mod openai_tools; pub mod project_doc; mod rollout; pub(crate) mod safety; @@ -105,5 +105,4 @@ pub use codex_protocol::models::LocalShellExecAction; pub use codex_protocol::models::LocalShellStatus; pub use codex_protocol::models::ReasoningItemContent; pub use codex_protocol::models::ResponseItem; - pub mod otel_init; diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/core/src/mcp/auth.rs new file mode 100644 index 0000000000..22d1f5f51f --- /dev/null +++ b/codex-rs/core/src/mcp/auth.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; + +use anyhow::Result; +use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_rmcp_client::determine_streamable_http_auth_status; +use futures::future::join_all; +use tracing::warn; + +use crate::config_types::McpServerConfig; +use crate::config_types::McpServerTransportConfig; + +pub async fn compute_auth_statuses<'a, I>( + servers: I, + store_mode: OAuthCredentialsStoreMode, +) -> HashMap +where + I: IntoIterator, +{ + let futures = servers.into_iter().map(|(name, config)| { + let name = name.clone(); + let config = config.clone(); + async move { + let status = match compute_auth_status(&name, &config, store_mode).await { + Ok(status) => status, + Err(error) => { + warn!("failed to determine auth status for MCP server `{name}`: {error:?}"); + McpAuthStatus::Unsupported + } + }; + (name, status) + } + }); + + join_all(futures).await.into_iter().collect() +} + +async fn compute_auth_status( + server_name: &str, + config: &McpServerConfig, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + match &config.transport { + McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported), + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + determine_streamable_http_auth_status( + server_name, + url, + bearer_token_env_var.as_deref(), + http_headers.clone(), + env_http_headers.clone(), + store_mode, + ) + .await + } + } +} diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs new file mode 100644 index 0000000000..0e4a05d597 --- /dev/null +++ b/codex-rs/core/src/mcp/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 0c2fdc6301..9ea634428c 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -21,6 +22,14 @@ use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use mcp_types::ClientCapabilities; use mcp_types::Implementation; +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 mcp_types::Resource; +use mcp_types::ResourceTemplate; use mcp_types::Tool; use serde_json::json; @@ -56,8 +65,8 @@ fn qualify_tools(tools: Vec) -> HashMap { let mut qualified_tools = HashMap::new(); for tool in tools { let mut qualified_name = format!( - "{}{}{}", - tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name + "mcp{}{}{}{}", + MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name ); if qualified_name.len() > MAX_TOOL_NAME_LENGTH { let mut hasher = Sha1::new(); @@ -102,36 +111,51 @@ enum McpClientAdapter { } impl McpClientAdapter { + #[allow(clippy::too_many_arguments)] async fn new_stdio_client( use_rmcp_client: bool, program: OsString, args: Vec, env: Option>, + env_vars: Vec, + cwd: Option, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, ) -> Result { if use_rmcp_client { - let client = Arc::new(RmcpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(RmcpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) } else { - let client = Arc::new(McpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(McpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Legacy(client)) } } + #[allow(clippy::too_many_arguments)] async fn new_streamable_http_client( server_name: String, url: String, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, store_mode: OAuthCredentialsStoreMode, ) -> Result { let client = Arc::new( - RmcpClient::new_streamable_http_client(&server_name, &url, bearer_token, store_mode) - .await?, + RmcpClient::new_streamable_http_client( + &server_name, + &url, + bearer_token, + http_headers, + env_http_headers, + store_mode, + ) + .await?, ); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) @@ -148,6 +172,47 @@ impl McpClientAdapter { } } + async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourcesResult { + next_cursor: None, + resources: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resources(params, timeout).await, + } + } + + async fn read_resource( + &self, + params: mcp_types::ReadResourceRequestParams, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Err(anyhow!( + "resources/read is not supported by legacy MCP clients" + )), + McpClientAdapter::Rmcp(client) => client.read_resource(params, timeout).await, + } + } + + async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resource_templates(params, timeout).await, + } + } + async fn call_tool( &self, name: String, @@ -207,6 +272,10 @@ impl McpConnectionManager { continue; } + if !cfg.enabled { + continue; + } + let startup_timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); let tool_timeout = cfg.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT); @@ -242,7 +311,13 @@ impl McpConnectionManager { }; let client = match transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let command_os: OsString = command.into(); let args_os: Vec = args.into_iter().map(Into::into).collect(); McpClientAdapter::new_stdio_client( @@ -250,16 +325,25 @@ impl McpConnectionManager { command_os, args_os, env, + env_vars, + cwd, params, startup_timeout, ) .await } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { McpClientAdapter::new_streamable_http_client( server_name.clone(), url, resolved_bearer_token.unwrap_or_default(), + http_headers, + env_http_headers, params, startup_timeout, store_mode, @@ -314,7 +398,7 @@ impl McpConnectionManager { Ok((Self { clients, tools }, errors)) } - /// Returns a single map that contains **all** tools. Each key is the + /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. pub fn list_all_tools(&self) -> HashMap { self.tools @@ -323,6 +407,133 @@ impl McpConnectionManager { .collect() } + /// Returns a single map that contains all resources. Each key is the + /// server name and the value is a vector of resources. + pub async fn list_all_resources(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| ListResourcesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resources(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resources); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!("resources/list returned duplicate cursor")), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(resources))) => { + aggregated.insert(server_name, resources); + } + Ok((server_name, Err(err))) => { + warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); + } + Err(err) => { + warn!("Task panic when listing resources for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Returns a single map that contains all resource templates. Each key is the + /// server name and the value is a vector of resource templates. + pub async fn list_all_resource_templates(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor + .as_ref() + .map(|next| ListResourceTemplatesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resource_templates(params, timeout).await + { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resource_templates); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!( + "resources/templates/list returned duplicate cursor" + )), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(templates))) => { + aggregated.insert(server_name, templates); + } + Ok((server_name, Err(err))) => { + warn!( + "Failed to list resource templates for MCP server '{server_name}': {err:#}" + ); + } + Err(err) => { + warn!("Task panic when listing resource templates for MCP server: {err:#}"); + } + } + } + + aggregated + } + /// Invoke the tool indicated by the (server, tool) pair. pub async fn call_tool( &self, @@ -334,7 +545,7 @@ impl McpConnectionManager { .clients .get(server) .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; - let client = managed.client.clone(); + let client = &managed.client; let timeout = managed.tool_timeout; client @@ -343,6 +554,64 @@ impl McpConnectionManager { .with_context(|| format!("tool call failed for `{server}/{tool}`")) } + /// List resources from the specified server. + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resources(params, timeout) + .await + .with_context(|| format!("resources/list failed for `{server}`")) + } + + /// List resource templates from the specified server. + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resource_templates(params, timeout) + .await + .with_context(|| format!("resources/templates/list failed for `{server}`")) + } + + /// Read a resource from the specified server. + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + let uri = params.uri.clone(); + + client + .read_resource(params, timeout) + .await + .with_context(|| format!("resources/read failed for `{server}` ({uri})")) + } + pub fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> { self.tools .get(tool_name) @@ -378,7 +647,7 @@ fn resolve_bearer_token( } /// Query every server for its available tools and return a single map that -/// contains **all** tools. Each key is the fully-qualified name for the tool. +/// contains all tools. Each key is the fully-qualified name for the tool. async fn list_all_tools(clients: &HashMap) -> Result> { let mut join_set = JoinSet::new(); @@ -472,8 +741,8 @@ mod tests { let qualified_tools = qualify_tools(tools); assert_eq!(qualified_tools.len(), 2); - assert!(qualified_tools.contains_key("server1__tool1")); - assert!(qualified_tools.contains_key("server1__tool2")); + assert!(qualified_tools.contains_key("mcp__server1__tool1")); + assert!(qualified_tools.contains_key("mcp__server1__tool2")); } #[test] @@ -487,7 +756,7 @@ mod tests { // Only the first tool should remain, the second is skipped assert_eq!(qualified_tools.len(), 1); - assert!(qualified_tools.contains_key("server1__duplicate_tool")); + assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool")); } #[test] @@ -515,13 +784,13 @@ mod tests { assert_eq!(keys[0].len(), 64); assert_eq!( keys[0], - "my_server__extremely_lena02e507efc5a9de88637e436690364fd4219e4ef" + "mcp__my_server__extremel119a2b97664e41363932dc84de21e2ff1b93b3e9" ); assert_eq!(keys[1].len(), 64); assert_eq!( keys[1], - "my_server__yet_another_e1c3987bd9c50b826cbe1687966f79f0c602d19ca" + "mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341" ); } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 7091a05656..21fdaf0026 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -58,7 +58,10 @@ pub(crate) async fn handle_mcp_tool_call( let result = sess .call_tool(&server, &tool_name, arguments_value.clone()) .await - .map_err(|e| format!("tool call error: {e}")); + .map_err(|e| format!("tool call error: {e:?}")); + if let Err(e) = &result { + tracing::warn!("MCP tool call error: {e:?}"); + } let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 80b3f27986..623cc26ef9 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -48,6 +48,12 @@ pub struct ModelFamily { /// Names of beta tools that should be exposed to this model family. pub experimental_supported_tools: Vec, + + /// Percentage of the context window considered usable for inputs, after + /// reserving headroom for system prompts, tool overhead, and model output. + /// This is applied when computing the effective context window seen by + /// consumers. + pub effective_context_window_percent: i64, } macro_rules! model_family { @@ -66,6 +72,7 @@ macro_rules! model_family { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, }; // apply overrides $( @@ -175,5 +182,6 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, } } diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index d1d2305aaa..e0d0bfd1b2 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -1,5 +1,9 @@ use crate::model_family::ModelFamily; +// Shared constants for commonly used window/token sizes. +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; +pub(crate) const MAX_OUTPUT_TOKENS_128K: i64 = 128_000; + /// Metadata about a model, particularly OpenAI models. /// We may want to consider including details like the pricing for /// input tokens, output tokens, etc., though users will need to be able to @@ -8,10 +12,10 @@ use crate::model_family::ModelFamily; #[derive(Debug)] pub(crate) struct ModelInfo { /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: u64, + pub(crate) context_window: i64, /// Maximum number of output tokens that can be generated for the model. - pub(crate) max_output_tokens: u64, + pub(crate) max_output_tokens: i64, /// Token threshold where we should automatically compact conversation history. This considers /// input tokens + output tokens of this turn. @@ -19,13 +23,17 @@ pub(crate) struct ModelInfo { } impl ModelInfo { - const fn new(context_window: u64, max_output_tokens: u64) -> Self { + const fn new(context_window: i64, max_output_tokens: i64) -> Self { Self { context_window, max_output_tokens, - auto_compact_token_limit: None, + auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), } } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } } pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { @@ -62,15 +70,17 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { // https://platform.openai.com/docs/models/gpt-3.5-turbo "gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)), - _ if slug.starts_with("gpt-5-codex") => Some(ModelInfo { - context_window: 272_000, - max_output_tokens: 128_000, - auto_compact_token_limit: Some(350_000), - }), + _ if slug.starts_with("gpt-5-codex") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("gpt-5") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } - _ if slug.starts_with("codex-") => Some(ModelInfo::new(272_000, 128_000)), + _ if slug.starts_with("codex-") => { + Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + } _ => None, } diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs deleted file mode 100644 index 0e10f909ec..0000000000 --- a/codex-rs/core/src/openai_tools.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::tools::spec::*; diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 3c89b61c91..e7fa55687c 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1,43 +1,9 @@ use crate::bash::try_parse_bash; use crate::bash::try_parse_word_only_commands_sequence; -use serde::Deserialize; -use serde::Serialize; +use codex_protocol::parse_command::ParsedCommand; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -pub enum ParsedCommand { - Read { - cmd: String, - name: String, - }, - ListFiles { - cmd: String, - path: Option, - }, - Search { - cmd: String, - query: Option, - path: Option, - }, - Unknown { - cmd: String, - }, -} - -// Convert core's parsed command enum into the protocol's simplified type so -// events can carry the canonical representation across process boundaries. -impl From for codex_protocol::parse_command::ParsedCommand { - fn from(v: ParsedCommand) -> Self { - use codex_protocol::parse_command::ParsedCommand as P; - match v { - ParsedCommand::Read { cmd, name } => P::Read { cmd, name }, - ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path }, - ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path }, - ParsedCommand::Unknown { cmd } => P::Unknown { cmd }, - } - } -} +use std::path::PathBuf; fn shlex_join(tokens: &[String]) -> String { shlex_try_join(tokens.iter().map(String::as_str)) @@ -72,6 +38,7 @@ pub fn parse_command(command: &[String]) -> Vec { /// Tests are at the top to encourage using TDD + Codex to fix the implementation. mod tests { use super::*; + use std::path::PathBuf; use std::string::ToString; fn shlex_split_safe(s: &str) -> Vec { @@ -221,6 +188,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("webview/README.md"), }], ); } @@ -232,6 +200,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -254,6 +223,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -278,6 +248,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -290,6 +261,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("tui/Cargo.toml"), }], ); } @@ -302,6 +274,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -315,6 +288,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }] ); } @@ -484,6 +458,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "parse_command.rs".to_string(), + path: PathBuf::from("core/src/parse_command.rs"), }], ); } @@ -496,6 +471,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "history_cell.rs".to_string(), + path: PathBuf::from("tui/src/history_cell.rs"), }], ); } @@ -509,6 +485,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ansi-escape/Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("ansi-escape/Cargo.toml"), }], ); } @@ -538,6 +515,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '260,640p' exec/src/event_processor_with_human_output.rs".to_string(), name: "event_processor_with_human_output.rs".to_string(), + path: PathBuf::from("exec/src/event_processor_with_human_output.rs"), }], ); } @@ -697,6 +675,7 @@ mod tests { vec![ParsedCommand::Read { cmd: r#"cat "pkg\\src\\main.rs""#.to_string(), name: "main.rs".to_string(), + path: PathBuf::from(r#"pkg\src\main.rs"#), }], ); } @@ -708,6 +687,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "head -n50 Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -738,6 +718,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "tail -n+10 README.md".to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -774,6 +755,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ./-strange-file-name".to_string(), name: "-strange-file-name".to_string(), + path: PathBuf::from("./-strange-file-name"), }], ); @@ -783,6 +765,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '12,20p' Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -875,11 +858,39 @@ pub fn parse_command_impl(command: &[String]) -> Vec { // Preserve left-to-right execution order for all commands, including bash -c/-lc // so summaries reflect the order they will run. - // Map each pipeline segment to its parsed summary. - let mut commands: Vec = parts - .iter() - .map(|tokens| summarize_main_tokens(tokens)) - .collect(); + // Map each pipeline segment to its parsed summary, tracking `cd` to compute paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in &parts { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } while let Some(next) = simplify_once(&commands) { commands = next; @@ -1164,10 +1175,39 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { cmd: script.clone(), }]); } - let mut commands: Vec = filtered_commands - .into_iter() - .map(|tokens| summarize_main_tokens(&tokens)) - .collect(); + // Build parsed commands, tracking `cd` segments to compute effective file paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in filtered_commands.into_iter() { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(&tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } if commands.len() > 1 { commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. @@ -1187,7 +1227,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { commands = commands .into_iter() .map(|pc| match pc { - ParsedCommand::Read { name, cmd, .. } => { + ParsedCommand::Read { name, cmd, path } => { if had_connectors { let has_pipe = script_tokens.iter().any(|t| t == "|"); let has_sed_n = script_tokens.windows(2).any(|w| { @@ -1198,14 +1238,16 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { ParsedCommand::Read { cmd: script.clone(), name, + path, } } else { - ParsedCommand::Read { cmd, name } + ParsedCommand::Read { cmd, name, path } } } else { ParsedCommand::Read { cmd: shlex_join(&script_tokens), name, + path, } } } @@ -1370,10 +1412,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { tail }; if effective_tail.len() == 1 { - let name = short_display_path(&effective_tail[0]); + let path = effective_tail[0].clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1408,10 +1452,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1450,10 +1496,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1465,10 +1513,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { // Avoid treating option values as paths (e.g., nl -s " "). let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]); if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1483,10 +1533,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { && is_valid_sed_n_arg(tail.get(1).map(String::as_str)) => { if let Some(path) = tail.get(2) { - let name = short_display_path(path); + let path = path.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1500,3 +1552,30 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }, } } + +fn is_abs_like(path: &str) -> bool { + if std::path::Path::new(path).is_absolute() { + return true; + } + let mut chars = path.chars(); + match (chars.next(), chars.next(), chars.next()) { + // Windows drive path like C:\ + (Some(d), Some(':'), Some('\\')) if d.is_ascii_alphabetic() => return true, + // UNC path like \\server\share + (Some('\\'), Some('\\'), _) => return true, + _ => {} + } + false +} + +fn join_paths(base: &str, rel: &str) -> String { + if is_abs_like(rel) { + return rel.to_string(); + } + if base.is_empty() { + return rel.to_string(); + } + let mut buf = PathBuf::from(base); + buf.push(rel); + buf.to_string_lossy().to_string() +} diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index c33b1a5dac..c8076aa822 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -21,6 +21,8 @@ use tracing::error; /// Default filename scanned for project-level docs. pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md"; +/// Preferred local override for project-level docs. +pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md"; /// When both `Config::instructions` and the project doc are present, they will /// be concatenated with the following separator. @@ -178,7 +180,8 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result(config: &'a Config) -> Vec<&'a str> { let mut names: Vec<&'a str> = - Vec::with_capacity(1 + config.project_doc_fallback_filenames.len()); + Vec::with_capacity(2 + config.project_doc_fallback_filenames.len()); + names.push(LOCAL_PROJECT_DOC_FILENAME); names.push(DEFAULT_PROJECT_DOC_FILENAME); for candidate in &config.project_doc_fallback_filenames { let candidate = candidate.as_str(); @@ -381,6 +384,29 @@ mod tests { assert_eq!(res, "root doc\n\ncrate doc"); } + /// AGENTS.override.md is preferred over AGENTS.md when both are present. + #[tokio::test] + async fn agents_local_md_preferred() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap(); + fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap(); + + let cfg = make_config(&tmp, 4096, None); + + let res = get_user_instructions(&cfg) + .await + .expect("local doc expected"); + + assert_eq!(res, "local"); + + let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); + assert_eq!(discovery.len(), 1); + assert_eq!( + discovery[0].file_name().unwrap().to_string_lossy(), + LOCAL_PROJECT_DOC_FILENAME + ); + } + /// When AGENTS.md is absent but a configured fallback exists, the fallback is used. #[tokio::test] async fn uses_configured_fallback_when_agents_missing() { diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 0ed0f929ff..4492947d58 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::path::Component; use std::path::Path; use std::path::PathBuf; @@ -8,8 +7,6 @@ use codex_apply_patch::ApplyPatchFileChange; use crate::exec::SandboxType; -use crate::command_safety::is_dangerous_command::command_might_be_dangerous; -use crate::command_safety::is_safe_command::is_known_safe_command; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; @@ -48,30 +45,29 @@ pub fn assess_patch_safety( } } - // Even though the patch *appears* to be constrained to writable paths, it - // is possible that paths in the patch are hard links to files outside the - // writable roots, so we should still run `apply_patch` in a sandbox in that - // case. + // Even though the patch appears to be constrained to writable paths, it is + // possible that paths in the patch are hard links to files outside the + // writable roots, so we should still run `apply_patch` in a sandbox in that case. if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd) || policy == AskForApproval::OnFailure { - // Only auto‑approve when we can actually enforce a sandbox. Otherwise - // fall back to asking the user because the patch may touch arbitrary - // paths outside the project. - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + // DangerFullAccess is intended to bypass sandboxing entirely. + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, user_explicitly_approved: false, - }, - None if sandbox_policy == &SandboxPolicy::DangerFullAccess => { - // If the user has explicitly requested DangerFullAccess, then - // we can auto-approve even without a sandbox. - SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: false, - } } - None => SafetyCheck::AskUser, + } else { + // Only auto‑approve when we can actually enforce a sandbox. Otherwise + // fall back to asking the user because the patch may touch arbitrary + // paths outside the project. + match get_platform_sandbox() { + Some(sandbox_type) => SafetyCheck::AutoApprove { + sandbox_type, + user_explicitly_approved: false, + }, + None => SafetyCheck::AskUser, + } } } else if policy == AskForApproval::Never { SafetyCheck::Reject { @@ -83,124 +79,6 @@ pub fn assess_patch_safety( } } -/// For a command to be run _without_ a sandbox, one of the following must be -/// true: -/// -/// - the user has explicitly approved the command -/// - the command is on the "known safe" list -/// - `DangerFullAccess` was specified and `UnlessTrusted` was not -pub fn assess_command_safety( - command: &[String], - approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - approved: &HashSet>, - with_escalated_permissions: bool, -) -> SafetyCheck { - // Some commands look dangerous. Even if they are run inside a sandbox, - // unless the user has explicitly approved them, we should ask, - // or reject if the approval_policy tells us not to ask. - if command_might_be_dangerous(command) && !approved.contains(command) { - if approval_policy == AskForApproval::Never { - return SafetyCheck::Reject { - reason: "dangerous command detected; rejected by user approval settings" - .to_string(), - }; - } - - return SafetyCheck::AskUser; - } - - // A command is "trusted" because either: - // - it belongs to a set of commands we consider "safe" by default, or - // - the user has explicitly approved the command for this session - // - // Currently, whether a command is "trusted" is a simple boolean, but we - // should include more metadata on this command test to indicate whether it - // should be run inside a sandbox or not. (This could be something the user - // defines as part of `execpolicy`.) - // - // For example, when `is_known_safe_command(command)` returns `true`, it - // would probably be fine to run the command in a sandbox, but when - // `approved.contains(command)` is `true`, the user may have approved it for - // the session _because_ they know it needs to run outside a sandbox. - - if is_known_safe_command(command) || approved.contains(command) { - let user_explicitly_approved = approved.contains(command); - return SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved, - }; - } - - assess_safety_for_untrusted_command(approval_policy, sandbox_policy, with_escalated_permissions) -} - -pub(crate) fn assess_safety_for_untrusted_command( - approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, - with_escalated_permissions: bool, -) -> SafetyCheck { - use AskForApproval::*; - use SandboxPolicy::*; - - match (approval_policy, sandbox_policy) { - (UnlessTrusted, _) => { - // Even though the user may have opted into DangerFullAccess, - // they also requested that we ask for approval for untrusted - // commands. - SafetyCheck::AskUser - } - (OnFailure, DangerFullAccess) - | (Never, DangerFullAccess) - | (OnRequest, DangerFullAccess) => SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: false, - }, - (OnRequest, ReadOnly) | (OnRequest, WorkspaceWrite { .. }) => { - if with_escalated_permissions { - SafetyCheck::AskUser - } else { - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - // Fall back to asking since the command is untrusted and - // we do not have a sandbox available - None => SafetyCheck::AskUser, - } - } - } - (Never, ReadOnly) - | (Never, WorkspaceWrite { .. }) - | (OnFailure, ReadOnly) - | (OnFailure, WorkspaceWrite { .. }) => { - match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - None => { - if matches!(approval_policy, OnFailure) { - // Since the command is not trusted, even though the - // user has requested to only ask for approval on - // failure, we will ask the user because no sandbox is - // available. - SafetyCheck::AskUser - } else { - // We are in non-interactive mode and lack approval, so - // all we can do is reject the command. - SafetyCheck::Reject { - reason: "auto-rejected because command is not on trusted list" - .to_string(), - } - } - } - } - } - } -} - pub fn get_platform_sandbox() -> Option { if cfg!(target_os = "macos") { Some(SandboxType::MacosSeatbelt) @@ -339,101 +217,4 @@ mod tests { &cwd, )); } - - #[test] - fn test_request_escalated_privileges() { - // Should not be a trusted command - let command = vec!["git commit".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = true; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!(safety_check, SafetyCheck::AskUser); - } - - #[test] - fn dangerous_command_allowed_if_explicitly_approved() { - let command = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let mut approved: HashSet> = HashSet::new(); - approved.insert(command.clone()); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!( - safety_check, - SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - user_explicitly_approved: true, - } - ); - } - - #[test] - fn dangerous_command_not_allowed_if_not_explicitly_approved() { - let command = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()]; - let approval_policy = AskForApproval::Never; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - assert_eq!( - safety_check, - SafetyCheck::Reject { - reason: "dangerous command detected; rejected by user approval settings" - .to_string(), - } - ); - } - - #[test] - fn test_request_escalated_privileges_no_sandbox_fallback() { - let command = vec!["git".to_string(), "commit".to_string()]; - let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::ReadOnly; - let approved: HashSet> = HashSet::new(); - let request_escalated_privileges = false; - - let safety_check = assess_command_safety( - &command, - approval_policy, - &sandbox_policy, - &approved, - request_escalated_privileges, - ); - - let expected = match get_platform_sandbox() { - Some(sandbox_type) => SafetyCheck::AutoApprove { - sandbox_type, - user_explicitly_approved: false, - }, - None => SafetyCheck::AskUser, - }; - assert_eq!(safety_check, expected); - } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs new file mode 100644 index 0000000000..d632b5da19 --- /dev/null +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -0,0 +1,170 @@ +/* +Module: sandboxing + +Build platform wrappers and produce ExecEnv for execution. Owns low‑level +sandbox placement and transformation of portable CommandSpec into a +ready‑to‑spawn environment. +*/ +use crate::exec::ExecToolCallOutput; +use crate::exec::SandboxType; +use crate::exec::StdoutStream; +use crate::exec::execute_exec_env; +use crate::landlock::create_linux_sandbox_command_args; +use crate::protocol::SandboxPolicy; +use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use crate::seatbelt::create_seatbelt_command_args; +use crate::spawn::CODEX_SANDBOX_ENV_VAR; +use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use crate::tools::sandboxing::SandboxablePreference; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct CommandSpec { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub timeout_ms: Option, + pub with_escalated_permissions: Option, + pub justification: Option, +} + +#[derive(Clone, Debug)] +pub struct ExecEnv { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub timeout_ms: Option, + pub sandbox: SandboxType, + pub with_escalated_permissions: Option, + pub justification: Option, + pub arg0: Option, +} + +pub enum SandboxPreference { + Auto, + Require, + Forbid, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SandboxTransformError { + #[error("missing codex-linux-sandbox executable path")] + MissingLinuxSandboxExecutable, +} + +#[derive(Default)] +pub struct SandboxManager; + +impl SandboxManager { + pub fn new() -> Self { + Self + } + + pub(crate) fn select_initial( + &self, + policy: &SandboxPolicy, + pref: SandboxablePreference, + ) -> SandboxType { + match pref { + SandboxablePreference::Forbid => SandboxType::None, + SandboxablePreference::Require => { + #[cfg(target_os = "macos")] + { + return SandboxType::MacosSeatbelt; + } + #[cfg(target_os = "linux")] + { + return SandboxType::LinuxSeccomp; + } + #[allow(unreachable_code)] + SandboxType::None + } + SandboxablePreference::Auto => match policy { + SandboxPolicy::DangerFullAccess => SandboxType::None, + #[cfg(target_os = "macos")] + _ => SandboxType::MacosSeatbelt, + #[cfg(target_os = "linux")] + _ => SandboxType::LinuxSeccomp, + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + _ => SandboxType::None, + }, + } + } + + pub(crate) fn transform( + &self, + spec: &CommandSpec, + policy: &SandboxPolicy, + sandbox: SandboxType, + sandbox_policy_cwd: &Path, + codex_linux_sandbox_exe: Option<&PathBuf>, + ) -> Result { + let mut env = spec.env.clone(); + if !policy.has_full_network_access() { + env.insert( + CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), + "1".to_string(), + ); + } + + let mut command = Vec::with_capacity(1 + spec.args.len()); + command.push(spec.program.clone()); + command.extend(spec.args.iter().cloned()); + + let (command, sandbox_env, arg0_override) = match sandbox { + SandboxType::None => (command, HashMap::new(), None), + SandboxType::MacosSeatbelt => { + let mut seatbelt_env = HashMap::new(); + seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + let mut args = + create_seatbelt_command_args(command.clone(), policy, sandbox_policy_cwd); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); + full_command.append(&mut args); + (full_command, seatbelt_env, None) + } + SandboxType::LinuxSeccomp => { + let exe = codex_linux_sandbox_exe + .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let mut args = + create_linux_sandbox_command_args(command.clone(), policy, sandbox_policy_cwd); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(exe.to_string_lossy().to_string()); + full_command.append(&mut args); + ( + full_command, + HashMap::new(), + Some("codex-linux-sandbox".to_string()), + ) + } + }; + + env.extend(sandbox_env); + + Ok(ExecEnv { + command, + cwd: spec.cwd.clone(), + env, + timeout_ms: spec.timeout_ms, + sandbox, + with_escalated_permissions: spec.with_escalated_permissions, + justification: spec.justification.clone(), + arg0: arg0_override, + }) + } + + pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool { + crate::exec::is_likely_sandbox_denied(sandbox, out) + } +} + +pub async fn execute_env( + env: &ExecEnv, + policy: &SandboxPolicy, + stdout_stream: Option, +) -> crate::error::Result { + execute_exec_env(env.clone(), policy, stdout_stream).await +} diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 09e93668bc..abd88d41bf 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -14,7 +14,7 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl /// to defend against an attacker trying to inject a malicious version on the /// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker /// already has root access. -const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; +pub(crate) const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; pub async fn spawn_command_under_seatbelt( command: Vec, @@ -39,7 +39,7 @@ pub async fn spawn_command_under_seatbelt( .await } -fn create_seatbelt_command_args( +pub(crate) fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac93d8b12e..9a3f88976e 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -347,6 +347,7 @@ mod tests { )]), with_escalated_permissions: None, justification: None, + arg0: None, }, SandboxType::None, &SandboxPolicy::DangerFullAccess, @@ -455,6 +456,7 @@ mod macos_tests { )]), with_escalated_permissions: None, justification: None, + arg0: None, }, SandboxType::None, &SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 994352eddf..ad6f5f90e8 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,18 +1,22 @@ +use std::sync::Arc; + +use crate::AuthManager; use crate::RolloutRecorder; -use crate::exec_command::ExecSessionManager; -use crate::executor::Executor; use crate::mcp_connection_manager::McpConnectionManager; +use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; +use codex_otel::otel_event_manager::OtelEventManager; use tokio::sync::Mutex; pub(crate) struct SessionServices { pub(crate) mcp_connection_manager: McpConnectionManager, - pub(crate) session_manager: ExecSessionManager, pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, - pub(crate) executor: Executor, + pub(crate) auth_manager: Arc, + pub(crate) otel_event_manager: OtelEventManager, + pub(crate) tool_approvals: Mutex, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 8310d91c0c..eaa3bcb92a 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -2,14 +2,15 @@ use codex_protocol::models::ResponseItem; +use crate::codex::SessionConfiguration; use crate::conversation_history::ConversationHistory; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; /// Persistent, session-scoped state previously stored directly on `Session`. -#[derive(Default)] pub(crate) struct SessionState { + pub(crate) session_configuration: SessionConfiguration, pub(crate) history: ConversationHistory, pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, @@ -17,10 +18,12 @@ pub(crate) struct SessionState { impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new() -> Self { + pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { Self { + session_configuration, history: ConversationHistory::new(), - ..Default::default() + token_info: None, + latest_rate_limits: None, } } @@ -45,7 +48,7 @@ impl SessionState { pub(crate) fn update_token_info_from_usage( &mut self, usage: &TokenUsage, - model_context_window: Option, + model_context_window: Option, ) { self.token_info = TokenUsageInfo::new_or_append( &self.token_info, @@ -64,7 +67,7 @@ impl SessionState { (self.token_info.clone(), self.latest_rate_limits.clone()) } - pub(crate) fn set_token_usage_full(&mut self, context_window: u64) { + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), None => { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index f715d5481e..3ed63c5b8f 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -4,7 +4,9 @@ use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::task::AbortHandle; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; use tokio::sync::oneshot; @@ -34,11 +36,23 @@ pub(crate) enum TaskKind { Compact, } +impl TaskKind { + pub(crate) fn header_value(self) -> &'static str { + match self { + TaskKind::Regular => "standard", + TaskKind::Review => "review", + TaskKind::Compact => "compact", + } + } +} + #[derive(Clone)] pub(crate) struct RunningTask { - pub(crate) handle: AbortHandle, + pub(crate) done: Arc, pub(crate) kind: TaskKind, pub(crate) task: Arc, + pub(crate) cancellation_token: CancellationToken, + pub(crate) handle: Arc>, } impl ActiveTurn { @@ -105,11 +119,16 @@ impl ActiveTurn { let mut ts = self.turn_state.lock().await; ts.clear_pending(); } +} - /// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt). - pub(crate) fn try_clear_pending_sync(&self) { - if let Ok(mut ts) = self.turn_state.try_lock() { - ts.clear_pending(); - } +#[cfg(test)] +mod tests { + use super::TaskKind; + + #[test] + fn header_value_matches_expected_labels() { + assert_eq!(TaskKind::Regular.header_value(), "standard"); + assert_eq!(TaskKind::Review.header_value(), "review"); + assert_eq!(TaskKind::Compact.header_value(), "compact"); } } diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 823febfc94..12e61b1f65 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::compact; @@ -25,6 +26,7 @@ impl SessionTask for CompactTask { ctx: Arc, sub_id: String, input: Vec, + _cancellation_token: CancellationToken, ) -> Option { compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 464c1e633a..15ec419f9b 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -3,9 +3,15 @@ mod regular; mod review; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use tokio::select; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use tracing::trace; +use tracing::warn; use crate::codex::Session; use crate::codex::TurnContext; @@ -23,6 +29,8 @@ pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; pub(crate) use review::ReviewTask; +const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; + /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] pub(crate) struct SessionTaskContext { @@ -49,6 +57,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option; async fn abort(&self, session: Arc, sub_id: &str) { @@ -69,26 +78,42 @@ impl Session { let task: Arc = Arc::new(task); let task_kind = task.kind(); + let cancellation_token = CancellationToken::new(); + let done = Arc::new(Notify::new()); + + let done_clone = Arc::clone(&done); let handle = { let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let sub_clone = sub_id.clone(); + let task_cancellation_token = cancellation_token.child_token(); tokio::spawn(async move { let last_agent_message = task_for_run - .run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input) + .run( + Arc::clone(&session_ctx), + ctx, + sub_clone.clone(), + input, + task_cancellation_token.child_token(), + ) .await; - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - let sess = session_ctx.clone_session(); - sess.on_task_finished(sub_clone, last_agent_message).await; + + if !task_cancellation_token.is_cancelled() { + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + let sess = session_ctx.clone_session(); + sess.on_task_finished(sub_clone, last_agent_message).await; + } + done_clone.notify_waiters(); }) - .abort_handle() }; let running_task = RunningTask { - handle, + done, + handle: Arc::new(AbortOnDropHandle::new(handle)), kind: task_kind, task, + cancellation_token, }; self.register_new_active_task(sub_id, running_task).await; } @@ -143,14 +168,24 @@ impl Session { task: RunningTask, reason: TurnAbortReason, ) { - if task.handle.is_finished() { + if task.cancellation_token.is_cancelled() { return; } trace!(task_kind = ?task.kind, sub_id, "aborting running task"); + task.cancellation_token.cancel(); let session_task = task.task; - let handle = task.handle; - handle.abort(); + + select! { + _ = task.done.notified() => { + }, + _ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => { + warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS); + } + } + + task.handle.abort(); + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); session_task.abort(session_ctx, &sub_id).await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 9d24099746..3a3fa267ce 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::run_task; @@ -25,8 +26,17 @@ impl SessionTask for RegularTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Regular, + cancellation_token, + ) + .await } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 047a2f40e2..6b4b2175db 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::exit_review_mode; @@ -26,9 +27,18 @@ impl SessionTask for ReviewTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Review, + cancellation_token, + ) + .await } async fn abort(&self, session: Arc, sub_id: &str) { diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 7185a54fee..0a7694e9f3 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -28,6 +28,8 @@ pub struct IdTokenInfo { /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, + /// Organization/workspace identifier associated with the token, if present. + pub chatgpt_account_id: Option, pub raw_jwt: String, } @@ -71,6 +73,8 @@ struct IdClaims { struct AuthClaims { #[serde(default)] chatgpt_plan_type: Option, + #[serde(default)] + chatgpt_account_id: Option, } #[derive(Debug, Error)] @@ -94,11 +98,20 @@ pub fn parse_id_token(id_token: &str) -> Result { let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?; let claims: IdClaims = serde_json::from_slice(&payload_bytes)?; - Ok(IdTokenInfo { - email: claims.email, - chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type), - raw_jwt: id_token.to_string(), - }) + match claims.auth { + Some(auth) => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: auth.chatgpt_plan_type, + chatgpt_account_id: auth.chatgpt_account_id, + }), + None => Ok(IdTokenInfo { + email: claims.email, + raw_jwt: id_token.to_string(), + chatgpt_plan_type: None, + chatgpt_account_id: None, + }), + } } fn deserialize_id_token<'de, D>(deserializer: D) -> Result diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 7ab4691a55..a262404b05 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -232,6 +232,7 @@ mod tests { } #[derive(Clone, Debug)] +#[allow(dead_code)] pub(crate) struct ExecCommandContext { pub(crate) sub_id: String, pub(crate) call_id: String, @@ -243,6 +244,7 @@ pub(crate) struct ExecCommandContext { } #[derive(Clone, Debug)] +#[allow(dead_code)] pub(crate) struct ApplyPatchCommandContext { pub(crate) user_explicitly_approved_this_action: bool, pub(crate) changes: HashMap, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs new file mode 100644 index 0000000000..e09f266bbe --- /dev/null +++ b/codex-rs/core/src/tools/events.rs @@ -0,0 +1,235 @@ +use crate::codex::Session; +use crate::exec::ExecToolCallOutput; +use crate::parse_command::parse_command; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandBeginEvent; +use crate::protocol::ExecCommandEndEvent; +use crate::protocol::FileChange; +use crate::protocol::PatchApplyBeginEvent; +use crate::protocol::PatchApplyEndEvent; +use crate::protocol::TurnDiffEvent; +use crate::tools::context::SharedTurnDiffTracker; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use super::format_exec_output; +use super::format_exec_output_str; + +#[derive(Clone, Copy)] +pub(crate) struct ToolEventCtx<'a> { + pub session: &'a Session, + pub sub_id: &'a str, + pub call_id: &'a str, + pub turn_diff_tracker: Option<&'a SharedTurnDiffTracker>, +} + +impl<'a> ToolEventCtx<'a> { + pub fn new( + session: &'a Session, + sub_id: &'a str, + call_id: &'a str, + turn_diff_tracker: Option<&'a SharedTurnDiffTracker>, + ) -> Self { + Self { + session, + sub_id, + call_id, + turn_diff_tracker, + } + } +} + +pub(crate) enum ToolEventStage { + Begin, + Success(ExecToolCallOutput), + Failure(ToolEventFailure), +} + +pub(crate) enum ToolEventFailure { + Output(ExecToolCallOutput), + Message(String), +} +// Concrete, allocation-free emitter: avoid trait objects and boxed futures. +pub(crate) enum ToolEmitter { + Shell { + command: Vec, + cwd: PathBuf, + }, + ApplyPatch { + changes: HashMap, + auto_approved: bool, + }, +} + +impl ToolEmitter { + pub fn shell(command: Vec, cwd: PathBuf) -> Self { + Self::Shell { command, cwd } + } + + pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { + Self::ApplyPatch { + changes, + auto_approved, + } + } + + pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage) { + match (self, stage) { + (Self::Shell { command, cwd }, ToolEventStage::Begin) => { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: ctx.call_id.to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parse_command(command), + }), + }) + .await; + } + (Self::Shell { .. }, ToolEventStage::Success(output)) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + (Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Output(output))) => { + emit_exec_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.aggregated_output.text.clone(), + output.exit_code, + output.duration, + format_exec_output_str(&output), + ) + .await; + } + (Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Message(message))) => { + emit_exec_end( + ctx, + String::new(), + (*message).to_string(), + (*message).to_string(), + -1, + Duration::ZERO, + format_exec_output(&message), + ) + .await; + } + + ( + Self::ApplyPatch { + changes, + auto_approved, + }, + ToolEventStage::Begin, + ) => { + if let Some(tracker) = ctx.turn_diff_tracker { + let mut guard = tracker.lock().await; + guard.on_patch_begin(changes); + } + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: ctx.call_id.to_string(), + auto_approved: *auto_approved, + changes: changes.clone(), + }), + }) + .await; + } + (Self::ApplyPatch { .. }, ToolEventStage::Success(output)) => { + emit_patch_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.exit_code == 0, + ) + .await; + } + ( + Self::ApplyPatch { .. }, + ToolEventStage::Failure(ToolEventFailure::Output(output)), + ) => { + emit_patch_end( + ctx, + output.stdout.text.clone(), + output.stderr.text.clone(), + output.exit_code == 0, + ) + .await; + } + ( + Self::ApplyPatch { .. }, + ToolEventStage::Failure(ToolEventFailure::Message(message)), + ) => { + emit_patch_end(ctx, String::new(), (*message).to_string(), false).await; + } + } + } +} + +async fn emit_exec_end( + ctx: ToolEventCtx<'_>, + stdout: String, + stderr: String, + aggregated_output: String, + exit_code: i32, + duration: Duration, + formatted_output: String, +) { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: ctx.call_id.to_string(), + stdout, + stderr, + aggregated_output, + exit_code, + duration, + formatted_output, + }), + }) + .await; +} + +async fn emit_patch_end(ctx: ToolEventCtx<'_>, stdout: String, stderr: String, success: bool) { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: ctx.call_id.to_string(), + stdout, + stderr, + success, + }), + }) + .await; + + if let Some(tracker) = ctx.turn_diff_tracker { + let unified_diff = { + let mut guard = tracker.lock().await; + guard.get_unified_diff() + }; + if let Ok(Some(unified_diff)) = unified_diff { + ctx.session + .send_event(Event { + id: ctx.sub_id.to_string(), + msg: EventMsg::TurnDiff(TurnDiffEvent { unified_diff }), + }) + .await; + } + } +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index d85ac8b76f..4223740c3a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -8,7 +8,6 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::exec::ExecParams; use crate::function_tool::FunctionCallError; -use crate::openai_tools::JsonSchema; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -16,6 +15,7 @@ use crate::tools::handle_container_exec_with_params; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use crate::tools::spec::ApplyPatchToolArgs; +use crate::tools::spec::JsonSchema; use async_trait::async_trait; use serde::Deserialize; use serde::Serialize; @@ -72,6 +72,7 @@ impl ToolHandler for ApplyPatchHandler { env: HashMap::new(), with_escalated_permissions: None, justification: None, + arg0: None, }; let content = handle_container_exec_with_params( diff --git a/codex-rs/core/src/tools/handlers/exec_stream.rs b/codex-rs/core/src/tools/handlers/exec_stream.rs deleted file mode 100644 index 7f14c6737a..0000000000 --- a/codex-rs/core/src/tools/handlers/exec_stream.rs +++ /dev/null @@ -1,68 +0,0 @@ -use async_trait::async_trait; - -use crate::exec_command::EXEC_COMMAND_TOOL_NAME; -use crate::exec_command::ExecCommandParams; -use crate::exec_command::WRITE_STDIN_TOOL_NAME; -use crate::exec_command::WriteStdinParams; -use crate::function_tool::FunctionCallError; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ExecStreamHandler; - -#[async_trait] -impl ToolHandler for ExecStreamHandler { - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - tool_name, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "exec_stream handler received unsupported payload".to_string(), - )); - } - }; - - let content = match tool_name.as_str() { - EXEC_COMMAND_TOOL_NAME => { - let params: ExecCommandParams = serde_json::from_str(&arguments).map_err(|e| { - FunctionCallError::RespondToModel(format!( - "failed to parse function arguments: {e:?}" - )) - })?; - session.handle_exec_command_tool(params).await? - } - WRITE_STDIN_TOOL_NAME => { - let params: WriteStdinParams = serde_json::from_str(&arguments).map_err(|e| { - FunctionCallError::RespondToModel(format!( - "failed to parse function arguments: {e:?}" - )) - })?; - session.handle_write_stdin_tool(params).await? - } - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "exec_stream handler does not support tool {tool_name}" - ))); - } - }; - - Ok(ToolOutput::Function { - content, - success: Some(true), - }) - } -} diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs new file mode 100644 index 0000000000..7e425aad9b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -0,0 +1,773 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use async_trait::async_trait; +use mcp_types::CallToolResult; +use mcp_types::ContentBlock; +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 mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextContent; +use serde::Deserialize; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::McpInvocation; +use crate::protocol::McpToolCallBeginEvent; +use crate::protocol::McpToolCallEndEvent; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct McpResourceHandler; + +#[derive(Debug, Deserialize, Default)] +struct ListResourcesArgs { + /// Lists all resources from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ListResourceTemplatesArgs { + /// Lists all resource templates from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct ReadResourceArgs { + server: String, + uri: String, +} + +#[derive(Debug, Serialize)] +struct ResourceWithServer { + server: String, + #[serde(flatten)] + resource: Resource, +} + +impl ResourceWithServer { + fn new(server: String, resource: Resource) -> Self { + Self { server, resource } + } +} + +#[derive(Debug, Serialize)] +struct ResourceTemplateWithServer { + server: String, + #[serde(flatten)] + template: ResourceTemplate, +} + +impl ResourceTemplateWithServer { + fn new(server: String, template: ResourceTemplate) -> Self { + Self { server, template } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourcesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourcesPayload { + fn from_single_server(server: String, result: ListResourcesResult) -> Self { + let resources = result + .resources + .into_iter() + .map(|resource| ResourceWithServer::new(server.clone(), resource)) + .collect(); + Self { + server: Some(server), + resources, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(resources_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = resources_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resources = Vec::new(); + for (server, server_resources) in entries { + for resource in server_resources { + resources.push(ResourceWithServer::new(server.clone(), resource)); + } + } + + Self { + server: None, + resources, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourceTemplatesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resource_templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourceTemplatesPayload { + fn from_single_server(server: String, result: ListResourceTemplatesResult) -> Self { + let resource_templates = result + .resource_templates + .into_iter() + .map(|template| ResourceTemplateWithServer::new(server.clone(), template)) + .collect(); + Self { + server: Some(server), + resource_templates, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(templates_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = + templates_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resource_templates = Vec::new(); + for (server, server_templates) in entries { + for template in server_templates { + resource_templates.push(ResourceTemplateWithServer::new(server.clone(), template)); + } + } + + Self { + server: None, + resource_templates, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +struct ReadResourcePayload { + server: String, + uri: String, + #[serde(flatten)] + result: ReadResourceResult, +} + +#[async_trait] +impl ToolHandler for McpResourceHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + sub_id, + call_id, + tool_name, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "mcp_resource handler received unsupported payload".to_string(), + )); + } + }; + + let arguments_value = parse_arguments(arguments.as_str())?; + + match tool_name.as_str() { + "list_mcp_resources" => { + handle_list_resources( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "list_mcp_resource_templates" => { + handle_list_resource_templates( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "read_mcp_resource" => { + handle_read_resource(Arc::clone(&session), sub_id, call_id, arguments_value).await + } + other => Err(FunctionCallError::RespondToModel(format!( + "unsupported MCP resource tool: {other}" + ))), + } + } +} + +async fn handle_list_resources( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; + let ListResourcesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resources".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| ListResourcesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resources(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + })?; + Ok(ListResourcesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let resources = session + .services + .mcp_connection_manager + .list_all_resources() + .await; + Ok(ListResourcesPayload::from_all_servers(resources)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_list_resource_templates( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; + let ListResourceTemplatesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resource_templates".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor + .clone() + .map(|value| ListResourceTemplatesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resource_templates(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "resources/templates/list failed: {err:#}" + )) + })?; + Ok(ListResourceTemplatesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let templates = session + .services + .mcp_connection_manager + .list_all_resource_templates() + .await; + Ok(ListResourceTemplatesPayload::from_all_servers(templates)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_read_resource( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ReadResourceArgs = parse_args(arguments.clone())?; + let ReadResourceArgs { server, uri } = args; + let server = normalize_required_string("server", server)?; + let uri = normalize_required_string("uri", uri)?; + + let invocation = McpInvocation { + server: server.clone(), + tool: "read_mcp_resource".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + let result = session + .read_resource(&server, ReadResourceRequestParams { uri: uri.clone() }) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) + })?; + + Ok(ReadResourcePayload { + server, + uri, + result, + }) + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +fn call_tool_result_from_content(content: &str, success: Option) -> CallToolResult { + CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + annotations: None, + text: content.to_string(), + r#type: "text".to_string(), + })], + is_error: success.map(|value| !value), + structured_content: None, + } +} + +async fn emit_tool_call_begin( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.to_string(), + invocation, + }), + }) + .await; +} + +async fn emit_tool_call_end( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, + duration: Duration, + result: Result, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: call_id.to_string(), + invocation, + duration, + result, + }), + }) + .await; +} + +fn normalize_optional_string(input: Option) -> Option { + input.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_required_string(field: &str, value: String) -> Result { + match normalize_optional_string(Some(value)) { + Some(normalized) => Ok(normalized), + None => Err(FunctionCallError::RespondToModel(format!( + "{field} must be provided" + ))), + } +} + +fn serialize_function_output(payload: T) -> Result +where + T: Serialize, +{ + let content = serde_json::to_string(&payload).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to serialize MCP resource response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + success: Some(true), + }) +} + +fn parse_arguments(raw_args: &str) -> Result, FunctionCallError> { + if raw_args.trim().is_empty() { + Ok(None) + } else { + serde_json::from_str(raw_args).map(Some).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }) + } +} + +fn parse_args(arguments: Option) -> Result +where + T: DeserializeOwned, +{ + match arguments { + Some(value) => serde_json::from_value(value).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }), + None => Err(FunctionCallError::RespondToModel( + "failed to parse function arguments: expected value".to_string(), + )), + } +} + +fn parse_args_with_default(arguments: Option) -> Result +where + T: DeserializeOwned + Default, +{ + match arguments { + Some(value) => parse_args(Some(value)), + None => Ok(T::default()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ListResourcesResult; + use mcp_types::ResourceTemplate; + use pretty_assertions::assert_eq; + use serde_json::json; + + fn resource(uri: &str, name: &str) -> Resource { + Resource { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + size: None, + title: None, + uri: uri.to_string(), + } + } + + fn template(uri_template: &str, name: &str) -> ResourceTemplate { + ResourceTemplate { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + title: None, + uri_template: uri_template.to_string(), + } + } + + #[test] + fn resource_with_server_serializes_server_field() { + let entry = ResourceWithServer::new("test".to_string(), resource("memo://id", "memo")); + let value = serde_json::to_value(&entry).expect("serialize resource"); + + assert_eq!(value["server"], json!("test")); + assert_eq!(value["uri"], json!("memo://id")); + assert_eq!(value["name"], json!("memo")); + } + + #[test] + fn list_resources_payload_from_single_server_copies_next_cursor() { + let result = ListResourcesResult { + next_cursor: Some("cursor-1".to_string()), + resources: vec![resource("memo://id", "memo")], + }; + let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); + let value = serde_json::to_value(&payload).expect("serialize payload"); + + assert_eq!(value["server"], json!("srv")); + assert_eq!(value["nextCursor"], json!("cursor-1")); + let resources = value["resources"].as_array().expect("resources array"); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0]["server"], json!("srv")); + } + + #[test] + fn list_resources_payload_from_all_servers_is_sorted() { + let mut map = HashMap::new(); + map.insert("beta".to_string(), vec![resource("memo://b-1", "b-1")]); + map.insert( + "alpha".to_string(), + vec![resource("memo://a-1", "a-1"), resource("memo://a-2", "a-2")], + ); + + let payload = ListResourcesPayload::from_all_servers(map); + let value = serde_json::to_value(&payload).expect("serialize payload"); + let uris: Vec = value["resources"] + .as_array() + .expect("resources array") + .iter() + .map(|entry| entry["uri"].as_str().unwrap().to_string()) + .collect(); + + assert_eq!( + uris, + vec![ + "memo://a-1".to_string(), + "memo://a-2".to_string(), + "memo://b-1".to_string() + ] + ); + } + + #[test] + fn call_tool_result_from_content_marks_success() { + let result = call_tool_result_from_content("{}", Some(true)); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.content.len(), 1); + } + + #[test] + fn parse_arguments_handles_empty_and_json() { + assert!( + parse_arguments(" \n\t").unwrap().is_none(), + "expected None for empty arguments" + ); + + let value = parse_arguments(r#"{"server":"figma"}"#) + .expect("parse json") + .expect("value present"); + assert_eq!(value["server"], json!("figma")); + } + + #[test] + fn template_with_server_serializes_server_field() { + let entry = + ResourceTemplateWithServer::new("srv".to_string(), template("memo://{id}", "memo")); + let value = serde_json::to_value(&entry).expect("serialize template"); + + assert_eq!( + value, + json!({ + "server": "srv", + "uriTemplate": "memo://{id}", + "name": "memo" + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9bff9fd5e7..187b444414 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,8 +1,8 @@ pub mod apply_patch; -mod exec_stream; mod grep_files; mod list_dir; mod mcp; +mod mcp_resource; mod plan; mod read_file; mod shell; @@ -13,10 +13,10 @@ mod view_image; pub use plan::PLAN_TOOL; pub use apply_patch::ApplyPatchHandler; -pub use exec_stream::ExecStreamHandler; pub use grep_files::GrepFilesHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; +pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 386933a52d..9291443f08 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -2,12 +2,12 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::function_tool::FunctionCallError; -use crate::openai_tools::JsonSchema; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use crate::tools::spec::JsonSchema; use async_trait::async_trait; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::Event; diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 38b76f28ad..f9b6ae4dab 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -1,12 +1,9 @@ -use std::path::Path; +use std::collections::VecDeque; use std::path::PathBuf; use async_trait::async_trait; use codex_utils_string::take_bytes_at_char_boundary; use serde::Deserialize; -use tokio::fs::File; -use tokio::io::AsyncBufReadExt; -use tokio::io::BufReader; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; @@ -18,22 +15,78 @@ use crate::tools::registry::ToolKind; pub struct ReadFileHandler; const MAX_LINE_LENGTH: usize = 500; +const TAB_WIDTH: usize = 4; -fn default_offset() -> usize { - 1 -} +// TODO(jif) add support for block comments +const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; -fn default_limit() -> usize { - 2000 +/// JSON arguments accepted by the `read_file` tool handler. +#[derive(Deserialize)] +struct ReadFileArgs { + /// Absolute path to the file that will be read. + file_path: String, + /// 1-indexed line number to start reading from; defaults to 1. + #[serde(default = "defaults::offset")] + offset: usize, + /// Maximum number of lines to return; defaults to 2000. + #[serde(default = "defaults::limit")] + limit: usize, + /// Determines whether the handler reads a simple slice or indentation-aware block. + #[serde(default)] + mode: ReadMode, + /// Optional indentation configuration used when `mode` is `Indentation`. + #[serde(default)] + indentation: Option, } #[derive(Deserialize)] -struct ReadFileArgs { - file_path: String, - #[serde(default = "default_offset")] - offset: usize, - #[serde(default = "default_limit")] - limit: usize, +#[serde(rename_all = "snake_case")] +enum ReadMode { + Slice, + Indentation, +} +/// Additional configuration for indentation-aware reads. +#[derive(Deserialize, Clone)] +struct IndentationArgs { + /// Optional explicit anchor line; defaults to `offset` when omitted. + #[serde(default)] + anchor_line: Option, + /// Maximum indentation depth to collect; `0` means unlimited. + #[serde(default = "defaults::max_levels")] + max_levels: usize, + /// Whether to include sibling blocks at the same indentation level. + #[serde(default = "defaults::include_siblings")] + include_siblings: bool, + /// Whether to include header lines above the anchor block. This made on a best effort basis. + #[serde(default = "defaults::include_header")] + include_header: bool, + /// Optional hard cap on returned lines; defaults to the global `limit`. + #[serde(default)] + max_lines: Option, +} + +#[derive(Clone, Debug)] +struct LineRecord { + number: usize, + raw: String, + display: String, + indent: usize, +} + +impl LineRecord { + fn trimmed(&self) -> &str { + self.raw.trim_start() + } + + fn is_blank(&self) -> bool { + self.trimmed().is_empty() + } + + fn is_comment(&self) -> bool { + COMMENT_PREFIXES + .iter() + .any(|prefix| self.raw.trim().starts_with(prefix)) + } } #[async_trait] @@ -64,6 +117,8 @@ impl ToolHandler for ReadFileHandler { file_path, offset, limit, + mode, + indentation, } = args; if offset == 0 { @@ -85,7 +140,13 @@ impl ToolHandler for ReadFileHandler { )); } - let collected = read_file_slice(&path, offset, limit).await?; + let collected = match mode { + ReadMode::Slice => slice::read(&path, offset, limit).await?, + ReadMode::Indentation => { + let indentation = indentation.unwrap_or_default(); + indentation::read_block(&path, offset, limit, indentation).await? + } + }; Ok(ToolOutput::Function { content: collected.join("\n"), success: Some(true), @@ -93,62 +154,280 @@ impl ToolHandler for ReadFileHandler { } } -async fn read_file_slice( - path: &Path, - offset: usize, - limit: usize, -) -> Result, FunctionCallError> { - let file = File::open(path) - .await - .map_err(|err| FunctionCallError::RespondToModel(format!("failed to read file: {err}")))?; +mod slice { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::format_line; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; - let mut reader = BufReader::new(file); - let mut collected = Vec::new(); - let mut seen = 0usize; - let mut buffer = Vec::new(); - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + pub async fn read( + path: &Path, + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { FunctionCallError::RespondToModel(format!("failed to read file: {err}")) })?; - if bytes_read == 0 { - break; - } + let mut reader = BufReader::new(file); + let mut collected = Vec::new(); + let mut seen = 0usize; + let mut buffer = Vec::new(); - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + seen += 1; + + if seen < offset { + continue; + } + + if collected.len() == limit { + break; + } + + let formatted = format_line(&buffer); + collected.push(format!("L{seen}: {formatted}")); + + if collected.len() == limit { + break; } } - seen += 1; - if seen < offset { - continue; + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); } - if collected.len() == limit { - break; + Ok(collected) + } +} + +mod indentation { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::IndentationArgs; + use crate::tools::handlers::read_file::LineRecord; + use crate::tools::handlers::read_file::TAB_WIDTH; + use crate::tools::handlers::read_file::format_line; + use crate::tools::handlers::read_file::trim_empty_lines; + use std::collections::VecDeque; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; + + pub async fn read_block( + path: &Path, + offset: usize, + limit: usize, + options: IndentationArgs, + ) -> Result, FunctionCallError> { + let anchor_line = options.anchor_line.unwrap_or(offset); + if anchor_line == 0 { + return Err(FunctionCallError::RespondToModel( + "anchor_line must be a 1-indexed line number".to_string(), + )); } - let formatted = format_line(&buffer); - collected.push(format!("L{seen}: {formatted}")); - - if collected.len() == limit { - break; + let guard_limit = options.max_lines.unwrap_or(limit); + if guard_limit == 0 { + return Err(FunctionCallError::RespondToModel( + "max_lines must be greater than zero".to_string(), + )); } + + let collected = collect_file_lines(path).await?; + if collected.is_empty() || anchor_line > collected.len() { + return Err(FunctionCallError::RespondToModel( + "anchor_line exceeds file length".to_string(), + )); + } + + let anchor_index = anchor_line - 1; + let effective_indents = compute_effective_indents(&collected); + let anchor_indent = effective_indents[anchor_index]; + + // Compute the min indent + let min_indent = if options.max_levels == 0 { + 0 + } else { + anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) + }; + + // Cap requested lines by guard_limit and file length + let final_limit = limit.min(guard_limit).min(collected.len()); + + if final_limit == 1 { + return Ok(vec![format!( + "L{}: {}", + collected[anchor_index].number, collected[anchor_index].display + )]); + } + + // Cursors + let mut i: isize = anchor_index as isize - 1; // up (inclusive) + let mut j: usize = anchor_index + 1; // down (inclusive) + let mut i_counter_min_indent = 0; + let mut j_counter_min_indent = 0; + + let mut out = VecDeque::with_capacity(limit); + out.push_back(&collected[anchor_index]); + + while out.len() < final_limit { + let mut progressed = 0; + + // Up. + if i >= 0 { + let iu = i as usize; + if effective_indents[iu] >= min_indent { + out.push_front(&collected[iu]); + progressed += 1; + i -= 1; + + // We do not include the siblings (not applied to comments). + if effective_indents[iu] == min_indent && !options.include_siblings { + let allow_header_comment = + options.include_header && collected[iu].is_comment(); + let can_take_line = allow_header_comment || i_counter_min_indent == 0; + + if can_take_line { + i_counter_min_indent += 1; + } else { + // This line shouldn't have been taken. + out.pop_front(); + progressed -= 1; + i = -1; // consider using Option or a control flag instead of a sentinel + } + } + + // Short-cut. + if out.len() >= final_limit { + break; + } + } else { + // Stop moving up. + i = -1; + } + } + + // Down. + if j < collected.len() { + let ju = j; + if effective_indents[ju] >= min_indent { + out.push_back(&collected[ju]); + progressed += 1; + j += 1; + + // We do not include the siblings (applied to comments). + if effective_indents[ju] == min_indent && !options.include_siblings { + if j_counter_min_indent > 0 { + // This line shouldn't have been taken. + out.pop_back(); + progressed -= 1; + j = collected.len(); + } + j_counter_min_indent += 1; + } + } else { + // Stop moving down. + j = collected.len(); + } + } + + if progressed == 0 { + break; + } + } + + // Trim empty lines + trim_empty_lines(&mut out); + + Ok(out + .into_iter() + .map(|record| format!("L{}: {}", record.number, record.display)) + .collect()) } - if seen < offset { - return Err(FunctionCallError::RespondToModel( - "offset exceeds file length".to_string(), - )); + async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut lines = Vec::new(); + let mut number = 0usize; + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + number += 1; + let raw = String::from_utf8_lossy(&buffer).into_owned(); + let indent = measure_indent(&raw); + let display = format_line(&buffer); + lines.push(LineRecord { + number, + raw, + display, + indent, + }); + } + + Ok(lines) } - Ok(collected) + fn compute_effective_indents(records: &[LineRecord]) -> Vec { + let mut effective = Vec::with_capacity(records.len()); + let mut previous_indent = 0usize; + for record in records { + if record.is_blank() { + effective.push(previous_indent); + } else { + previous_indent = record.indent; + effective.push(previous_indent); + } + } + effective + } + + fn measure_indent(line: &str) -> usize { + line.chars() + .take_while(|c| matches!(c, ' ' | '\t')) + .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) + .sum() + } } fn format_line(bytes: &[u8]) -> String { @@ -160,93 +439,560 @@ fn format_line(bytes: &[u8]) -> String { } } +fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { + while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { + out.pop_front(); + } + while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { + out.pop_back(); + } +} + +mod defaults { + use super::*; + + impl Default for IndentationArgs { + fn default() -> Self { + Self { + anchor_line: None, + max_levels: max_levels(), + include_siblings: include_siblings(), + include_header: include_header(), + max_lines: None, + } + } + } + + impl Default for ReadMode { + fn default() -> Self { + Self::Slice + } + } + + pub fn offset() -> usize { + 1 + } + + pub fn limit() -> usize { + 2000 + } + + pub fn max_levels() -> usize { + 0 + } + + pub fn include_siblings() -> bool { + false + } + + pub fn include_header() -> bool { + true + } +} + #[cfg(test)] mod tests { + use super::indentation::read_block; + use super::slice::read; use super::*; + use pretty_assertions::assert_eq; use tempfile::NamedTempFile; #[tokio::test] - async fn reads_requested_range() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn reads_requested_range() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; - writeln!(temp, "alpha").unwrap(); - writeln!(temp, "beta").unwrap(); - writeln!(temp, "gamma").unwrap(); + write!( + temp, + "alpha +beta +gamma +" + )?; - let lines = read_file_slice(temp.path(), 2, 2) - .await - .expect("read slice"); + let lines = read(temp.path(), 2, 2).await?; assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); + Ok(()) } #[tokio::test] - async fn errors_when_offset_exceeds_length() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; - writeln!(temp, "only").unwrap(); + writeln!(temp, "only")?; - let err = read_file_slice(temp.path(), 3, 1) + let err = read(temp.path(), 3, 1) .await .expect_err("offset exceeds length"); assert_eq!( err, FunctionCallError::RespondToModel("offset exceeds file length".to_string()) ); + Ok(()) } #[tokio::test] - async fn reads_non_utf8_lines() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn reads_non_utf8_lines() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; - temp.as_file_mut().write_all(b"\xff\xfe\nplain\n").unwrap(); + temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; - let lines = read_file_slice(temp.path(), 1, 2) - .await - .expect("read slice"); + let lines = read(temp.path(), 1, 2).await?; let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); + Ok(()) } #[tokio::test] - async fn trims_crlf_endings() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn trims_crlf_endings() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; - write!(temp, "one\r\ntwo\r\n").unwrap(); + write!(temp, "one\r\ntwo\r\n")?; - let lines = read_file_slice(temp.path(), 1, 2) - .await - .expect("read slice"); + let lines = read(temp.path(), 1, 2).await?; assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); + Ok(()) } #[tokio::test] - async fn respects_limit_even_with_more_lines() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn respects_limit_even_with_more_lines() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; - writeln!(temp, "first").unwrap(); - writeln!(temp, "second").unwrap(); - writeln!(temp, "third").unwrap(); + write!( + temp, + "first +second +third +" + )?; - let lines = read_file_slice(temp.path(), 1, 2) - .await - .expect("read slice"); + let lines = read(temp.path(), 1, 2).await?; assert_eq!( lines, vec!["L1: first".to_string(), "L2: second".to_string()] ); + Ok(()) } #[tokio::test] - async fn truncates_lines_longer_than_max_length() { - let mut temp = NamedTempFile::new().expect("create temp file"); + async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; use std::io::Write as _; let long_line = "x".repeat(MAX_LINE_LENGTH + 50); - writeln!(temp, "{long_line}").unwrap(); + writeln!(temp, "{long_line}")?; - let lines = read_file_slice(temp.path(), 1, 1) - .await - .expect("read slice"); + let lines = read(temp.path(), 1, 1).await?; let expected = "x".repeat(MAX_LINE_LENGTH); assert_eq!(lines, vec![format!("L1: {expected}")]); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_captures_block() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "fn outer() {{ + if cond {{ + inner(); + }} + tail(); +}} +" + )?; + + let options = IndentationArgs { + anchor_line: Some(3), + include_siblings: false, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 3, 10, options).await?; + + assert_eq!( + lines, + vec![ + "L2: if cond {".to_string(), + "L3: inner();".to_string(), + "L4: }".to_string() + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_expands_parents() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "mod root {{ + fn outer() {{ + if cond {{ + inner(); + }} + }} +}} +" + )?; + + let mut options = IndentationArgs { + anchor_line: Some(4), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 4, 50, options.clone()).await?; + assert_eq!( + lines, + vec![ + "L2: fn outer() {".to_string(), + "L3: if cond {".to_string(), + "L4: inner();".to_string(), + "L5: }".to_string(), + "L6: }".to_string(), + ] + ); + + options.max_levels = 3; + let expanded = read_block(temp.path(), 4, 50, options).await?; + assert_eq!( + expanded, + vec![ + "L1: mod root {".to_string(), + "L2: fn outer() {".to_string(), + "L3: if cond {".to_string(), + "L4: inner();".to_string(), + "L5: }".to_string(), + "L6: }".to_string(), + "L7: }".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "fn wrapper() {{ + if first {{ + do_first(); + }} + if second {{ + do_second(); + }} +}} +" + )?; + + let mut options = IndentationArgs { + anchor_line: Some(3), + include_siblings: false, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 3, 50, options.clone()).await?; + assert_eq!( + lines, + vec![ + "L2: if first {".to_string(), + "L3: do_first();".to_string(), + "L4: }".to_string(), + ] + ); + + options.include_siblings = true; + let with_siblings = read_block(temp.path(), 3, 50, options).await?; + assert_eq!( + with_siblings, + vec![ + "L2: if first {".to_string(), + "L3: do_first();".to_string(), + "L4: }".to_string(), + "L5: if second {".to_string(), + "L6: do_second();".to_string(), + "L7: }".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_handles_python_sample() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "class Foo: + def __init__(self, size): + self.size = size + def double(self, value): + if value is None: + return 0 + result = value * self.size + return result +class Bar: + def compute(self): + helper = Foo(2) + return helper.double(5) +" + )?; + + let options = IndentationArgs { + anchor_line: Some(7), + include_siblings: true, + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 1, 200, options).await?; + assert_eq!( + lines, + vec![ + "L2: def __init__(self, size):".to_string(), + "L3: self.size = size".to_string(), + "L4: def double(self, value):".to_string(), + "L5: if value is None:".to_string(), + "L6: return 0".to_string(), + "L7: result = value * self.size".to_string(), + "L8: return result".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + #[ignore] + async fn indentation_mode_handles_javascript_sample() -> anyhow::Result<()> { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "export function makeThing() {{ + const cache = new Map(); + function ensure(key) {{ + if (!cache.has(key)) {{ + cache.set(key, []); + }} + return cache.get(key); + }} + const handlers = {{ + init() {{ + console.log(\"init\"); + }}, + run() {{ + if (Math.random() > 0.5) {{ + return \"heads\"; + }} + return \"tails\"; + }}, + }}; + return {{ cache, handlers }}; +}} +export function other() {{ + return makeThing(); +}} +" + )?; + + let options = IndentationArgs { + anchor_line: Some(15), + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 15, 200, options).await?; + assert_eq!( + lines, + vec![ + "L10: init() {".to_string(), + "L11: console.log(\"init\");".to_string(), + "L12: },".to_string(), + "L13: run() {".to_string(), + "L14: if (Math.random() > 0.5) {".to_string(), + "L15: return \"heads\";".to_string(), + "L16: }".to_string(), + "L17: return \"tails\";".to_string(), + "L18: },".to_string(), + ] + ); + Ok(()) + } + + fn write_cpp_sample() -> anyhow::Result { + let mut temp = NamedTempFile::new()?; + use std::io::Write as _; + write!( + temp, + "#include +#include + +namespace sample {{ +class Runner {{ +public: + void setup() {{ + if (enabled_) {{ + init(); + }} + }} + + // Run the code + int run() const {{ + switch (mode_) {{ + case Mode::Fast: + return fast(); + case Mode::Slow: + return slow(); + default: + return fallback(); + }} + }} + +private: + bool enabled_ = false; + Mode mode_ = Mode::Fast; + + int fast() const {{ + return 1; + }} +}}; +}} // namespace sample +" + )?; + Ok(temp) + } + + #[tokio::test] + async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + anchor_line: Some(18), + max_levels: 1, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L13: // Run the code".to_string(), + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: false, + include_header: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { + let temp = write_cpp_sample()?; + + let options = IndentationArgs { + include_siblings: true, + include_header: false, + anchor_line: Some(18), + max_levels: 2, + ..Default::default() + }; + + let lines = read_block(temp.path(), 18, 200, options).await?; + assert_eq!( + lines, + vec![ + "L7: void setup() {".to_string(), + "L8: if (enabled_) {".to_string(), + "L9: init();".to_string(), + "L10: }".to_string(), + "L11: }".to_string(), + "L12: ".to_string(), + "L13: // Run the code".to_string(), + "L14: int run() const {".to_string(), + "L15: switch (mode_) {".to_string(), + "L16: case Mode::Fast:".to_string(), + "L17: return fast();".to_string(), + "L18: case Mode::Slow:".to_string(), + "L19: return slow();".to_string(), + "L20: default:".to_string(), + "L21: return fallback();".to_string(), + "L22: }".to_string(), + "L23: }".to_string(), + ] + ); + Ok(()) } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 1b27a58ef3..168b8cd03e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -24,6 +24,7 @@ impl ShellHandler { env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, + arg0: None, } } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index ce47dded3c..d171a1f7e3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -35,7 +35,13 @@ impl ToolHandler for UnifiedExecHandler { async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { - session, payload, .. + session, + turn, + sub_id, + call_id, + tool_name: _tool_name, + payload, + .. } = invocation; let args = match payload { @@ -73,13 +79,23 @@ impl ToolHandler for UnifiedExecHandler { }; let request = UnifiedExecRequest { - session_id: parsed_session_id, input_chunks: &input, timeout_ms, }; let value = session - .run_unified_exec_request(request) + .services + .unified_exec_manager + .handle_request( + request, + crate::unified_exec::UnifiedExecContext { + session: &session, + turn: turn.as_ref(), + sub_id: &sub_id, + call_id: &call_id, + session_id: parsed_session_id, + }, + ) .await .map_err(|err| { FunctionCallError::RespondToModel(format!("unified exec failed: {err:?}")) diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 691c6dc0c2..c0e9a90fb7 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,12 +1,15 @@ pub mod context; +pub mod events; pub(crate) mod handlers; +pub mod orchestrator; pub mod parallel; pub mod registry; pub mod router; +pub mod runtimes; +pub mod sandboxing; pub mod spec; use crate::apply_patch; -use crate::apply_patch::ApplyPatchExec; use crate::apply_patch::InternalApplyPatchInvocation; use crate::apply_patch::convert_apply_patch_to_protocol; use crate::codex::Session; @@ -15,14 +18,19 @@ use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecParams; use crate::exec::ExecToolCallOutput; -use crate::exec::StdoutStream; -use crate::executor::ExecutionMode; -use crate::executor::errors::ExecError; -use crate::executor::linkers::PreparedExec; use crate::function_tool::FunctionCallError; -use crate::tools::context::ApplyPatchCommandContext; -use crate::tools::context::ExecCommandContext; use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventFailure; +use crate::tools::events::ToolEventStage; +use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::runtimes::apply_patch::ApplyPatchRequest; +use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; +use crate::tools::runtimes::shell::ShellRequest; +use crate::tools::runtimes::shell::ShellRuntime; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; use codex_protocol::protocol::AskForApproval; @@ -56,7 +64,7 @@ pub(crate) async fn handle_container_exec_with_params( sub_id: String, call_id: String, ) -> Result { - let otel_event_manager = turn_context.client.get_otel_event_manager(); + let _otel_event_manager = turn_context.client.get_otel_event_manager(); if params.with_escalated_permissions.unwrap_or(false) && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) @@ -100,86 +108,142 @@ pub(crate) async fn handle_container_exec_with_params( MaybeApplyPatchVerified::NotApplyPatch => None, }; - let command_for_display = if let Some(exec) = apply_patch_exec.as_ref() { - vec!["apply_patch".to_string(), exec.action.patch.clone()] - } else { - params.command.clone() - }; - - let exec_command_context = ExecCommandContext { - sub_id: sub_id.clone(), - call_id: call_id.clone(), - command_for_display: command_for_display.clone(), - cwd: params.cwd.clone(), - apply_patch: apply_patch_exec.as_ref().map( - |ApplyPatchExec { - action, - user_explicitly_approved_this_action, - }| ApplyPatchCommandContext { - user_explicitly_approved_this_action: *user_explicitly_approved_this_action, - changes: convert_apply_patch_to_protocol(action), - }, + let (event_emitter, diff_opt) = match apply_patch_exec.as_ref() { + Some(exec) => ( + ToolEmitter::apply_patch( + convert_apply_patch_to_protocol(&exec.action), + !exec.user_explicitly_approved_this_action, + ), + Some(&turn_diff_tracker), + ), + None => ( + ToolEmitter::shell(params.command.clone(), params.cwd.clone()), + None, ), - tool_name: tool_name.to_string(), - otel_event_manager, }; - let mode = match apply_patch_exec { - Some(exec) => ExecutionMode::ApplyPatch(exec), - None => ExecutionMode::Shell, - }; + let event_ctx = ToolEventCtx::new(sess.as_ref(), &sub_id, &call_id, diff_opt); + event_emitter.emit(event_ctx, ToolEventStage::Begin).await; - sess.services.executor.update_environment( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), - ); + // Build runtime contexts only when needed (shell/apply_patch below). - let prepared_exec = PreparedExec::new( - exec_command_context, - params, - command_for_display, - mode, - Some(StdoutStream { + if let Some(exec) = apply_patch_exec { + // Route apply_patch execution through the new orchestrator/runtime. + let req = ApplyPatchRequest { + patch: exec.action.patch.clone(), + cwd: params.cwd.clone(), + timeout_ms: params.timeout_ms, + user_explicitly_approved: exec.user_explicitly_approved_this_action, + codex_exe: turn_context.codex_linux_sandbox_exe.clone(), + }; + + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = ApplyPatchRuntime::new(); + let tool_ctx = ToolCtx { + session: sess.as_ref(), sub_id: sub_id.clone(), call_id: call_id.clone(), - tx_event: sess.get_tx_event(), - }), - turn_context.shell_environment_policy.use_profile, - ); + tool_name: tool_name.to_string(), + }; - let output_result = sess - .run_exec_with_events( - turn_diff_tracker.clone(), - prepared_exec, - turn_context.approval_policy, - ) - .await; + let out = orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + &turn_context, + turn_context.approval_policy, + ) + .await; - // always make sure to truncate the output if its length isn't controlled. - match output_result { + handle_exec_outcome(&event_emitter, event_ctx, out).await + } else { + // Route shell execution through the new orchestrator/runtime. + let req = ShellRequest { + command: params.command.clone(), + cwd: params.cwd.clone(), + timeout_ms: params.timeout_ms, + env: params.env.clone(), + with_escalated_permissions: params.with_escalated_permissions, + justification: params.justification.clone(), + }; + + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = ShellRuntime::new(); + let tool_ctx = ToolCtx { + session: sess.as_ref(), + sub_id: sub_id.clone(), + call_id: call_id.clone(), + tool_name: tool_name.to_string(), + }; + + let out = orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + &turn_context, + turn_context.approval_policy, + ) + .await; + + handle_exec_outcome(&event_emitter, event_ctx, out).await + } +} + +async fn handle_exec_outcome( + event_emitter: &ToolEmitter, + event_ctx: ToolEventCtx<'_>, + out: Result, +) -> Result { + let event; + let result = match out { Ok(output) => { - let ExecToolCallOutput { exit_code, .. } = &output; - let content = format_exec_output_apply_patch(&output); - if *exit_code == 0 { + let content = format_exec_output_for_model(&output); + let exit_code = output.exit_code; + event = ToolEventStage::Success(output); + if exit_code == 0 { Ok(content) } else { Err(FunctionCallError::RespondToModel(content)) } } - Err(ExecError::Function(err)) => Err(truncate_function_error(err)), - Err(ExecError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) => Err( - FunctionCallError::RespondToModel(format_exec_output_apply_patch(&output)), - ), - Err(ExecError::Codex(err)) => { + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output }))) + | Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + let response = format_exec_output_for_model(&output); + event = ToolEventStage::Failure(ToolEventFailure::Output(*output)); + Err(FunctionCallError::RespondToModel(response)) + } + Err(ToolError::Codex(err)) => { let message = format!("execution error: {err:?}"); + let response = format_exec_output(&message); + event = ToolEventStage::Failure(ToolEventFailure::Message(message)); Err(FunctionCallError::RespondToModel(format_exec_output( - &message, + &response, ))) } - } + Err(ToolError::Rejected(msg)) | Err(ToolError::SandboxDenied(msg)) => { + // Normalize common rejection messages for exec tools so tests and + // users see a clear, consistent phrase. + let normalized = if msg == "rejected by user" { + "exec command rejected by user".to_string() + } else { + msg + }; + let response = format_exec_output(&normalized); + event = ToolEventStage::Failure(ToolEventFailure::Message(normalized)); + Err(FunctionCallError::RespondToModel(format_exec_output( + &response, + ))) + } + }; + event_emitter.emit(event_ctx, event).await; + result } -pub fn format_exec_output_apply_patch(exec_output: &ExecToolCallOutput) -> String { +/// Format the combined exec output for sending back to the model. +/// Includes exit code and duration metadata; truncates large bodies safely. +pub fn format_exec_output_for_model(exec_output: &ExecToolCallOutput) -> String { let ExecToolCallOutput { exit_code, duration, @@ -233,17 +297,7 @@ pub fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String { format_exec_output(content) } -fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { - match err { - FunctionCallError::RespondToModel(msg) => { - FunctionCallError::RespondToModel(format_exec_output(&msg)) - } - FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), - other => other, - } -} - -fn format_exec_output(content: &str) -> String { +pub(super) fn format_exec_output(content: &str) -> String { // Head+tail truncation for the model: show the beginning and end with an elision. // Clients still receive full streams; only this formatted summary is capped. let total_lines = content.lines().count(); @@ -314,6 +368,17 @@ mod tests { use super::*; use regex_lite::Regex; + fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { + match err { + FunctionCallError::RespondToModel(msg) => { + FunctionCallError::RespondToModel(format_exec_output(&msg)) + } + FunctionCallError::Denied(msg) => FunctionCallError::Denied(format_exec_output(&msg)), + FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), + other => other, + } + } + fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) { let pattern = truncated_message_pattern(line, total_lines); let regex = Regex::new(&pattern).unwrap_or_else(|err| { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs new file mode 100644 index 0000000000..4d06163323 --- /dev/null +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -0,0 +1,172 @@ +/* +Module: orchestrator + +Central place for approvals + sandbox selection + retry semantics. Drives a +simple sequence for any ToolRuntime: approval → select sandbox → attempt → +retry without sandbox on denial (no re‑approval thanks to caching). +*/ +use crate::error::CodexErr; +use crate::error::SandboxErr; +use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxManager; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; + +pub(crate) struct ToolOrchestrator { + sandbox: SandboxManager, +} + +impl ToolOrchestrator { + pub fn new() -> Self { + Self { + sandbox: SandboxManager::new(), + } + } + + pub async fn run( + &mut self, + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx<'_>, + turn_ctx: &crate::codex::TurnContext, + approval_policy: AskForApproval, + ) -> Result + where + T: ToolRuntime, + { + let otel = turn_ctx.client.get_otel_event_manager(); + let otel_tn = &tool_ctx.tool_name; + let otel_ci = &tool_ctx.call_id; + let otel_user = codex_otel::otel_event_manager::ToolDecisionSource::User; + let otel_cfg = codex_otel::otel_event_manager::ToolDecisionSource::Config; + + // 1) Approval + let needs_initial_approval = + tool.wants_initial_approval(req, approval_policy, &turn_ctx.sandbox_policy); + let mut already_approved = false; + + if needs_initial_approval { + let approval_ctx = ApprovalCtx { + session: tool_ctx.session, + sub_id: &tool_ctx.sub_id, + call_id: &tool_ctx.call_id, + retry_reason: None, + }; + let decision = tool.start_approval_async(req, approval_ctx).await; + + otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone()); + + match decision { + ReviewDecision::Denied | ReviewDecision::Abort => { + return Err(ToolError::Rejected("rejected by user".to_string())); + } + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + } + already_approved = true; + } else { + otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg); + } + + // 2) First attempt under the selected sandbox. + let mut initial_sandbox = self + .sandbox + .select_initial(&turn_ctx.sandbox_policy, tool.sandbox_preference()); + if tool.wants_escalated_first_attempt(req) { + initial_sandbox = crate::exec::SandboxType::None; + } + let initial_attempt = SandboxAttempt { + sandbox: initial_sandbox, + policy: &turn_ctx.sandbox_policy, + manager: &self.sandbox, + sandbox_cwd: &turn_ctx.cwd, + codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), + }; + + match tool.run(req, &initial_attempt, tool_ctx).await { + Ok(out) => { + // We have a successful initial result + Ok(out) + } + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => { + if !tool.escalate_on_failure() { + return Err(ToolError::SandboxDenied( + "sandbox denied and no retry".to_string(), + )); + } + // Under `Never`, do not retry without sandbox; surface a concise message + // derived from the actual output (platform-agnostic). + if matches!(approval_policy, AskForApproval::Never) { + let msg = build_never_denied_message_from_output(output.as_ref()); + return Err(ToolError::SandboxDenied(msg)); + } + + // Ask for approval before retrying without sandbox. + if !tool.should_bypass_approval(approval_policy, already_approved) { + let reason_msg = build_denial_reason_from_output(output.as_ref()); + let approval_ctx = ApprovalCtx { + session: tool_ctx.session, + sub_id: &tool_ctx.sub_id, + call_id: &tool_ctx.call_id, + retry_reason: Some(reason_msg), + }; + + let decision = tool.start_approval_async(req, approval_ctx).await; + otel.tool_decision(otel_tn, otel_ci, decision, otel_user); + + match decision { + ReviewDecision::Denied | ReviewDecision::Abort => { + return Err(ToolError::Rejected("rejected by user".to_string())); + } + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + } + } + + let escalated_attempt = SandboxAttempt { + sandbox: crate::exec::SandboxType::None, + policy: &turn_ctx.sandbox_policy, + manager: &self.sandbox, + sandbox_cwd: &turn_ctx.cwd, + codex_linux_sandbox_exe: None, + }; + + // Second attempt. + (*tool).run(req, &escalated_attempt, tool_ctx).await + } + other => other, + } + } +} + +fn build_never_denied_message_from_output(output: &ExecToolCallOutput) -> String { + let body = format!( + "{}\n{}\n{}", + output.stderr.text, output.stdout.text, output.aggregated_output.text + ) + .to_lowercase(); + + let detail = if body.contains("permission denied") { + Some("Permission denied") + } else if body.contains("operation not permitted") { + Some("Operation not permitted") + } else if body.contains("read-only file system") { + Some("Read-only file system") + } else { + None + }; + + match detail { + Some(tag) => format!("failed in sandbox: {tag}"), + None => "failed in sandbox".to_string(), + } +} + +fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { + // Keep approval reason terse and stable for UX/tests, but accept the + // output so we can evolve heuristics later without touching call sites. + "command failed; retry without sandbox?".to_string() +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs new file mode 100644 index 0000000000..f6062a3a9c --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -0,0 +1,148 @@ +//! Apply Patch runtime: executes verified patches under the orchestrator. +//! +//! Assumes `apply_patch` verification/approval happened upstream. Reuses that +//! decision to avoid re-prompting, builds the self-invocation command for +//! `codex --codex-run-as-apply-patch`, and runs under the current +//! `SandboxAttempt` with a minimal environment. +use crate::CODEX_APPLY_PATCH_ARG1; +use crate::exec::ExecToolCallOutput; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::execute_env; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct ApplyPatchRequest { + pub patch: String, + pub cwd: PathBuf, + pub timeout_ms: Option, + pub user_explicitly_approved: bool, + pub codex_exe: Option, +} + +#[derive(Default)] +pub struct ApplyPatchRuntime; + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct ApprovalKey { + patch: String, + cwd: PathBuf, +} + +impl ApplyPatchRuntime { + pub fn new() -> Self { + Self + } + + fn build_command_spec(req: &ApplyPatchRequest) -> Result { + use std::env; + let exe = if let Some(path) = &req.codex_exe { + path.clone() + } else { + env::current_exe() + .map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))? + }; + let program = exe.to_string_lossy().to_string(); + Ok(CommandSpec { + program, + args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.patch.clone()], + cwd: req.cwd.clone(), + timeout_ms: req.timeout_ms, + // Run apply_patch with a minimal environment for determinism and to avoid leaks. + env: HashMap::new(), + with_escalated_permissions: None, + justification: None, + }) + } + + fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + Some(crate::exec::StdoutStream { + sub_id: ctx.sub_id.clone(), + call_id: ctx.call_id.clone(), + tx_event: ctx.session.get_tx_event(), + }) + } +} + +impl Sandboxable for ApplyPatchRuntime { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for ApplyPatchRuntime { + type ApprovalKey = ApprovalKey; + + fn approval_key(&self, req: &ApplyPatchRequest) -> Self::ApprovalKey { + ApprovalKey { + patch: req.patch.clone(), + cwd: req.cwd.clone(), + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a ApplyPatchRequest, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision> { + let key = self.approval_key(req); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + let cwd = req.cwd.clone(); + let retry_reason = ctx.retry_reason.clone(); + let user_explicitly_approved = req.user_explicitly_approved; + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + if let Some(reason) = retry_reason { + session + .request_command_approval( + sub_id, + call_id, + vec!["apply_patch".to_string()], + cwd, + Some(reason), + ) + .await + } else if user_explicitly_approved { + ReviewDecision::ApprovedForSession + } else { + ReviewDecision::Approved + } + }) + .await + }) + } +} + +impl ToolRuntime for ApplyPatchRuntime { + async fn run( + &mut self, + req: &ApplyPatchRequest, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx<'_>, + ) -> Result { + let spec = Self::build_command_spec(req)?; + let env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + .await + .map_err(ToolError::Codex)?; + Ok(out) + } +} diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs new file mode 100644 index 0000000000..212163d72c --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -0,0 +1,38 @@ +/* +Module: runtimes + +Concrete ToolRuntime implementations for specific tools. Each runtime stays +small and focused and reuses the orchestrator for approvals + sandbox + retry. +*/ +use crate::sandboxing::CommandSpec; +use crate::tools::sandboxing::ToolError; +use std::collections::HashMap; +use std::path::Path; + +pub mod apply_patch; +pub mod shell; +pub mod unified_exec; + +/// Shared helper to construct a CommandSpec from a tokenized command line. +/// Validates that at least a program is present. +pub(crate) fn build_command_spec( + command: &[String], + cwd: &Path, + env: &HashMap, + timeout_ms: Option, + with_escalated_permissions: Option, + justification: Option, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; + Ok(CommandSpec { + program: program.clone(), + args: args.to_vec(), + cwd: cwd.to_path_buf(), + env: env.clone(), + timeout_ms, + with_escalated_permissions, + justification, + }) +} diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs new file mode 100644 index 0000000000..313e9e0700 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -0,0 +1,164 @@ +/* +Runtime: shell + +Executes shell requests under the orchestrator: asks for approval when needed, +builds a CommandSpec, and runs it under the current SandboxAttempt. +*/ +use crate::command_safety::is_dangerous_command::command_might_be_dangerous; +use crate::command_safety::is_safe_command::is_known_safe_command; +use crate::exec::ExecToolCallOutput; +use crate::protocol::SandboxPolicy; +use crate::sandboxing::execute_env; +use crate::tools::runtimes::build_command_spec; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct ShellRequest { + pub command: Vec, + pub cwd: PathBuf, + pub timeout_ms: Option, + pub env: std::collections::HashMap, + pub with_escalated_permissions: Option, + pub justification: Option, +} + +#[derive(Default)] +pub struct ShellRuntime; + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct ApprovalKey { + command: Vec, + cwd: PathBuf, + escalated: bool, +} + +impl ShellRuntime { + pub fn new() -> Self { + Self + } + + fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + Some(crate::exec::StdoutStream { + sub_id: ctx.sub_id.clone(), + call_id: ctx.call_id.clone(), + tx_event: ctx.session.get_tx_event(), + }) + } +} + +impl Sandboxable for ShellRuntime { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for ShellRuntime { + type ApprovalKey = ApprovalKey; + + fn approval_key(&self, req: &ShellRequest) -> Self::ApprovalKey { + ApprovalKey { + command: req.command.clone(), + cwd: req.cwd.clone(), + escalated: req.with_escalated_permissions.unwrap_or(false), + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a ShellRequest, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision> { + let key = self.approval_key(req); + let command = req.command.clone(); + let cwd = req.cwd.clone(); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + session + .request_command_approval(sub_id, call_id, command, cwd, reason) + .await + }) + .await + }) + } + + fn wants_initial_approval( + &self, + req: &ShellRequest, + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + ) -> bool { + if is_known_safe_command(&req.command) { + return false; + } + match policy { + AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::OnRequest => { + // In DangerFullAccess, only prompt if the command looks dangerous. + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + return command_might_be_dangerous(&req.command); + } + + // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for + // non‑escalated, non‑dangerous commands — let the sandbox enforce + // restrictions (e.g., block network/write) without a user prompt. + let wants_escalation = req.with_escalated_permissions.unwrap_or(false); + if wants_escalation { + return true; + } + command_might_be_dangerous(&req.command) + } + AskForApproval::UnlessTrusted => !is_known_safe_command(&req.command), + } + } + + fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool { + req.with_escalated_permissions.unwrap_or(false) + } +} + +impl ToolRuntime for ShellRuntime { + async fn run( + &mut self, + req: &ShellRequest, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx<'_>, + ) -> Result { + let spec = build_command_spec( + &req.command, + &req.cwd, + &req.env, + req.timeout_ms, + req.with_escalated_permissions, + req.justification.clone(), + )?; + let env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + .await + .map_err(ToolError::Codex)?; + Ok(out) + } +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs new file mode 100644 index 0000000000..e642105551 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -0,0 +1,123 @@ +/* +Runtime: unified exec + +Handles approval + sandbox orchestration for unified exec requests, delegating to +the session manager to spawn PTYs once an ExecEnv is prepared. +*/ +use crate::error::CodexErr; +use crate::error::SandboxErr; +use crate::tools::runtimes::build_command_spec; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::Sandboxable; +use crate::tools::sandboxing::SandboxablePreference; +use crate::tools::sandboxing::ToolCtx; +use crate::tools::sandboxing::ToolError; +use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::with_cached_approval; +use crate::unified_exec::UnifiedExecError; +use crate::unified_exec::UnifiedExecSession; +use crate::unified_exec::UnifiedExecSessionManager; +use codex_protocol::protocol::ReviewDecision; +use futures::future::BoxFuture; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct UnifiedExecRequest { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, +} + +#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] +pub struct UnifiedExecApprovalKey { + pub command: Vec, + pub cwd: PathBuf, +} + +pub struct UnifiedExecRuntime<'a> { + manager: &'a UnifiedExecSessionManager, +} + +impl UnifiedExecRequest { + pub fn new(command: Vec, cwd: PathBuf, env: HashMap) -> Self { + Self { command, cwd, env } + } +} + +impl<'a> UnifiedExecRuntime<'a> { + pub fn new(manager: &'a UnifiedExecSessionManager) -> Self { + Self { manager } + } +} + +impl Sandboxable for UnifiedExecRuntime<'_> { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } + + fn escalate_on_failure(&self) -> bool { + true + } +} + +impl Approvable for UnifiedExecRuntime<'_> { + type ApprovalKey = UnifiedExecApprovalKey; + + fn approval_key(&self, req: &UnifiedExecRequest) -> Self::ApprovalKey { + UnifiedExecApprovalKey { + command: req.command.clone(), + cwd: req.cwd.clone(), + } + } + + fn start_approval_async<'b>( + &'b mut self, + req: &'b UnifiedExecRequest, + ctx: ApprovalCtx<'b>, + ) -> BoxFuture<'b, ReviewDecision> { + let key = self.approval_key(req); + let session = ctx.session; + let sub_id = ctx.sub_id.to_string(); + let call_id = ctx.call_id.to_string(); + let command = req.command.clone(); + let cwd = req.cwd.clone(); + let reason = ctx.retry_reason.clone(); + Box::pin(async move { + with_cached_approval(&session.services, key, || async move { + session + .request_command_approval(sub_id, call_id, command, cwd, reason) + .await + }) + .await + }) + } +} + +impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + async fn run( + &mut self, + req: &UnifiedExecRequest, + attempt: &SandboxAttempt<'_>, + _ctx: &ToolCtx<'_>, + ) -> Result { + let spec = build_command_spec(&req.command, &req.cwd, &req.env, None, None, None) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let exec_env = attempt + .env_for(&spec) + .map_err(|err| ToolError::Codex(err.into()))?; + self.manager + .open_session_with_exec_env(&exec_env) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + })) + } + other => ToolError::Rejected(other.to_string()), + }) + } +} diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs new file mode 100644 index 0000000000..ed142da46c --- /dev/null +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -0,0 +1,190 @@ +//! Shared approvals and sandboxing traits used by tool runtimes. +//! +//! Consolidates the approval flow primitives (`ApprovalDecision`, `ApprovalStore`, +//! `ApprovalCtx`, `Approvable`) together with the sandbox orchestration traits +//! and helpers (`Sandboxable`, `ToolRuntime`, `SandboxAttempt`, etc.). + +use crate::codex::Session; +use crate::error::CodexErr; +use crate::protocol::SandboxPolicy; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxTransformError; +use crate::state::SessionServices; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; +use std::path::Path; + +use futures::Future; +use futures::future::BoxFuture; +use serde::Serialize; + +#[derive(Clone, Default, Debug)] +pub(crate) struct ApprovalStore { + // Store serialized keys for generic caching across requests. + map: HashMap, +} + +impl ApprovalStore { + pub fn get(&self, key: &K) -> Option + where + K: Serialize, + { + let s = serde_json::to_string(key).ok()?; + self.map.get(&s).cloned() + } + + pub fn put(&mut self, key: K, value: ReviewDecision) + where + K: Serialize, + { + if let Ok(s) = serde_json::to_string(&key) { + self.map.insert(s, value); + } + } +} + +pub(crate) async fn with_cached_approval( + services: &SessionServices, + key: K, + fetch: F, +) -> ReviewDecision +where + K: Serialize + Clone, + F: FnOnce() -> Fut, + Fut: Future, +{ + { + let store = services.tool_approvals.lock().await; + if let Some(decision) = store.get(&key) { + return decision; + } + } + + let decision = fetch().await; + + if matches!(decision, ReviewDecision::ApprovedForSession) { + let mut store = services.tool_approvals.lock().await; + store.put(key, ReviewDecision::ApprovedForSession); + } + + decision +} + +#[derive(Clone)] +pub(crate) struct ApprovalCtx<'a> { + pub session: &'a Session, + pub sub_id: &'a str, + pub call_id: &'a str, + pub retry_reason: Option, +} + +pub(crate) trait Approvable { + type ApprovalKey: Hash + Eq + Clone + Debug + Serialize; + + fn approval_key(&self, req: &Req) -> Self::ApprovalKey; + + /// Some tools may request to skip the sandbox on the first attempt + /// (e.g., when the request explicitly asks for escalated permissions). + /// Defaults to `false`. + fn wants_escalated_first_attempt(&self, _req: &Req) -> bool { + false + } + + fn should_bypass_approval(&self, policy: AskForApproval, already_approved: bool) -> bool { + if already_approved { + // We do not ask one more time + return true; + } + matches!(policy, AskForApproval::Never) + } + + /// Decide whether an initial user approval should be requested before the + /// first attempt. Defaults to the orchestrator's behavior (pre‑refactor): + /// - Never, OnFailure: do not ask + /// - OnRequest: ask unless sandbox policy is DangerFullAccess + /// - UnlessTrusted: always ask + fn wants_initial_approval( + &self, + _req: &Req, + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + ) -> bool { + match policy { + AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), + AskForApproval::UnlessTrusted => true, + } + } + + fn start_approval_async<'a>( + &'a mut self, + req: &'a Req, + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SandboxablePreference { + Auto, + #[allow(dead_code)] // Will be used by later tools. + Require, + #[allow(dead_code)] // Will be used by later tools. + Forbid, +} + +pub(crate) trait Sandboxable { + fn sandbox_preference(&self) -> SandboxablePreference; + fn escalate_on_failure(&self) -> bool { + true + } +} + +pub(crate) struct ToolCtx<'a> { + pub session: &'a Session, + pub sub_id: String, + pub call_id: String, + pub tool_name: String, +} + +#[derive(Debug)] +pub(crate) enum ToolError { + Rejected(String), + SandboxDenied(String), + Codex(CodexErr), +} + +pub(crate) trait ToolRuntime: Approvable + Sandboxable { + async fn run( + &mut self, + req: &Req, + attempt: &SandboxAttempt<'_>, + ctx: &ToolCtx, + ) -> Result; +} + +pub(crate) struct SandboxAttempt<'a> { + pub sandbox: crate::exec::SandboxType, + pub policy: &'a crate::protocol::SandboxPolicy, + pub(crate) manager: &'a SandboxManager, + pub(crate) sandbox_cwd: &'a Path, + pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>, +} + +impl<'a> SandboxAttempt<'a> { + pub fn env_for( + &self, + spec: &CommandSpec, + ) -> Result { + self.manager.transform( + spec, + self.policy, + self.sandbox, + self.sandbox_cwd, + self.codex_linux_sandbox_exe, + ) + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e38095ecc9..c70da247ba 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,5 +1,7 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; +use crate::features::Feature; +use crate::features::Features; use crate::model_family::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::ApplyPatchToolType; @@ -33,26 +35,23 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_family: &'a ModelFamily, - pub(crate) include_plan_tool: bool, - pub(crate) include_apply_patch_tool: bool, - pub(crate) include_web_search_request: bool, - pub(crate) use_streamable_shell_tool: bool, - pub(crate) include_view_image_tool: bool, - pub(crate) experimental_unified_exec_tool: bool, + pub(crate) features: &'a Features, } impl ToolsConfig { pub fn new(params: &ToolsConfigParams) -> Self { let ToolsConfigParams { model_family, - include_plan_tool, - include_apply_patch_tool, - include_web_search_request, - use_streamable_shell_tool, - include_view_image_tool, - experimental_unified_exec_tool, + features, } = params; - let shell_type = if *use_streamable_shell_tool { + let use_streamable_shell_tool = features.enabled(Feature::StreamableShell); + let experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let include_plan_tool = features.enabled(Feature::PlanTool); + let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); + let include_web_search_request = features.enabled(Feature::WebSearchRequest); + let include_view_image_tool = features.enabled(Feature::ViewImageTool); + + let shell_type = if use_streamable_shell_tool { ConfigShellToolType::Streamable } else if model_family.uses_local_shell_tool { ConfigShellToolType::Local @@ -64,7 +63,7 @@ impl ToolsConfig { Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), None => { - if *include_apply_patch_tool { + if include_apply_patch_tool { Some(ApplyPatchToolType::Freeform) } else { None @@ -74,11 +73,11 @@ impl ToolsConfig { Self { shell_type, - plan_tool: *include_plan_tool, + plan_tool: include_plan_tool, apply_patch_tool_type, - web_search_request: *include_web_search_request, - include_view_image_tool: *include_view_image_tool, - experimental_unified_exec_tool: *experimental_unified_exec_tool, + web_search_request: include_web_search_request, + include_view_image_tool, + experimental_unified_exec_tool, experimental_supported_tools: model_family.experimental_supported_tools.clone(), } } @@ -392,11 +391,72 @@ fn create_read_file_tool() -> ToolSpec { description: Some("The maximum number of lines to return.".to_string()), }, ); + properties.insert( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ + to expand around an anchor line." + .to_string(), + ), + }, + ); + + let mut indentation_properties = BTreeMap::new(); + indentation_properties.insert( + "anchor_line".to_string(), + JsonSchema::Number { + description: Some( + "Anchor line to center the indentation lookup on (defaults to offset).".to_string(), + ), + }, + ); + indentation_properties.insert( + "max_levels".to_string(), + JsonSchema::Number { + description: Some( + "How many parent indentation levels (smaller indents) to include.".to_string(), + ), + }, + ); + indentation_properties.insert( + "include_siblings".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, include additional blocks that share the anchor indentation." + .to_string(), + ), + }, + ); + indentation_properties.insert( + "include_header".to_string(), + JsonSchema::Boolean { + description: Some( + "Include doc comments or attributes directly above the selected block.".to_string(), + ), + }, + ); + indentation_properties.insert( + "max_lines".to_string(), + JsonSchema::Number { + description: Some( + "Hard cap on the number of lines returned when using indentation mode.".to_string(), + ), + }, + ); + properties.insert( + "indentation".to_string(), + JsonSchema::Object { + properties: indentation_properties, + required: None, + additional_properties: Some(false.into()), + }, + ); ToolSpec::Function(ResponsesApiTool { name: "read_file".to_string(), description: - "Reads a local file with 1-indexed line numbers and returns up to the requested number of lines." + "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." .to_string(), strict: false, parameters: JsonSchema::Object { @@ -451,6 +511,107 @@ fn create_list_dir_tool() -> ToolSpec { }, }) } + +fn create_list_mcp_resources_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resources from every configured server." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resources call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resources".to_string(), + description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_list_mcp_resource_templates_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resource templates from all configured servers." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resource_templates".to_string(), + description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_read_mcp_resource_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources." + .to_string(), + ), + }, + ); + properties.insert( + "uri".to_string(), + JsonSchema::String { + description: Some( + "Resource URI to read. Must be one of the URIs returned by list_mcp_resources." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "read_mcp_resource".to_string(), + description: + "Read a specific resource from an MCP server given the server name and resource URI." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["server".to_string(), "uri".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} /// TODO(dylan): deprecate once we get rid of json tool #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { @@ -654,15 +815,11 @@ pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, ) -> ToolRegistryBuilder { - use crate::exec_command::EXEC_COMMAND_TOOL_NAME; - use crate::exec_command::WRITE_STDIN_TOOL_NAME; - use crate::exec_command::create_exec_command_tool_for_responses_api; - use crate::exec_command::create_write_stdin_tool_for_responses_api; use crate::tools::handlers::ApplyPatchHandler; - use crate::tools::handlers::ExecStreamHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; + use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::ShellHandler; @@ -674,14 +831,17 @@ pub(crate) fn build_specs( let mut builder = ToolRegistryBuilder::new(); let shell_handler = Arc::new(ShellHandler); - let exec_stream_handler = Arc::new(ExecStreamHandler); let unified_exec_handler = Arc::new(UnifiedExecHandler); let plan_handler = Arc::new(PlanHandler); let apply_patch_handler = Arc::new(ApplyPatchHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); + let mcp_resource_handler = Arc::new(McpResourceHandler); - if config.experimental_unified_exec_tool { + let use_unified_exec = config.experimental_unified_exec_tool + || matches!(config.shell_type, ConfigShellToolType::Streamable); + + if use_unified_exec { builder.push_spec(create_unified_exec_tool()); builder.register_handler("unified_exec", unified_exec_handler); } else { @@ -693,14 +853,7 @@ pub(crate) fn build_specs( builder.push_spec(ToolSpec::LocalShell {}); } ConfigShellToolType::Streamable => { - builder.push_spec(ToolSpec::Function( - create_exec_command_tool_for_responses_api(), - )); - builder.push_spec(ToolSpec::Function( - create_write_stdin_tool_for_responses_api(), - )); - builder.register_handler(EXEC_COMMAND_TOOL_NAME, exec_stream_handler.clone()); - builder.register_handler(WRITE_STDIN_TOOL_NAME, exec_stream_handler); + // Already handled by use_unified_exec. } } } @@ -710,6 +863,13 @@ pub(crate) fn build_specs( builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); + builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); + builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); + builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); + builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); + builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); + builder.register_handler("read_mcp_resource", mcp_resource_handler); + if config.plan_tool { builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); @@ -845,40 +1005,54 @@ mod tests { fn test_build_specs() { let model_family = find_family_for_model("codex-mini-latest") .expect("codex-mini-latest should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::PlanTool); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: true, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } #[test] fn test_build_specs_default_shell() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::PlanTool); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: true, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } @@ -887,14 +1061,12 @@ mod tests { fn test_parallel_support_flags() { let model_family = find_family_for_model("gpt-5-codex") .expect("codex-mini-latest should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::ViewImageTool); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, None).build(); @@ -908,14 +1080,11 @@ mod tests { fn test_test_model_family_includes_sync_tool() { let model_family = find_family_for_model("test-gpt-5-codex") .expect("test-gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::ViewImageTool); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: false, + features: &features, }); let (tools, _) = build_specs(&config, None).build(); @@ -940,14 +1109,12 @@ mod tests { #[test] fn test_build_specs_mcp_tools() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( &config, @@ -992,15 +1159,19 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "web_search", "view_image", "test_server/do_something_cool", ], ); + let tool = find_tool(&tools, "test_server/do_something_cool"); assert_eq!( - tools[3].spec, - ToolSpec::Function(ResponsesApiTool { + &tool.spec, + &ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([ @@ -1045,14 +1216,11 @@ mod tests { #[test] fn test_build_specs_mcp_tools_sorted_by_name() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); // Intentionally construct a map with keys that would sort alphabetically. @@ -1110,6 +1278,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "view_image", "test_server/cool", "test_server/do", @@ -1122,14 +1293,12 @@ mod tests { fn test_mcp_tool_property_missing_type_defaults_to_string() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1160,6 +1329,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1168,7 +1340,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { @@ -1191,14 +1363,12 @@ mod tests { fn test_mcp_tool_integer_normalized_to_number() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1227,6 +1397,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1234,7 +1407,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { @@ -1255,14 +1428,13 @@ mod tests { fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::ApplyPatchFreeform); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: true, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1291,6 +1463,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1298,7 +1473,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { @@ -1322,14 +1497,12 @@ mod tests { fn test_mcp_tool_anyof_defaults_to_string() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1358,6 +1531,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1365,7 +1541,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { @@ -1401,14 +1577,12 @@ mod tests { fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( &config, @@ -1462,6 +1636,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1470,7 +1647,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { diff --git a/codex-rs/core/src/unified_exec/errors.rs b/codex-rs/core/src/unified_exec/errors.rs index 6bf5bf7ec5..bfbef93572 100644 --- a/codex-rs/core/src/unified_exec/errors.rs +++ b/codex-rs/core/src/unified_exec/errors.rs @@ -1,22 +1,29 @@ +use crate::exec::ExecToolCallOutput; use thiserror::Error; #[derive(Debug, Error)] pub(crate) enum UnifiedExecError { - #[error("Failed to create unified exec session: {pty_error}")] - CreateSession { - #[source] - pty_error: anyhow::Error, - }, + #[error("Failed to create unified exec session: {message}")] + CreateSession { message: String }, #[error("Unknown session id {session_id}")] UnknownSessionId { session_id: i32 }, #[error("failed to write to stdin")] WriteToStdin, #[error("missing command line for unified exec request")] MissingCommandLine, + #[error("Command denied by sandbox: {message}")] + SandboxDenied { + message: String, + output: ExecToolCallOutput, + }, } impl UnifiedExecError { - pub(crate) fn create_session(error: anyhow::Error) -> Self { - Self::CreateSession { pty_error: error } + pub(crate) fn create_session(message: String) -> Self { + Self::CreateSession { message } + } + + pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self { + Self::SandboxDenied { message, output } } } diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 48b82e3e8c..fb791fe89f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -1,36 +1,55 @@ -use portable_pty::CommandBuilder; -use portable_pty::PtySize; -use portable_pty::native_pty_system; -use std::collections::HashMap; -use std::collections::VecDeque; -use std::io::ErrorKind; -use std::io::Read; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicI32; -use std::sync::atomic::Ordering; -use tokio::sync::Mutex; -use tokio::sync::Notify; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use tokio::time::Duration; -use tokio::time::Instant; +//! Unified Exec: interactive PTY execution orchestrated with approvals + sandboxing. +//! +//! Responsibilities +//! - Manages interactive PTY sessions (create, reuse, buffer output with caps). +//! - Uses the shared ToolOrchestrator to handle approval, sandbox selection, and +//! retry semantics in a single, descriptive flow. +//! - Spawns the PTY from a sandbox‑transformed `ExecEnv`; on sandbox denial, +//! retries without sandbox when policy allows (no re‑prompt thanks to caching). +//! - Uses the shared `is_likely_sandbox_denied` heuristic to keep denial messages +//! consistent with other exec paths. +//! +//! Flow at a glance (open session) +//! 1) Build a small request `{ command, cwd }`. +//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run. +//! 3) Runtime: transform `CommandSpec` → `ExecEnv` → spawn PTY. +//! 4) If denial, orchestrator retries with `SandboxType::None`. +//! 5) Session is returned with streaming output + metadata. +//! +//! This keeps policy logic and user interaction centralized while the PTY/session +//! concerns remain isolated here. The implementation is split between: +//! - `session.rs`: PTY session lifecycle + output buffering. +//! - `session_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling. -use crate::exec_command::ExecCommandSession; -use crate::truncate::truncate_middle; +use std::collections::HashMap; +use std::sync::atomic::AtomicI32; + +use tokio::sync::Mutex; + +use crate::codex::Session; +use crate::codex::TurnContext; mod errors; +mod session; +mod session_manager; pub(crate) use errors::UnifiedExecError; +pub(crate) use session::UnifiedExecSession; const DEFAULT_TIMEOUT_MS: u64 = 1_000; const MAX_TIMEOUT_MS: u64 = 60_000; const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 128 * 1024; // 128 KiB +pub(crate) struct UnifiedExecContext<'a> { + pub session: &'a Session, + pub turn: &'a TurnContext, + pub sub_id: &'a str, + pub call_id: &'a str, + pub session_id: Option, +} + #[derive(Debug)] pub(crate) struct UnifiedExecRequest<'a> { - pub session_id: Option, pub input_chunks: &'a [String], pub timeout_ms: Option, } @@ -44,368 +63,60 @@ pub(crate) struct UnifiedExecResult { #[derive(Debug, Default)] pub(crate) struct UnifiedExecSessionManager { next_session_id: AtomicI32, - sessions: Mutex>, -} - -#[derive(Debug)] -struct ManagedUnifiedExecSession { - session: ExecCommandSession, - output_buffer: OutputBuffer, - /// Notifies waiters whenever new output has been appended to - /// `output_buffer`, allowing clients to poll for fresh data. - output_notify: Arc, - output_task: JoinHandle<()>, -} - -#[derive(Debug, Default)] -struct OutputBufferState { - chunks: VecDeque>, - total_bytes: usize, -} - -impl OutputBufferState { - fn push_chunk(&mut self, chunk: Vec) { - self.total_bytes = self.total_bytes.saturating_add(chunk.len()); - self.chunks.push_back(chunk); - - let mut excess = self - .total_bytes - .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); - - while excess > 0 { - match self.chunks.front_mut() { - Some(front) if excess >= front.len() => { - excess -= front.len(); - self.total_bytes = self.total_bytes.saturating_sub(front.len()); - self.chunks.pop_front(); - } - Some(front) => { - front.drain(..excess); - self.total_bytes = self.total_bytes.saturating_sub(excess); - break; - } - None => break, - } - } - } - - fn drain(&mut self) -> Vec> { - let drained: Vec> = self.chunks.drain(..).collect(); - self.total_bytes = 0; - drained - } -} - -type OutputBuffer = Arc>; -type OutputHandles = (OutputBuffer, Arc); - -impl ManagedUnifiedExecSession { - fn new( - session: ExecCommandSession, - initial_output_rx: tokio::sync::broadcast::Receiver>, - ) -> Self { - let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); - let output_notify = Arc::new(Notify::new()); - let mut receiver = initial_output_rx; - let buffer_clone = Arc::clone(&output_buffer); - let notify_clone = Arc::clone(&output_notify); - let output_task = tokio::spawn(async move { - while let Ok(chunk) = receiver.recv().await { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); - } - }); - - Self { - session, - output_buffer, - output_notify, - output_task, - } - } - - fn writer_sender(&self) -> mpsc::Sender> { - self.session.writer_sender() - } - - fn output_handles(&self) -> OutputHandles { - ( - Arc::clone(&self.output_buffer), - Arc::clone(&self.output_notify), - ) - } - - fn has_exited(&self) -> bool { - self.session.has_exited() - } -} - -impl Drop for ManagedUnifiedExecSession { - fn drop(&mut self) { - self.output_task.abort(); - } -} - -impl UnifiedExecSessionManager { - pub async fn handle_request( - &self, - request: UnifiedExecRequest<'_>, - ) -> Result { - let (timeout_ms, timeout_warning) = match request.timeout_ms { - Some(requested) if requested > MAX_TIMEOUT_MS => ( - MAX_TIMEOUT_MS, - Some(format!( - "Warning: requested timeout {requested}ms exceeds maximum of {MAX_TIMEOUT_MS}ms; clamping to {MAX_TIMEOUT_MS}ms.\n" - )), - ), - Some(requested) => (requested, None), - None => (DEFAULT_TIMEOUT_MS, None), - }; - - let mut new_session: Option = None; - let session_id; - let writer_tx; - let output_buffer; - let output_notify; - - if let Some(existing_id) = request.session_id { - let mut sessions = self.sessions.lock().await; - match sessions.get(&existing_id) { - Some(session) => { - if session.has_exited() { - sessions.remove(&existing_id); - return Err(UnifiedExecError::UnknownSessionId { - session_id: existing_id, - }); - } - let (buffer, notify) = session.output_handles(); - session_id = existing_id; - writer_tx = session.writer_sender(); - output_buffer = buffer; - output_notify = notify; - } - None => { - return Err(UnifiedExecError::UnknownSessionId { - session_id: existing_id, - }); - } - } - drop(sessions); - } else { - let command = request.input_chunks.to_vec(); - let new_id = self.next_session_id.fetch_add(1, Ordering::SeqCst); - let (session, initial_output_rx) = create_unified_exec_session(&command).await?; - let managed_session = ManagedUnifiedExecSession::new(session, initial_output_rx); - let (buffer, notify) = managed_session.output_handles(); - writer_tx = managed_session.writer_sender(); - output_buffer = buffer; - output_notify = notify; - session_id = new_id; - new_session = Some(managed_session); - }; - - if request.session_id.is_some() { - let joined_input = request.input_chunks.join(" "); - if !joined_input.is_empty() && writer_tx.send(joined_input.into_bytes()).await.is_err() - { - return Err(UnifiedExecError::WriteToStdin); - } - } - - let mut collected: Vec = Vec::with_capacity(4096); - let start = Instant::now(); - let deadline = start + Duration::from_millis(timeout_ms); - - loop { - let drained_chunks; - let mut wait_for_output = None; - { - let mut guard = output_buffer.lock().await; - drained_chunks = guard.drain(); - if drained_chunks.is_empty() { - wait_for_output = Some(output_notify.notified()); - } - } - - if drained_chunks.is_empty() { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining == Duration::ZERO { - break; - } - - let notified = wait_for_output.unwrap_or_else(|| output_notify.notified()); - tokio::pin!(notified); - tokio::select! { - _ = &mut notified => {} - _ = tokio::time::sleep(remaining) => break, - } - continue; - } - - for chunk in drained_chunks { - collected.extend_from_slice(&chunk); - } - - if Instant::now() >= deadline { - break; - } - } - - let (output, _maybe_tokens) = truncate_middle( - &String::from_utf8_lossy(&collected), - UNIFIED_EXEC_OUTPUT_MAX_BYTES, - ); - let output = if let Some(warning) = timeout_warning { - format!("{warning}{output}") - } else { - output - }; - - let should_store_session = if let Some(session) = new_session.as_ref() { - !session.has_exited() - } else if request.session_id.is_some() { - let mut sessions = self.sessions.lock().await; - if let Some(existing) = sessions.get(&session_id) { - if existing.has_exited() { - sessions.remove(&session_id); - false - } else { - true - } - } else { - false - } - } else { - true - }; - - if should_store_session { - if let Some(session) = new_session { - self.sessions.lock().await.insert(session_id, session); - } - Ok(UnifiedExecResult { - session_id: Some(session_id), - output, - }) - } else { - Ok(UnifiedExecResult { - session_id: None, - output, - }) - } - } -} - -async fn create_unified_exec_session( - command: &[String], -) -> Result< - ( - ExecCommandSession, - tokio::sync::broadcast::Receiver>, - ), - UnifiedExecError, -> { - if command.is_empty() { - return Err(UnifiedExecError::MissingCommandLine); - } - - let pty_system = native_pty_system(); - - let pair = pty_system - .openpty(PtySize { - rows: 24, - cols: 80, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(UnifiedExecError::create_session)?; - - // Safe thanks to the check at the top of the function. - let mut command_builder = CommandBuilder::new(command[0].clone()); - for arg in &command[1..] { - command_builder.arg(arg); - } - - let mut child = pair - .slave - .spawn_command(command_builder) - .map_err(UnifiedExecError::create_session)?; - let killer = child.clone_killer(); - - let (writer_tx, mut writer_rx) = mpsc::channel::>(128); - let (output_tx, _) = tokio::sync::broadcast::channel::>(256); - - let mut reader = pair - .master - .try_clone_reader() - .map_err(UnifiedExecError::create_session)?; - let output_tx_clone = output_tx.clone(); - let reader_handle = tokio::task::spawn_blocking(move || { - let mut buf = [0u8; 8192]; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - let _ = output_tx_clone.send(buf[..n].to_vec()); - } - Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, - Err(ref e) if e.kind() == ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - continue; - } - Err(_) => break, - } - } - }); - - let writer = pair - .master - .take_writer() - .map_err(UnifiedExecError::create_session)?; - let writer = Arc::new(StdMutex::new(writer)); - let writer_handle = tokio::spawn({ - let writer = writer.clone(); - async move { - while let Some(bytes) = writer_rx.recv().await { - let writer = writer.clone(); - let _ = tokio::task::spawn_blocking(move || { - if let Ok(mut guard) = writer.lock() { - use std::io::Write; - let _ = guard.write_all(&bytes); - let _ = guard.flush(); - } - }) - .await; - } - } - }); - - let exit_status = Arc::new(AtomicBool::new(false)); - let wait_exit_status = Arc::clone(&exit_status); - let wait_handle = tokio::task::spawn_blocking(move || { - let _ = child.wait(); - wait_exit_status.store(true, Ordering::SeqCst); - }); - - let (session, initial_output_rx) = ExecCommandSession::new( - writer_tx, - output_tx, - killer, - reader_handle, - writer_handle, - wait_handle, - exit_status, - ); - Ok((session, initial_output_rx)) + sessions: Mutex>, } #[cfg(test)] +#[cfg(unix)] mod tests { use super::*; - #[cfg(unix)] + + use crate::codex::Session; + use crate::codex::TurnContext; + use crate::codex::make_session_and_context; + use crate::protocol::AskForApproval; + use crate::protocol::SandboxPolicy; use core_test_support::skip_if_sandbox; + use std::sync::Arc; + use tokio::time::Duration; + + use super::session::OutputBufferState; + + fn test_session_and_turn() -> (Arc, Arc) { + let (session, mut turn) = make_session_and_context(); + turn.approval_policy = AskForApproval::Never; + turn.sandbox_policy = SandboxPolicy::DangerFullAccess; + (Arc::new(session), Arc::new(turn)) + } + + async fn run_unified_exec_request( + session: &Arc, + turn: &Arc, + session_id: Option, + input: Vec, + timeout_ms: Option, + ) -> Result { + let request_input = input; + let request = UnifiedExecRequest { + input_chunks: &request_input, + timeout_ms, + }; + + session + .services + .unified_exec_manager + .handle_request( + request, + UnifiedExecContext { + session, + turn: turn.as_ref(), + sub_id: "sub", + call_id: "call", + session_id, + }, + ) + .await + } #[test] fn push_chunk_trims_only_excess_bytes() { @@ -415,167 +126,170 @@ mod tests { buffer.push_chunk(vec![b'c']); assert_eq!(buffer.total_bytes, UNIFIED_EXEC_OUTPUT_MAX_BYTES); - assert_eq!(buffer.chunks.len(), 3); + let snapshot = buffer.snapshot(); + assert_eq!(snapshot.len(), 3); assert_eq!( - buffer.chunks.front().unwrap().len(), + snapshot.first().unwrap().len(), UNIFIED_EXEC_OUTPUT_MAX_BYTES - 2 ); - assert_eq!(buffer.chunks.pop_back().unwrap(), vec![b'c']); - assert_eq!(buffer.chunks.pop_back().unwrap(), vec![b'b']); + assert_eq!(snapshot.get(2).unwrap(), &vec![b'c']); + assert_eq!(snapshot.get(1).unwrap(), &vec![b'b']); } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn unified_exec_persists_across_requests_jif() -> Result<(), UnifiedExecError> { + async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session_id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[ - "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec![ + "export".to_string(), + "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + ], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(2_500), + ) + .await?; assert!(out_2.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn multi_unified_exec_sessions() -> Result<(), UnifiedExecError> { + async fn multi_unified_exec_sessions() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let shell_a = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let shell_a = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_a = shell_a.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_a), - input_chunks: &["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_a), + vec!["export CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string()], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &[ - "echo".to_string(), - "$CODEX_INTERACTIVE_SHELL_VAR\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + None, + vec![ + "echo".to_string(), + "$CODEX_INTERACTIVE_SHELL_VAR\n".to_string(), + ], + Some(2_500), + ) + .await?; assert!(!out_2.output.contains("codex")); - let out_3 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_a), - input_chunks: &["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let out_3 = run_unified_exec_request( + &session, + &turn, + Some(session_a), + vec!["echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(2_500), + ) + .await?; assert!(out_3.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test] - async fn unified_exec_timeouts() -> Result<(), UnifiedExecError> { + async fn unified_exec_timeouts() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[ - "export".to_string(), - "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), - ], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec![ + "export".to_string(), + "CODEX_INTERACTIVE_SHELL_VAR=codex\n".to_string(), + ], + Some(2_500), + ) + .await?; - let out_2 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], - timeout_ms: Some(10), - }) - .await?; + let out_2 = run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n".to_string()], + Some(10), + ) + .await?; assert!(!out_2.output.contains("codex")); tokio::time::sleep(Duration::from_secs(7)).await; - let empty = Vec::new(); - let out_3 = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &empty, - timeout_ms: Some(100), - }) - .await?; + let out_3 = + run_unified_exec_request(&session, &turn, Some(session_id), Vec::new(), Some(100)) + .await?; assert!(out_3.output.contains("codex")); Ok(()) } - #[cfg(unix)] #[tokio::test] #[ignore] // Ignored while we have a better way to test this. - async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> { - let manager = UnifiedExecSessionManager::default(); + async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn(); - let result = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["echo".to_string(), "codex".to_string()], - timeout_ms: Some(120_000), - }) - .await?; + let result = run_unified_exec_request( + &session, + &turn, + None, + vec!["echo".to_string(), "codex".to_string()], + Some(120_000), + ) + .await?; assert!(result.output.starts_with( "Warning: requested timeout 120000ms exceeds maximum of 60000ms; clamping to 60000ms.\n" @@ -585,61 +299,66 @@ mod tests { Ok(()) } - #[cfg(unix)] #[tokio::test] #[ignore] // Ignored while we have a better way to test this. - async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> { - let manager = UnifiedExecSessionManager::default(); - let result = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/echo".to_string(), "codex".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { + let (session, turn) = test_session_and_turn(); + let result = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/echo".to_string(), "codex".to_string()], + Some(2_500), + ) + .await?; assert!(result.session_id.is_none()); assert!(result.output.contains("codex")); - assert!(manager.sessions.lock().await.is_empty()); + assert!( + session + .services + .unified_exec_manager + .sessions + .lock() + .await + .is_empty() + ); Ok(()) } - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn reusing_completed_session_returns_unknown_session() -> Result<(), UnifiedExecError> { + async fn reusing_completed_session_returns_unknown_session() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); - let manager = UnifiedExecSessionManager::default(); + let (session, turn) = test_session_and_turn(); - let open_shell = manager - .handle_request(UnifiedExecRequest { - session_id: None, - input_chunks: &["/bin/bash".to_string(), "-i".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + let open_shell = run_unified_exec_request( + &session, + &turn, + None, + vec!["/bin/bash".to_string(), "-i".to_string()], + Some(2_500), + ) + .await?; let session_id = open_shell.session_id.expect("expected session id"); - manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &["exit\n".to_string()], - timeout_ms: Some(2_500), - }) - .await?; + run_unified_exec_request( + &session, + &turn, + Some(session_id), + vec!["exit\n".to_string()], + Some(2_500), + ) + .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let err = manager - .handle_request(UnifiedExecRequest { - session_id: Some(session_id), - input_chunks: &[], - timeout_ms: Some(100), - }) - .await - .expect_err("expected unknown session error"); + let err = + run_unified_exec_request(&session, &turn, Some(session_id), Vec::new(), Some(100)) + .await + .expect_err("expected unknown session error"); match err { UnifiedExecError::UnknownSessionId { session_id: err_id } => { @@ -648,7 +367,15 @@ mod tests { other => panic!("expected UnknownSessionId, got {other:?}"), } - assert!(!manager.sessions.lock().await.contains_key(&session_id)); + assert!( + !session + .services + .unified_exec_manager + .sessions + .lock() + .await + .contains_key(&session_id) + ); Ok(()) } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs new file mode 100644 index 0000000000..bdb935f171 --- /dev/null +++ b/codex-rs/core/src/unified_exec/session.rs @@ -0,0 +1,217 @@ +#![allow(clippy::module_inception)] + +use std::collections::VecDeque; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::sync::oneshot::error::TryRecvError; +use tokio::task::JoinHandle; +use tokio::time::Duration; + +use crate::exec::ExecToolCallOutput; +use crate::exec::SandboxType; +use crate::exec::StreamOutput; +use crate::exec::is_likely_sandbox_denied; +use crate::truncate::truncate_middle; +use codex_utils_pty::ExecCommandSession; +use codex_utils_pty::SpawnedPty; + +use super::UNIFIED_EXEC_OUTPUT_MAX_BYTES; +use super::UnifiedExecError; + +#[derive(Debug, Default)] +pub(crate) struct OutputBufferState { + chunks: VecDeque>, + pub(crate) total_bytes: usize, +} + +impl OutputBufferState { + pub(super) fn push_chunk(&mut self, chunk: Vec) { + self.total_bytes = self.total_bytes.saturating_add(chunk.len()); + self.chunks.push_back(chunk); + + let mut excess = self + .total_bytes + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + + while excess > 0 { + match self.chunks.front_mut() { + Some(front) if excess >= front.len() => { + excess -= front.len(); + self.total_bytes = self.total_bytes.saturating_sub(front.len()); + self.chunks.pop_front(); + } + Some(front) => { + front.drain(..excess); + self.total_bytes = self.total_bytes.saturating_sub(excess); + break; + } + None => break, + } + } + } + + pub(super) fn drain(&mut self) -> Vec> { + let drained: Vec> = self.chunks.drain(..).collect(); + self.total_bytes = 0; + drained + } + + pub(super) fn snapshot(&self) -> Vec> { + self.chunks.iter().cloned().collect() + } +} + +pub(crate) type OutputBuffer = Arc>; +pub(crate) type OutputHandles = (OutputBuffer, Arc); + +#[derive(Debug)] +pub(crate) struct UnifiedExecSession { + session: ExecCommandSession, + output_buffer: OutputBuffer, + output_notify: Arc, + output_task: JoinHandle<()>, + sandbox_type: SandboxType, +} + +impl UnifiedExecSession { + pub(super) fn new( + session: ExecCommandSession, + initial_output_rx: tokio::sync::broadcast::Receiver>, + sandbox_type: SandboxType, + ) -> Self { + let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); + let output_notify = Arc::new(Notify::new()); + let mut receiver = initial_output_rx; + let buffer_clone = Arc::clone(&output_buffer); + let notify_clone = Arc::clone(&output_notify); + let output_task = tokio::spawn(async move { + loop { + match receiver.recv().await { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + Self { + session, + output_buffer, + output_notify, + output_task, + sandbox_type, + } + } + + pub(super) fn writer_sender(&self) -> mpsc::Sender> { + self.session.writer_sender() + } + + pub(super) fn output_handles(&self) -> OutputHandles { + ( + Arc::clone(&self.output_buffer), + Arc::clone(&self.output_notify), + ) + } + + pub(super) fn has_exited(&self) -> bool { + self.session.has_exited() + } + + pub(super) fn exit_code(&self) -> Option { + self.session.exit_code() + } + + async fn snapshot_output(&self) -> Vec> { + let guard = self.output_buffer.lock().await; + guard.snapshot() + } + + fn sandbox_type(&self) -> SandboxType { + self.sandbox_type + } + + pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> { + if self.sandbox_type() == SandboxType::None || !self.has_exited() { + return Ok(()); + } + + let _ = + tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await; + + let collected_chunks = self.snapshot_output().await; + let mut aggregated: Vec = Vec::new(); + for chunk in collected_chunks { + aggregated.extend_from_slice(&chunk); + } + let aggregated_text = String::from_utf8_lossy(&aggregated).to_string(); + let exit_code = self.exit_code().unwrap_or(-1); + + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_text.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_text.clone()), + duration: Duration::ZERO, + timed_out: false, + }; + + if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) { + let (snippet, _) = truncate_middle(&aggregated_text, UNIFIED_EXEC_OUTPUT_MAX_BYTES); + let message = if snippet.is_empty() { + format!("exit code {exit_code}") + } else { + snippet + }; + return Err(UnifiedExecError::sandbox_denied(message, exec_output)); + } + + Ok(()) + } + + pub(super) async fn from_spawned( + spawned: SpawnedPty, + sandbox_type: SandboxType, + ) -> Result { + let SpawnedPty { + session, + output_rx, + mut exit_rx, + } = spawned; + let managed = Self::new(session, output_rx, sandbox_type); + + let exit_ready = match exit_rx.try_recv() { + Ok(_) | Err(TryRecvError::Closed) => true, + Err(TryRecvError::Empty) => false, + }; + + if exit_ready { + managed.check_for_sandbox_denial().await?; + return Ok(managed); + } + + tokio::pin!(exit_rx); + if tokio::time::timeout(Duration::from_millis(50), &mut exit_rx) + .await + .is_ok() + { + managed.check_for_sandbox_denial().await?; + } + + Ok(managed) + } +} + +impl Drop for UnifiedExecSession { + fn drop(&mut self) { + self.output_task.abort(); + } +} diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs new file mode 100644 index 0000000000..8bc8cb293e --- /dev/null +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -0,0 +1,288 @@ +use std::sync::Arc; + +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::exec_env::create_env; +use crate::sandboxing::ExecEnv; +use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; +use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; +use crate::tools::sandboxing::ToolCtx; +use crate::truncate::truncate_middle; + +use super::DEFAULT_TIMEOUT_MS; +use super::MAX_TIMEOUT_MS; +use super::UNIFIED_EXEC_OUTPUT_MAX_BYTES; +use super::UnifiedExecContext; +use super::UnifiedExecError; +use super::UnifiedExecRequest; +use super::UnifiedExecResult; +use super::UnifiedExecSessionManager; +use super::session::OutputBuffer; +use super::session::UnifiedExecSession; + +pub(super) struct SessionAcquisition { + pub(super) session_id: i32, + pub(super) writer_tx: mpsc::Sender>, + pub(super) output_buffer: OutputBuffer, + pub(super) output_notify: Arc, + pub(super) new_session: Option, + pub(super) reuse_requested: bool, +} + +impl UnifiedExecSessionManager { + pub(super) async fn acquire_session( + &self, + request: &UnifiedExecRequest<'_>, + context: &UnifiedExecContext<'_>, + ) -> Result { + if let Some(existing_id) = context.session_id { + let mut sessions = self.sessions.lock().await; + match sessions.get(&existing_id) { + Some(session) => { + if session.has_exited() { + sessions.remove(&existing_id); + return Err(UnifiedExecError::UnknownSessionId { + session_id: existing_id, + }); + } + let (buffer, notify) = session.output_handles(); + let writer_tx = session.writer_sender(); + Ok(SessionAcquisition { + session_id: existing_id, + writer_tx, + output_buffer: buffer, + output_notify: notify, + new_session: None, + reuse_requested: true, + }) + } + None => Err(UnifiedExecError::UnknownSessionId { + session_id: existing_id, + }), + } + } else { + let new_id = self + .next_session_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let managed_session = self + .open_session_with_sandbox(request.input_chunks.to_vec(), context) + .await?; + let (buffer, notify) = managed_session.output_handles(); + let writer_tx = managed_session.writer_sender(); + Ok(SessionAcquisition { + session_id: new_id, + writer_tx, + output_buffer: buffer, + output_notify: notify, + new_session: Some(managed_session), + reuse_requested: false, + }) + } + } + + pub(crate) async fn open_session_with_exec_env( + &self, + env: &ExecEnv, + ) -> Result { + let (program, args) = env + .command + .split_first() + .ok_or(UnifiedExecError::MissingCommandLine)?; + let spawned = + codex_utils_pty::spawn_pty_process(program, args, env.cwd.as_path(), &env.env) + .await + .map_err(|err| UnifiedExecError::create_session(err.to_string()))?; + UnifiedExecSession::from_spawned(spawned, env.sandbox).await + } + + pub(super) async fn open_session_with_sandbox( + &self, + command: Vec, + context: &UnifiedExecContext<'_>, + ) -> Result { + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = UnifiedExecRuntime::new(self); + let req = UnifiedExecToolRequest::new( + command, + context.turn.cwd.clone(), + create_env(&context.turn.shell_environment_policy), + ); + let tool_ctx = ToolCtx { + session: context.session, + sub_id: context.sub_id.to_string(), + call_id: context.call_id.to_string(), + tool_name: "unified_exec".to_string(), + }; + orchestrator + .run( + &mut runtime, + &req, + &tool_ctx, + context.turn, + context.turn.approval_policy, + ) + .await + .map_err(|e| UnifiedExecError::create_session(format!("{e:?}"))) + } + + pub(super) async fn collect_output_until_deadline( + output_buffer: &OutputBuffer, + output_notify: &Arc, + deadline: Instant, + ) -> Vec { + let mut collected: Vec = Vec::with_capacity(4096); + loop { + let drained_chunks; + let mut wait_for_output = None; + { + let mut guard = output_buffer.lock().await; + drained_chunks = guard.drain(); + if drained_chunks.is_empty() { + wait_for_output = Some(output_notify.notified()); + } + } + + if drained_chunks.is_empty() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining == Duration::ZERO { + break; + } + + let notified = wait_for_output.unwrap_or_else(|| output_notify.notified()); + tokio::pin!(notified); + tokio::select! { + _ = &mut notified => {} + _ = tokio::time::sleep(remaining) => break, + } + continue; + } + + for chunk in drained_chunks { + collected.extend_from_slice(&chunk); + } + + if Instant::now() >= deadline { + break; + } + } + + collected + } + + pub(super) async fn should_store_session(&self, acquisition: &SessionAcquisition) -> bool { + if let Some(session) = acquisition.new_session.as_ref() { + !session.has_exited() + } else if acquisition.reuse_requested { + let mut sessions = self.sessions.lock().await; + if let Some(existing) = sessions.get(&acquisition.session_id) { + if existing.has_exited() { + sessions.remove(&acquisition.session_id); + false + } else { + true + } + } else { + false + } + } else { + true + } + } + + pub(super) async fn send_input_chunks( + writer_tx: &mpsc::Sender>, + chunks: &[String], + ) -> Result<(), UnifiedExecError> { + let mut trailing_whitespace = true; + for chunk in chunks { + if chunk.is_empty() { + continue; + } + + let leading_whitespace = chunk + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(true); + + if !trailing_whitespace + && !leading_whitespace + && writer_tx.send(vec![b' ']).await.is_err() + { + return Err(UnifiedExecError::WriteToStdin); + } + + if writer_tx.send(chunk.as_bytes().to_vec()).await.is_err() { + return Err(UnifiedExecError::WriteToStdin); + } + + trailing_whitespace = chunk + .chars() + .next_back() + .map(char::is_whitespace) + .unwrap_or(trailing_whitespace); + } + + Ok(()) + } + + pub async fn handle_request( + &self, + request: UnifiedExecRequest<'_>, + context: UnifiedExecContext<'_>, + ) -> Result { + let (timeout_ms, timeout_warning) = match request.timeout_ms { + Some(requested) if requested > MAX_TIMEOUT_MS => ( + MAX_TIMEOUT_MS, + Some(format!( + "Warning: requested timeout {requested}ms exceeds maximum of {MAX_TIMEOUT_MS}ms; clamping to {MAX_TIMEOUT_MS}ms.\n" + )), + ), + Some(requested) => (requested, None), + None => (DEFAULT_TIMEOUT_MS, None), + }; + + let mut acquisition = self.acquire_session(&request, &context).await?; + + if acquisition.reuse_requested { + Self::send_input_chunks(&acquisition.writer_tx, request.input_chunks).await?; + } + + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + let collected = Self::collect_output_until_deadline( + &acquisition.output_buffer, + &acquisition.output_notify, + deadline, + ) + .await; + + let (output, _maybe_tokens) = truncate_middle( + &String::from_utf8_lossy(&collected), + UNIFIED_EXEC_OUTPUT_MAX_BYTES, + ); + let output = if let Some(warning) = timeout_warning { + format!("{warning}{output}") + } else { + output + }; + + let should_store_session = self.should_store_session(&acquisition).await; + let session_id = if should_store_session { + if let Some(session) = acquisition.new_session.take() { + self.sessions + .lock() + .await + .insert(acquisition.session_id, session); + } + Some(acquisition.session_id) + } else { + None + }; + + Ok(UnifiedExecResult { session_id, output }) + } +} diff --git a/codex-rs/core/src/user_notification.rs b/codex-rs/core/src/user_notification.rs index 5eb9e98058..be96d56270 100644 --- a/codex-rs/core/src/user_notification.rs +++ b/codex-rs/core/src/user_notification.rs @@ -49,6 +49,7 @@ impl UserNotifier { pub(crate) enum UserNotification { #[serde(rename_all = "kebab-case")] AgentTurnComplete { + thread_id: String, turn_id: String, /// Messages that the user sent to the agent to initiate the turn. @@ -67,6 +68,7 @@ mod tests { #[test] fn test_user_notification() -> Result<()> { let notification = UserNotification::AgentTurnComplete { + thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(), turn_id: "12345".to_string(), input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()], last_assistant_message: Some( @@ -76,7 +78,7 @@ mod tests { let serialized = serde_json::to_string(¬ification)?; assert_eq!( serialized, - r#"{"type":"agent-turn-complete","turn-id":"12345","input-messages":["Rename `foo` to `bar` and update the callsites."],"last-assistant-message":"Rename complete and verified `cargo build` succeeds."}"# + r#"{"type":"agent-turn-complete","thread-id":"b5f6c1c2-1111-2222-3333-444455556666","turn-id":"12345","input-messages":["Rename `foo` to `bar` and update the callsites."],"last-assistant-message":"Rename complete and verified `cargo build` succeeds."}"# ); Ok(()) } diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 9e10b37822..0d0d60d47f 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -79,6 +79,7 @@ async fn run_request(input: Vec) -> Value { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 1aab6ac38c..dffc9e4213 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -78,6 +78,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 6ecc54937f..b3082dc548 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -10,8 +10,10 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } codex-core = { workspace = true } +notify = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["time"] } +walkdir = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 2c012b9b35..5944fa9481 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -164,6 +164,149 @@ pub fn sandbox_network_env_var() -> &'static str { codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR } +pub mod fs_wait { + use anyhow::Result; + use anyhow::anyhow; + use notify::RecursiveMode; + use notify::Watcher; + use std::path::Path; + use std::path::PathBuf; + use std::sync::mpsc; + use std::sync::mpsc::RecvTimeoutError; + use std::time::Duration; + use std::time::Instant; + use tokio::task; + use walkdir::WalkDir; + + pub async fn wait_for_path_exists( + path: impl Into, + timeout: Duration, + ) -> Result { + let path = path.into(); + task::spawn_blocking(move || wait_for_path_exists_blocking(path, timeout)).await? + } + + pub async fn wait_for_matching_file( + root: impl Into, + timeout: Duration, + predicate: impl FnMut(&Path) -> bool + Send + 'static, + ) -> Result { + let root = root.into(); + task::spawn_blocking(move || { + let mut predicate = predicate; + blocking_find_matching_file(root, timeout, &mut predicate) + }) + .await? + } + + fn wait_for_path_exists_blocking(path: PathBuf, timeout: Duration) -> Result { + if path.exists() { + return Ok(path); + } + + let watch_root = nearest_existing_ancestor(&path); + let (tx, rx) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + })?; + watcher.watch(&watch_root, RecursiveMode::Recursive)?; + + let deadline = Instant::now() + timeout; + loop { + if path.exists() { + return Ok(path.clone()); + } + let now = Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline.saturating_duration_since(now); + match rx.recv_timeout(remaining) { + Ok(Ok(_event)) => { + if path.exists() { + return Ok(path.clone()); + } + } + Ok(Err(err)) => return Err(err.into()), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => break, + } + } + + if path.exists() { + Ok(path) + } else { + Err(anyhow!("timed out waiting for {:?}", path)) + } + } + + fn blocking_find_matching_file( + root: PathBuf, + timeout: Duration, + predicate: &mut impl FnMut(&Path) -> bool, + ) -> Result { + let root = wait_for_path_exists_blocking(root, timeout)?; + + if let Some(found) = scan_for_match(&root, predicate) { + return Ok(found); + } + + let (tx, rx) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + })?; + watcher.watch(&root, RecursiveMode::Recursive)?; + + let deadline = Instant::now() + timeout; + + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(remaining) { + Ok(Ok(_event)) => { + if let Some(found) = scan_for_match(&root, predicate) { + return Ok(found); + } + } + Ok(Err(err)) => return Err(err.into()), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => break, + } + } + + if let Some(found) = scan_for_match(&root, predicate) { + Ok(found) + } else { + Err(anyhow!("timed out waiting for matching file in {:?}", root)) + } + } + + fn scan_for_match(root: &Path, predicate: &mut impl FnMut(&Path) -> bool) -> Option { + for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { + let path = entry.path(); + if !entry.file_type().is_file() { + continue; + } + if predicate(path) { + return Some(path.to_path_buf()); + } + } + None + } + + fn nearest_existing_ancestor(path: &Path) -> PathBuf { + let mut current = path; + loop { + if current.exists() { + return current.to_path_buf(); + } + match current.parent() { + Some(parent) => current = parent, + None => return PathBuf::from("."), + } + } + } +} + #[macro_export] macro_rules! skip_if_sandbox { () => {{ diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 98b3eca1f4..a8a777ae4f 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -138,7 +138,7 @@ pub fn ev_response_created(id: &str) -> Value { }) } -pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { +pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value { serde_json::json!({ "type": "response.completed", "response": { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 3957b05248..0e07d82228 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -1,4 +1,5 @@ use std::mem::swap; +use std::path::PathBuf; use std::sync::Arc; use codex_core::CodexAuth; @@ -39,6 +40,12 @@ impl TestCodexBuilder { let mut config = load_default_config_for_test(&home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; + config.codex_linux_sandbox_exe = Some(PathBuf::from( + assert_cmd::Command::cargo_bin("codex")? + .get_program() + .to_os_string(), + )); + let mut mutators = vec![]; swap(&mut self.config_mutators, &mut mutators); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs new file mode 100644 index 0000000000..4a086cf1e3 --- /dev/null +++ b/codex-rs/core/tests/responses_headers.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use codex_app_server_protocol::AuthMode; +use codex_core::ContentItem; +use codex_core::ModelClient; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ResponseEvent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_otel::otel_event_manager::OtelEventManager; +use codex_protocol::ConversationId; +use core_test_support::load_default_config_for_test; +use core_test_support::responses; +use futures::StreamExt; +use tempfile::TempDir; +use wiremock::matchers::header; + +#[tokio::test] +async fn responses_stream_includes_task_type_header() { + core_test_support::skip_if_no_network!(); + + let server = responses::start_mock_server().await; + let response_body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + + let request_recorder = responses::mount_sse_once_match( + &server, + header("Codex-Task-Type", "standard"), + response_body, + ) + .await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: 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(5_000), + requires_openai_auth: false, + }; + + let codex_home = TempDir::new().expect("failed to create TempDir"); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let conversation_id = ConversationId::new(); + + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some("test@test.com".to_string()), + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ); + + let client = ModelClient::new( + Arc::clone(&config), + None, + otel_event_manager, + provider, + effort, + summary, + conversation_id, + ); + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".into(), + content: vec![ContentItem::InputText { + text: "hello".into(), + }], + }]; + + let mut stream = client.stream(&prompt).await.expect("stream failed"); + while let Some(event) = stream.next().await { + if matches!(event, Ok(ResponseEvent::Completed { .. })) { + break; + } + } + + let request = request_recorder.single_request(); + assert_eq!( + request.header("Codex-Task-Type").as_deref(), + Some("standard") + ); +} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs new file mode 100644 index 0000000000..0ce1183edd --- /dev/null +++ b/codex-rs/core/tests/suite/approvals.rs @@ -0,0 +1,1175 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::model_family::find_family_for_model; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::ReviewDecision; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Copy)] +enum TargetPath { + Workspace(&'static str), + OutsideWorkspace(&'static str), +} + +impl TargetPath { + fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) { + match self { + TargetPath::Workspace(name) => { + let path = test.cwd.path().join(name); + (path, name.to_string()) + } + TargetPath::OutsideWorkspace(name) => { + let path = env::current_dir() + .expect("current dir should be available") + .join(name); + (path.clone(), path.display().to_string()) + } + } + } +} + +#[derive(Clone)] +enum ActionKind { + WriteFile { + target: TargetPath, + content: &'static str, + }, + FetchUrl { + endpoint: &'static str, + response_body: &'static str, + }, + RunCommand { + command: &'static [&'static str], + }, + ApplyPatchFunction { + target: TargetPath, + content: &'static str, + }, + ApplyPatchShell { + target: TargetPath, + content: &'static str, + }, +} + +impl ActionKind { + async fn prepare( + &self, + test: &TestCodex, + server: &MockServer, + call_id: &str, + with_escalated_permissions: bool, + ) -> Result<(Value, Option>)> { + match self { + ActionKind::WriteFile { target, content } => { + let (path, _) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!( + "printf {content:?} > {path:?} && cat {path:?}", + content = content, + path = path + ), + ]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::FetchUrl { + endpoint, + response_body, + } => { + Mock::given(method("GET")) + .and(path(*endpoint)) + .respond_with( + ResponseTemplate::new(200).set_body_string(response_body.to_string()), + ) + .mount(server) + .await; + + let url = format!("{}{}", server.uri(), endpoint); + let script = format!( + "import sys\nimport urllib.request\nurl = {url:?}\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", + ); + + let command = vec!["python3".to_string(), "-c".to_string(), script]; + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::RunCommand { command } => { + let command: Vec = command + .iter() + .map(std::string::ToString::to_string) + .collect(); + let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + ActionKind::ApplyPatchFunction { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + Ok((ev_apply_patch_function_call(call_id, &patch), None)) + } + ActionKind::ApplyPatchShell { target, content } => { + let (path, patch_path) = target.resolve_for_patch(test); + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, content); + let command = shell_apply_patch_command(&patch); + let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + Ok((event, Some(command))) + } + } + } +} + +fn build_add_file_patch(patch_path: &str, content: &str) -> String { + format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n") +} + +fn shell_apply_patch_command(patch: &str) -> Vec { + let mut script = String::from("apply_patch <<'PATCH'\n"); + script.push_str(patch); + if !patch.ends_with('\n') { + script.push('\n'); + } + script.push_str("PATCH\n"); + vec!["bash".to_string(), "-lc".to_string(), script] +} + +fn shell_event( + call_id: &str, + command: &[String], + timeout_ms: u64, + with_escalated_permissions: bool, +) -> Result { + let mut args = json!({ + "command": command, + "timeout_ms": timeout_ms, + }); + if with_escalated_permissions { + args["with_escalated_permissions"] = json!(true); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "shell", &args_str)) +} + +#[derive(Clone)] +enum Expectation { + FileCreated { + target: TargetPath, + content: &'static str, + }, + PatchApplied { + target: TargetPath, + content: &'static str, + }, + FileNotCreated { + target: TargetPath, + message_contains: &'static [&'static str], + }, + NetworkSuccess { + body_contains: &'static str, + }, + NetworkFailure { + expect_tag: &'static str, + }, + CommandSuccess { + stdout_contains: &'static str, + }, +} + +impl Expectation { + fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> { + match self { + Expectation::FileCreated { target, content } => { + let (path, _) = target.resolve_for_patch(test); + assert_eq!( + result.exit_code, + Some(0), + "expected successful exit for {:?}", + path + ); + assert!( + result.stdout.contains(content), + "stdout missing {content:?}: {}", + result.stdout + ); + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "file contents missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::PatchApplied { target, content } => { + let (path, _) = target.resolve_for_patch(test); + match result.exit_code { + Some(0) | None => { + if result.exit_code.is_none() { + assert!( + result.stdout.contains("Success."), + "patch output missing success indicator: {}", + result.stdout + ); + } + } + Some(code) => panic!( + "expected successful patch exit for {:?}, got {code} with stdout {}", + path, result.stdout + ), + } + let file_contents = fs::read_to_string(&path)?; + assert!( + file_contents.contains(content), + "patched file missing {content:?}: {file_contents}" + ); + let _ = fs::remove_file(path); + } + Expectation::FileNotCreated { + target, + message_contains, + } => { + let (path, _) = target.resolve_for_patch(test); + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for {path:?}" + ); + for needle in *message_contains { + assert!( + result.stdout.contains(needle), + "stdout missing {needle:?}: {}", + result.stdout + ); + } + assert!( + !path.exists(), + "command should not create {path:?}, but file exists" + ); + } + Expectation::NetworkSuccess { body_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful network exit: {}", + result.stdout + ); + assert!( + result.stdout.contains("OK:"), + "stdout missing OK prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(body_contains), + "stdout missing body text {body_contains:?}: {}", + result.stdout + ); + } + Expectation::NetworkFailure { expect_tag } => { + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for network failure: {}", + result.stdout + ); + assert!( + result.stdout.contains("ERR:"), + "stdout missing ERR prefix: {}", + result.stdout + ); + assert!( + result.stdout.contains(expect_tag), + "stdout missing expected tag {expect_tag:?}: {}", + result.stdout + ); + } + Expectation::CommandSuccess { stdout_contains } => { + assert_eq!( + result.exit_code, + Some(0), + "expected successful trusted command exit: {}", + result.stdout + ); + assert!( + result.stdout.contains(stdout_contains), + "trusted command stdout missing {stdout_contains:?}: {}", + result.stdout + ); + } + } + Ok(()) + } +} + +#[derive(Clone)] +enum Outcome { + Auto, + ExecApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, + PatchApproval { + decision: ReviewDecision, + expected_reason: Option<&'static str>, + }, +} + +#[derive(Clone)] +struct ScenarioSpec { + name: &'static str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + action: ActionKind, + with_escalated_permissions: bool, + requires_apply_patch_tool: bool, + model_override: Option<&'static str>, + outcome: Outcome, + expectation: Expectation, +} + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +async fn submit_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: prompt.into(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + sandbox_policy, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + Ok(()) +} + +fn parse_result(item: &Value) -> CommandResult { + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("shell output payload"); + match serde_json::from_str::(output_str) { + Ok(parsed) => { + let exit_code = parsed["metadata"]["exit_code"].as_i64(); + let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); + CommandResult { exit_code, stdout } + } + Err(_) => CommandResult { + exit_code: None, + stdout: output_str.to_string(), + }, + } +} + +async fn expect_exec_approval( + test: &TestCodex, + expected_command: &[String], +) -> ExecApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ExecApprovalRequest(approval) => { + assert_eq!(approval.command, expected_command); + approval + } + EventMsg::TaskComplete(_) => panic!("expected approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn expect_patch_approval( + test: &TestCodex, + expected_call_id: &str, +) -> ApplyPatchApprovalRequestEvent { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::ApplyPatchApprovalRequest(approval) => { + assert_eq!(approval.call_id, expected_call_id); + approval + } + EventMsg::TaskComplete(_) => panic!("expected patch approval request before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion_without_approval(test: &TestCodex) { + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }, + Duration::from_secs(5), + ) + .await; + + match event { + EventMsg::TaskComplete(_) => {} + EventMsg::ExecApprovalRequest(event) => { + panic!("unexpected approval request: {:?}", event.command) + } + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TaskComplete(_)) + }) + .await; +} + +fn scenarios() -> Vec { + use AskForApproval::*; + + let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + vec![ + ScenarioSpec { + name: "danger_full_access_on_request_allows_outside_write", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), + content: "danger-on-request", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_request_allows_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::FetchUrl { + endpoint: "/dfa/network", + response_body: "danger-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "danger-network-ok", + }, + }, + ScenarioSpec { + name: "trusted_command_unless_trusted_runs_without_prompt", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::RunCommand { + command: &["echo", "trusted-unless"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-unless", + }, + }, + ScenarioSpec { + name: "danger_full_access_on_failure_allows_outside_write", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), + content: "danger-on-failure", + }, + }, + ScenarioSpec { + name: "danger_full_access_unless_trusted_requests_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), + content: "danger-unless-trusted", + }, + }, + ScenarioSpec { + name: "danger_full_access_never_allows_outside_write", + approval_policy: Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("dfa_never.txt"), + content: "danger-never", + }, + }, + ScenarioSpec { + name: "read_only_on_request_requires_approval", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_request.txt"), + content: "read-only-approval", + }, + }, + ScenarioSpec { + name: "trusted_command_on_request_read_only_runs_without_prompt", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-read-only"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-read-only", + }, + }, + ScenarioSpec { + name: "read_only_on_request_blocks_network", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-blocked", + response_body: "should-not-see", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "read_only_on_request_denied_blocks_execution", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + content: "should-not-write", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_on_request_denied.txt"), + message_contains: &["exec command rejected by user"], + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "read_only_on_failure_escalates_after_sandbox_error", + approval_policy: OnFailure, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_on_failure.txt"), + content: "read-only-on-failure", + }, + }, + ScenarioSpec { + name: "read_only_on_request_network_escalates_when_approved", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::FetchUrl { + endpoint: "/ro/network-approved", + response_body: "read-only-network-ok", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::NetworkSuccess { + body_contains: "read-only-network-ok", + }, + }, + ScenarioSpec { + name: "apply_patch_shell_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_shell.txt"), + content: "shell-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_auto_inside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function.txt"), + content: "function-apply-patch", + }, + }, + ScenarioSpec { + name: "apply_patch_function_danger_allows_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), + content: "function-patch-danger", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), + content: "function-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_outside_denied_blocks_patch", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + content: "function-patch-outside-denied", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + message_contains: &["patch rejected by user"], + }, + }, + ScenarioSpec { + name: "apply_patch_shell_outside_requires_patch_approval", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchShell { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: None, + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), + content: "shell-patch-outside", + }, + }, + ScenarioSpec { + name: "apply_patch_function_unless_trusted_requires_patch_approval", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::PatchApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::PatchApplied { + target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), + content: "function-patch-unless-trusted", + }, + }, + ScenarioSpec { + name: "apply_patch_function_never_rejects_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::ApplyPatchFunction { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + content: "function-patch-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: true, + model_override: Some("gpt-5-codex"), + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + message_contains: &[ + "patch rejected: writing outside of the project; rejected by user approval settings", + ], + }, + }, + ScenarioSpec { + name: "read_only_unless_trusted_requires_approval", + approval_policy: UnlessTrusted, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ro_unless_trusted.txt"), + content: "read-only-unless-trusted", + }, + }, + ScenarioSpec { + name: "read_only_never_reports_sandbox_failure", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ro_never.txt"), + content: "read-only-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::Workspace("ro_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ScenarioSpec { + name: "trusted_command_never_runs_without_prompt", + approval_policy: Never, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunCommand { + command: &["echo", "trusted-never"], + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "trusted-never", + }, + }, + ScenarioSpec { + name: "workspace_write_on_request_allows_workspace_write", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileCreated { + target: TargetPath::Workspace("ww_on_request.txt"), + content: "workspace-on-request", + }, + }, + ScenarioSpec { + name: "workspace_write_network_disabled_blocks_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-blocked", + response_body: "workspace-network-blocked", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, + }, + ScenarioSpec { + name: "workspace_write_on_request_requires_approval_outside_workspace", + approval_policy: OnRequest, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + with_escalated_permissions: true, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), + content: "workspace-on-request-outside", + }, + }, + ScenarioSpec { + name: "workspace_write_network_enabled_allows_network", + approval_policy: OnRequest, + sandbox_policy: workspace_write(true), + action: ActionKind::FetchUrl { + endpoint: "/ww/network-ok", + response_body: "workspace-network-ok", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::NetworkSuccess { + body_contains: "workspace-network-ok", + }, + }, + #[cfg(not(target_os = "linux"))] // TODO (pakrym): figure out why linux behaves differently + ScenarioSpec { + name: "workspace_write_on_failure_escalates_outside_workspace", + approval_policy: OnFailure, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some("command failed; retry without sandbox?"), + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), + content: "workspace-on-failure", + }, + }, + ScenarioSpec { + name: "workspace_write_unless_trusted_requires_approval_outside_workspace", + approval_policy: UnlessTrusted, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: None, + }, + expectation: Expectation::FileCreated { + target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), + content: "workspace-unless-trusted", + }, + }, + ScenarioSpec { + name: "workspace_write_never_blocks_outside_workspace", + approval_policy: Never, + sandbox_policy: workspace_write(false), + action: ActionKind::WriteFile { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + content: "workspace-never", + }, + with_escalated_permissions: false, + requires_apply_patch_tool: false, + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::FileNotCreated { + target: TargetPath::OutsideWorkspace("ww_never.txt"), + message_contains: if cfg!(target_os = "linux") { + &["Permission denied"] + } else { + &["failed in sandbox"] + }, + }, + }, + ] +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approval_matrix_covers_all_modes() -> Result<()> { + skip_if_no_network!(Ok(())); + + for scenario in scenarios() { + run_scenario(&scenario).await?; + } + + Ok(()) +} + +async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { + eprintln!("running approval scenario: {}", scenario.name); + let server = start_mock_server().await; + let approval_policy = scenario.approval_policy; + let sandbox_policy = scenario.sandbox_policy.clone(); + let requires_apply_patch_tool = scenario.requires_apply_patch_tool; + let model_override = scenario.model_override; + + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = approval_policy; + config.sandbox_policy = sandbox_policy.clone(); + let model = model_override.unwrap_or("gpt-5"); + config.model = model.to_string(); + config.model_family = + find_family_for_model(model).expect("model should map to a known family"); + if requires_apply_patch_tool { + config.include_apply_patch_tool = true; + } + }); + let test = builder.build(&server).await?; + + let call_id = scenario.name; + let (event, expected_command) = scenario + .action + .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .await?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + event, + ev_completed("resp-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn( + &test, + scenario.name, + scenario.approval_policy, + scenario.sandbox_policy.clone(), + ) + .await?; + + match &scenario.outcome { + Outcome::Auto => { + wait_for_completion_without_approval(&test).await; + } + Outcome::ExecApproval { + decision, + expected_reason, + } => { + let command = expected_command + .as_ref() + .expect("exec approval requires shell command"); + let approval = expect_exec_approval(&test, command).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + Outcome::PatchApproval { + decision, + expected_reason, + } => { + let approval = expect_patch_approval(&test, call_id).await; + if let Some(expected_reason) = expected_reason { + assert_eq!( + approval.reason.as_deref(), + Some(*expected_reason), + "unexpected patch approval reason for {}", + scenario.name + ); + } + test.codex + .submit(Op::PatchApproval { + id: "0".into(), + decision: *decision, + }) + .await?; + wait_for_completion(&test).await; + } + } + + let output_item = results_mock.single_request().function_call_output(call_id); + let result = parse_result(&output_item); + scenario.expectation.verify(&test, &result)?; + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index f9408d5a9c..497730926a 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,12 +1,11 @@ use assert_cmd::Command as AssertCommand; use codex_core::RolloutRecorder; use codex_core::protocol::GitInfo; +use core_test_support::fs_wait; use core_test_support::skip_if_no_network; use std::time::Duration; -use std::time::Instant; use tempfile::TempDir; use uuid::Uuid; -use walkdir::WalkDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -211,12 +210,12 @@ async fn responses_api_stream_cli() { /// End-to-end: create a session (writes rollout), verify the file, then resume and confirm append. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn integration_creates_and_checks_session_file() { +async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { // Honor sandbox network restrictions for CI parity with the other tests. - skip_if_no_network!(); + skip_if_no_network!(Ok(())); // 1. Temp home so we read/write isolated session files. - let home = TempDir::new().unwrap(); + let home = TempDir::new()?; // 2. Unique marker we'll look for in the session log. let marker = format!("integration-test-{}", Uuid::new_v4()); @@ -254,63 +253,20 @@ async fn integration_creates_and_checks_session_file() { // Wait for sessions dir to appear. let sessions_dir = home.path().join("sessions"); - let dir_deadline = Instant::now() + Duration::from_secs(5); - while !sessions_dir.exists() && Instant::now() < dir_deadline { - std::thread::sleep(Duration::from_millis(50)); - } - assert!(sessions_dir.exists(), "sessions directory never appeared"); + fs_wait::wait_for_path_exists(&sessions_dir, Duration::from_secs(5)).await?; // Find the session file that contains `marker`. - let deadline = Instant::now() + Duration::from_secs(10); - let mut matching_path: Option = None; - while Instant::now() < deadline && matching_path.is_none() { - for entry in WalkDir::new(&sessions_dir) { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - if !entry.file_type().is_file() { - continue; - } - if !entry.file_name().to_string_lossy().ends_with(".jsonl") { - continue; - } - let path = entry.path(); - let Ok(content) = std::fs::read_to_string(path) else { - continue; - }; - let mut lines = content.lines(); - if lines.next().is_none() { - continue; - } - for line in lines { - if line.trim().is_empty() { - continue; - } - let item: serde_json::Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => continue, - }; - if item.get("type").and_then(|t| t.as_str()) == Some("response_item") - && let Some(payload) = item.get("payload") - && payload.get("type").and_then(|t| t.as_str()) == Some("message") - && let Some(c) = payload.get("content") - && c.to_string().contains(&marker) - { - matching_path = Some(path.to_path_buf()); - break; - } - } + let marker_clone = marker.clone(); + let path = fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| { + if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + return false; } - if matching_path.is_none() { - std::thread::sleep(Duration::from_millis(50)); - } - } - - let path = match matching_path { - Some(p) => p, - None => panic!("No session file containing the marker was found"), - }; + let Ok(content) = std::fs::read_to_string(p) else { + return false; + }; + content.contains(&marker_clone) + }) + .await?; // Basic sanity checks on location and metadata. let rel = match path.strip_prefix(&sessions_dir) { @@ -418,42 +374,25 @@ async fn integration_creates_and_checks_session_file() { assert!(output2.status.success(), "resume codex-cli run failed"); // Find the new session file containing the resumed marker. - let deadline = Instant::now() + Duration::from_secs(10); - let mut resumed_path: Option = None; - while Instant::now() < deadline && resumed_path.is_none() { - for entry in WalkDir::new(&sessions_dir) { - let entry = match entry { - Ok(e) => e, - Err(_) => continue, - }; - if !entry.file_type().is_file() { - continue; + let marker2_clone = marker2.clone(); + let resumed_path = + fs_wait::wait_for_matching_file(&sessions_dir, Duration::from_secs(10), move |p| { + if p.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + return false; } - if !entry.file_name().to_string_lossy().ends_with(".jsonl") { - continue; - } - let p = entry.path(); - let Ok(c) = std::fs::read_to_string(p) else { - continue; - }; - if c.contains(&marker2) { - resumed_path = Some(p.to_path_buf()); - break; - } - } - if resumed_path.is_none() { - std::thread::sleep(Duration::from_millis(50)); - } - } + std::fs::read_to_string(p) + .map(|content| content.contains(&marker2_clone)) + .unwrap_or(false) + }) + .await?; - let resumed_path = resumed_path.expect("No resumed session file found containing the marker2"); // Resume should write to the existing log file. assert_eq!( resumed_path, path, "resume should create a new session file" ); - let resumed_content = std::fs::read_to_string(&resumed_path).unwrap(); + let resumed_content = std::fs::read_to_string(&resumed_path)?; assert!( resumed_content.contains(&marker), "resumed file missing original marker" @@ -462,6 +401,7 @@ async fn integration_creates_and_checks_session_file() { resumed_content.contains(&marker2), "resumed file missing resumed marker" ); + Ok(()) } /// Integration test to verify git info is collected and recorded in session files. diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index eb14dabb81..7bc22449a8 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -657,6 +657,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), @@ -767,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() { .insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-secondary-window-minutes", "60") - .insert_header("x-codex-primary-reset-after-seconds", "1800") - .insert_header("x-codex-secondary-reset-after-seconds", "7200") + .insert_header("x-codex-primary-reset-at", "1704069000") + .insert_header("x-codex-secondary-reset-at", "1704074400") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -817,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": 1704074400 } } }) @@ -857,19 +858,19 @@ async fn token_count_includes_rate_limits_snapshot() { "reasoning_output_tokens": 0, "total_tokens": 123 }, - // Default model is gpt-5-codex in tests → 272000 context window - "model_context_window": 272000 + // Default model is gpt-5-codex in tests → 95% usable context window + "model_context_window": 258400 }, "rate_limits": { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": 1704069000 }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": 1704074400 } } }) @@ -892,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_in_seconds), - Some(1800) + .and_then(|window| window.resets_at), + Some(1704069000) ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -914,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_in_seconds": 42, + "resets_at": 1704067242, "plan_type": "pro" } })); @@ -934,12 +935,12 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "primary": { "used_percent": 100.0, "window_minutes": 15, - "resets_in_seconds": null + "resets_at": null }, "secondary": { "used_percent": 87.5, "window_minutes": 60, - "resets_in_seconds": null + "resets_at": null } }); @@ -984,6 +985,8 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res skip_if_no_network!(Ok(())); let server = MockServer::start().await; + const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; + responses::mount_sse_once_match( &server, body_string_contains("trigger context window"), @@ -1055,8 +1058,11 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res .info .expect("token usage info present when context window is exceeded"); - assert_eq!(info.model_context_window, Some(272_000)); - assert_eq!(info.total_token_usage.total_tokens, 272_000); + assert_eq!(info.model_context_window, Some(EFFECTIVE_CONTEXT_WINDOW)); + assert_eq!( + info.total_token_usage.total_tokens, + EFFECTIVE_CONTEXT_WINDOW + ); let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; let expected_context_window_message = CodexErr::ContextWindowExceeded.to_string(); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 82750e8503..bd760b8749 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -19,6 +19,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -43,6 +44,7 @@ const CONTEXT_LIMIT_MESSAGE: &str = "Your input exceeds the context window of this model. Please adjust your input and try again."; const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; +const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -860,3 +862,97 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "second auto compact request should include the summarization prompt" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let context_window = 100; + let limit = context_window * 90 / 100; + let over_limit_tokens = context_window * 95 / 100 + 1; + + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", 50), + ]); + let function_call_follow_up = sse(vec![ + ev_assistant_message("m2", FINAL_REPLY), + ev_completed_with_tokens("r2", over_limit_tokens), + ]); + let auto_compact_turn = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", 10), + ]); + let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); + + // Mount responses in order and keep mocks only for the ones we assert on. + let first_turn_mock = mount_sse_once(&server, first_turn).await; + let follow_up_mock = mount_sse_once(&server, function_call_follow_up).await; + let auto_compact_mock = mount_sse_once(&server, auto_compact_turn).await; + // We don't assert on the post-compact request, so no need to keep its mock. + mount_sse_once(&server, post_auto_compact_turn).await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider; + config.model_context_window = Some(context_window); + config.model_auto_compact_token_limit = Some(limit); + + let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) + .new_conversation(config) + .await + .unwrap() + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: FUNCTION_CALL_LIMIT_MSG.into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; + + // Assert first request captured expected user message that triggers function call. + let first_request = first_turn_mock.single_request().input(); + assert!( + first_request.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("message") + && item + .get("content") + .and_then(|content| content.as_array()) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("text")) + .and_then(|value| value.as_str()) + == Some(FUNCTION_CALL_LIMIT_MSG) + }), + "first request should include the user message that triggers the function call" + ); + + let function_call_output = follow_up_mock + .single_request() + .function_call_output(DUMMY_CALL_ID); + let output_text = function_call_output + .get("output") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + output_text.contains(DUMMY_FUNCTION_NAME), + "function call output should be sent before auto compact" + ); + + let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); + assert!( + auto_compact_body.contains("You have exceeded the maximum number of tokens"), + "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" + ); +} diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 2d1e99b0b7..ea5ab84879 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -36,6 +36,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result) -> Vec { - let mut out = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - stream: ExecOutputStream::Stdout, - chunk, - .. - }) = ev.msg - { - out.extend_from_slice(&chunk); - } - } - out -} - -#[tokio::test] -async fn test_exec_stdout_stream_events_echo() { - let (tx, rx) = async_channel::unbounded::(); - - let stdout_stream = StdoutStream { - sub_id: "test-sub".to_string(), - call_id: "call-1".to_string(), - tx_event: tx, - }; - - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - // Use printf for predictable behavior across shells - "printf 'hello-world\n'".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - Some(stdout_stream), - ) - .await; - - let result = match result { - Ok(r) => r, - Err(e) => panic!("process_exec_tool_call failed: {e}"), - }; - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, "hello-world\n"); - - let streamed = collect_stdout_events(rx); - // We should have received at least the same contents (possibly in one chunk) - assert_eq!(String::from_utf8_lossy(&streamed), "hello-world\n"); -} - -#[tokio::test] -async fn test_exec_stderr_stream_events_echo() { - let (tx, rx) = async_channel::unbounded::(); - - let stdout_stream = StdoutStream { - sub_id: "test-sub".to_string(), - call_id: "call-2".to_string(), - tx_event: tx, - }; - - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - // Write to stderr explicitly - "printf 'oops\n' 1>&2".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - Some(stdout_stream), - ) - .await; - - let result = match result { - Ok(r) => r, - Err(e) => panic!("process_exec_tool_call failed: {e}"), - }; - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, ""); - assert_eq!(result.stderr.text, "oops\n"); - - // Collect only stderr delta events - let mut err = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - stream: ExecOutputStream::Stderr, - chunk, - .. - }) = ev.msg - { - err.extend_from_slice(&chunk); - } - } - assert_eq!(String::from_utf8_lossy(&err), "oops\n"); -} - -#[tokio::test] -async fn test_aggregated_output_interleaves_in_order() { - // Spawn a shell that alternates stdout and stderr with sleeps to enforce order. - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(5_000), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - None, - ) - .await - .expect("process_exec_tool_call"); - - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.text, "O1\nO2\n"); - assert_eq!(result.stderr.text, "E1\nE2\n"); - assert_eq!(result.aggregated_output.text, "O1\nE1\nO2\nE2\n"); - assert_eq!(result.aggregated_output.truncated_after_lines, None); -} - -#[tokio::test] -async fn test_exec_timeout_returns_partial_output() { - let cmd = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf 'before\\n'; sleep 2; printf 'after\\n'".to_string(), - ]; - - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let params = ExecParams { - command: cmd, - cwd: cwd.clone(), - timeout_ms: Some(200), - env: HashMap::new(), - with_escalated_permissions: None, - justification: None, - }; - - let policy = SandboxPolicy::new_read_only_policy(); - - let result = process_exec_tool_call( - params, - SandboxType::None, - &policy, - cwd.as_path(), - &None, - None, - ) - .await; - - let Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) = result else { - panic!("expected timeout error"); - }; - - assert_eq!(output.exit_code, 124); - assert_eq!(output.stdout.text, "before\n"); - assert!(output.stderr.text.is_empty()); - assert_eq!(output.aggregated_output.text, "before\n"); - assert!(output.duration >= Duration::from_millis(200)); - assert!(output.timed_out); -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 6008811dea..440eb1e893 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -2,12 +2,13 @@ #[cfg(not(target_os = "windows"))] mod abort_tasks; +#[cfg(not(target_os = "windows"))] +mod approvals; mod cli_stream; mod client; mod compact; mod compact_resume_fork; mod exec; -mod exec_stream_events; mod fork_conversation; mod grep_files; mod json_result; diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index ee7b44d4b0..71e64f9e3a 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -4,6 +4,7 @@ use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -56,12 +57,12 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { config.model = model.to_string(); config.model_family = find_family_for_model(model).unwrap_or_else(|| panic!("unknown model family for {model}")); - config.include_plan_tool = false; - config.include_apply_patch_tool = false; - config.include_view_image_tool = false; - config.tools_web_search_request = false; - config.use_experimental_streamable_shell_tool = false; - config.use_experimental_unified_exec_tool = false; + config.features.disable(Feature::PlanTool); + config.features.disable(Feature::ApplyPatchFreeform); + config.features.disable(Feature::ViewImageTool); + config.features.disable(Feature::WebSearchRequest); + config.features.disable(Feature::StreamableShell); + config.features.disable(Feature::UnifiedExec); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); @@ -93,21 +94,37 @@ async fn model_selects_expected_tools() { let codex_tools = collect_tool_identifiers_for_model("codex-mini-latest").await; assert_eq!( codex_tools, - vec!["local_shell".to_string()], + vec![ + "local_shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "codex-mini-latest should expose the local shell tool", ); let o3_tools = collect_tool_identifiers_for_model("o3").await; assert_eq!( o3_tools, - vec!["shell".to_string()], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "o3 should expose the generic shell tool", ); let gpt5_codex_tools = collect_tool_identifiers_for_model("gpt-5-codex").await; assert_eq!( gpt5_codex_tools, - vec!["shell".to_string(), "apply_patch".to_string(),], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string(), + "apply_patch".to_string() + ], "gpt-5-codex should expose the apply_patch tool", ); } diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 2e7fe31794..63ad3ccaea 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -815,11 +815,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call( - "user_approved_call", - "completed", - vec!["/bin/echo", "approved"], - ), + ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -881,11 +877,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse( &server, sse(vec![ - ev_local_shell_call( - "user_approved_session_call", - "completed", - vec!["/bin/echo", "persist"], - ), + ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -947,11 +939,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call( - "sandbox_retry_call", - "completed", - vec!["/bin/echo", "retry"], - ), + ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1013,7 +1001,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call("user_denied_call", "completed", vec!["/bin/echo", "deny"]), + ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1075,11 +1063,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse( &server, sse(vec![ - ev_local_shell_call( - "sandbox_session_call", - "completed", - vec!["/bin/echo", "persist"], - ), + ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) @@ -1141,7 +1125,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse( &server, sse(vec![ - ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/echo", "deny"]), + ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9ca0cc9369..caa4cb68de 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -5,6 +5,7 @@ use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::config::OPENAI_DEFAULT_MODEL; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -99,10 +100,10 @@ async fn codex_mini_latest_tools() { config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.disable(Feature::ApplyPatchFreeform); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); - config.include_apply_patch_tool = false; config.model = "codex-mini-latest".to_string(); config.model_family = find_family_for_model("codex-mini-latest").unwrap(); @@ -185,7 +186,7 @@ async fn prompt_tools_are_consistent_across_requests() { config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; config.user_instructions = Some("be consistent and helpful".to_string()); - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); @@ -222,10 +223,28 @@ async fn prompt_tools_are_consistent_across_requests() { // our internal implementation is responsible for keeping tools in sync // with the OpenAI schema, so we just verify the tool presence here let tools_by_model: HashMap<&'static str, Vec<&'static str>> = HashMap::from([ - ("gpt-5", vec!["shell", "update_plan", "view_image"]), + ( + "gpt-5", + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ), ( "gpt-5-codex", - vec!["shell", "update_plan", "apply_patch", "view_image"], + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "apply_patch", + "view_image", + ], ), ]); let expected_tools_names = tools_by_model @@ -488,7 +507,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { {} "#, - writable.path().to_string_lossy() + writable.path().to_string_lossy(), ); let expected_env_msg_2 = serde_json::json!({ "type": "message", @@ -847,15 +866,14 @@ async fn send_user_turn_with_changes_sends_environment_context() { ]); assert_eq!(body1["input"], expected_input_1); - let expected_env_msg_2 = text_user_input(format!( + let expected_env_msg_2 = text_user_input( r#" - {} never danger-full-access enabled -"#, - default_cwd.to_string_lossy() - )); +"# + .to_string(), + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_ui_msg, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index a8a5d3afbf..ab702b5d91 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ffi::OsStr; use std::ffi::OsString; use std::fs; use std::net::TcpListener; @@ -9,6 +10,7 @@ use std::time::UNIX_EPOCH; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -34,6 +36,7 @@ use tokio::time::sleep; use wiremock::matchers::any; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] async fn stdio_server_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -41,7 +44,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let call_id = "call-123"; let server_name = "rmcp"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -74,7 +77,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { @@ -85,7 +88,10 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { "MCP_TEST_VALUE".to_string(), expected_env_value.to_string(), )])), + env_vars: Vec::new(), + cwd: None, }, + enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, }, @@ -104,7 +110,143 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), + Duration::from_secs(10), + ) + .await; + + let EventMsg::McpToolCallBegin(begin) = begin_event else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.invocation.server, server_name); + assert_eq!(begin.invocation.tool, "echo"); + + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + + let result = end + .result + .as_ref() + .expect("rmcp echo tool should return success"); + assert_eq!(result.is_error, Some(false)); + assert!( + result.content.is_empty(), + "content should default to an empty array" + ); + + let structured = result + .structured_content + .as_ref() + .expect("structured content"); + let Value::Object(map) = structured else { + panic!("structured content should be an object: {structured:?}"); + }; + let echo_value = map + .get("echo") + .and_then(Value::as_str) + .expect("echo payload present"); + assert_eq!(echo_value, "ECHOING: ping"); + let env_value = map + .get("env") + .and_then(Value::as_str) + .expect("env snapshot inserted"); + assert_eq!(env_value, expected_env_value); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + server.verify().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] +async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "call-1234"; + let server_name = "rmcp_whitelist"; + let tool_name = format!("mcp__{server_name}__echo"); + + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let expected_env_value = "propagated-env-from-whitelist"; + let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value)); + let rmcp_test_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + let fixture = test_codex() + .with_config(move |config| { + config.features.enable(Feature::RmcpClient); + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: vec!["MCP_TEST_VALUE".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + }, + ); + }) + .build(&server) + .await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "call the rmcp echo tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -175,7 +317,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let call_id = "call-456"; let server_name = "rmcp_http"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -226,14 +368,17 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, + enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, }, @@ -252,7 +397,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -340,7 +485,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let call_id = "call-789"; let server_name = "rmcp_http_oauth"; - let tool_name = format!("{server_name}__echo"); + let tool_name = format!("mcp__{server_name}__echo"); mount_sse_once_match( &server, @@ -406,14 +551,17 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, + enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, }, @@ -432,7 +580,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 67fc2786ca..21141ec535 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use anyhow::Result; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -9,9 +10,12 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use core_test_support::assert_regex_match; +use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -20,8 +24,11 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use regex_lite::Regex; use serde_json::Value; use serde_json::json; +use std::fs; async fn submit_turn(test: &TestCodex, prompt: &str, sandbox_policy: SandboxPolicy) -> Result<()> { let session_model = test.session_configured.model.clone(); @@ -71,13 +78,28 @@ fn find_function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> Option<& None } +fn find_custom_tool_call_output<'a>(bodies: &'a [Value], call_id: &str) -> Option<&'a Value> { + for body in bodies { + if let Some(items) = body.get("input").and_then(Value::as_array) { + for item in items { + if item.get("type").and_then(Value::as_str) == Some("custom_tool_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + { + return Some(item); + } + } + } + } + None +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_output_stays_json_without_freeform_apply_patch() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = false; + config.features.disable(Feature::ApplyPatchFreeform); config.model = "gpt-5".to_string(); config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is a model family"); }); @@ -119,7 +141,12 @@ async fn shell_output_stays_json_without_freeform_apply_patch() -> Result<()> { .and_then(Value::as_str) .expect("shell output string"); - let parsed: Value = serde_json::from_str(output)?; + let mut parsed: Value = serde_json::from_str(output)?; + if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { + // duration_seconds is non-deterministic; remove it for deep equality + let _ = metadata.remove("duration_seconds"); + } + assert_eq!( parsed .get("metadata") @@ -143,7 +170,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let test = builder.build(&server).await?; @@ -198,6 +225,83 @@ freeform shell Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_output_for_freeform_tool_records_duration() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + #[cfg(target_os = "linux")] + let sleep_cmd = vec!["/bin/bash", "-c", "sleep 1"]; + + #[cfg(target_os = "macos")] + let sleep_cmd = vec!["/bin/bash", "-c", "sleep 1"]; + + #[cfg(windows)] + let sleep_cmd = "timeout 1"; + + let call_id = "shell-structured"; + let args = json!({ + "command": sleep_cmd, + "timeout_ms": 2_000, + }); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "run the structured shell command", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_function_call_output(&bodies, call_id).expect("structured output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("structured output string"); + + let expected_pattern = r#"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +$"#; + assert_regex_match(expected_pattern, output); + + let wall_time_regex = Regex::new(r"(?m)^Wall (?:time|Clock): ([0-9]+(?:\.[0-9]+)?) seconds$") + .expect("compile wall time regex"); + let wall_time_seconds = wall_time_regex + .captures(output) + .and_then(|caps| caps.get(1)) + .and_then(|value| value.as_str().parse::().ok()) + .expect("expected structured shell output to contain wall time seconds"); + assert!( + wall_time_seconds > 0.5, + "expected wall time to be greater than zero seconds, got {wall_time_seconds}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_output_reserializes_truncated_content() -> Result<()> { skip_if_no_network!(Ok(())); @@ -213,7 +317,7 @@ async fn shell_output_reserializes_truncated_content() -> Result<()> { let call_id = "shell-truncated"; let args = json!({ "command": ["/bin/sh", "-c", "seq 1 400"], - "timeout_ms": 1_000, + "timeout_ms": 5_000, }); let responses = vec![ sse(vec![ @@ -275,3 +379,428 @@ $"#; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_output_is_structured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "apply-patch-structured"; + let file_name = "structured.txt"; + let patch = format!( + r#"*** Begin Patch +*** Add File: {file_name} ++from custom tool +*** End Patch +"# + ); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_custom_tool_call(call_id, "apply_patch", &patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "apply the patch via custom tool", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_custom_tool_call_output(&bodies, call_id).expect("apply_patch output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("apply_patch output string"); + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_creates_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "apply-patch-add-file"; + let file_name = "custom_tool_apply_patch.txt"; + let patch = format!( + "*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n" + ); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_custom_tool_call(call_id, "apply_patch", &patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "apply_patch done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "apply the patch via custom tool to create a file", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_custom_tool_call_output(&bodies, call_id).expect("apply_patch output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("apply_patch output string"); + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output); + + let new_file_path = test.cwd.path().join(file_name); + let created_contents = fs::read_to_string(&new_file_path)?; + assert_eq!( + created_contents, "custom tool content\n", + "expected file contents for {file_name}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "apply-patch-update-file"; + let file_name = "custom_tool_apply_patch_existing.txt"; + let file_path = test.cwd.path().join(file_name); + fs::write(&file_path, "before\n")?; + let patch = format!( + "*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n" + ); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_custom_tool_call(call_id, "apply_patch", &patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "apply_patch update done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "apply the patch via custom tool to update a file", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_custom_tool_call_output(&bodies, call_id).expect("apply_patch output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("apply_patch output string"); + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +M {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output); + + let updated_contents = fs::read_to_string(file_path)?; + assert_eq!(updated_contents, "after\n", "expected updated file content"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "apply-patch-failure"; + let missing_file = "missing_custom_tool_apply_patch.txt"; + let patch = format!( + "*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n" + ); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_custom_tool_call(call_id, "apply_patch", &patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "apply_patch failure done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "attempt a failing apply_patch via custom tool", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_custom_tool_call_output(&bodies, call_id).expect("apply_patch output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("apply_patch output string"); + + let expected_output = format!( + "apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)", + test.cwd.path().to_string_lossy() + ); + assert_eq!(output, expected_output); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_function_call_output_is_structured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "apply-patch-function"; + let file_name = "function_apply_patch.txt"; + let patch = + format!("*** Begin Patch\n*** Add File: {file_name}\n+via function call\n*** End Patch\n"); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_apply_patch_function_call(call_id, &patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "apply_patch function done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "apply the patch via function-call apply_patch", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_function_call_output(&bodies, call_id).expect("apply_patch function output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("apply_patch output string"); + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_output_is_structured_for_nonzero_exit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = "gpt-5-codex".to_string(); + config.model_family = + find_family_for_model("gpt-5-codex").expect("gpt-5-codex is a model family"); + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "shell-nonzero-exit"; + let args = json!({ + "command": ["/bin/sh", "-c", "exit 42"], + "timeout_ms": 1_000, + }); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "shell failure handled"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "run the failing shell command", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = find_function_call_output(&bodies, call_id).expect("shell output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell output string"); + + let expected_pattern = r"(?s)^Exit code: 42 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +?$"; + assert_regex_match(expected_pattern, output); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_shell_call_output_is_structured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = "gpt-5-codex".to_string(); + config.model_family = + find_family_for_model("gpt-5-codex").expect("gpt-5-codex is a model family"); + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + let call_id = "local-shell-call"; + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_local_shell_call(call_id, "completed", vec!["/bin/echo", "local shell"]), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "run the local shell command", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_function_call_output(&bodies, call_id).expect("local shell output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("local shell output string"); + + let expected_pattern = r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +local shell +?$"; + assert_regex_match(expected_pattern, output); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index eaefe7d9dc..68bd76bd8e 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,6 +1,9 @@ #![cfg(not(target_os = "windows"))] +use std::fs; + use assert_matches::assert_matches; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -104,7 +107,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); }); let TestCodex { codex, @@ -191,7 +194,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); }); let TestCodex { codex, @@ -285,7 +288,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let TestCodex { codex, @@ -294,15 +297,19 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() .. } = builder.build(&server).await?; + let file_name = "notes.txt"; + let file_path = cwd.path().join(file_name); let call_id = "apply-patch-call"; - let patch_content = r#"*** Begin Patch -*** Add File: notes.txt + let patch_content = format!( + r#"*** Begin Patch +*** Add File: {file_name} +Tool harness apply patch -*** End Patch"#; +*** End Patch"# + ); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, patch_content), + ev_apply_patch_function_call(call_id, &patch_content), ev_completed("resp-1"), ]); responses::mount_sse_once_match(&server, any(), first_response).await; @@ -351,6 +358,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() assert!(saw_patch_begin, "expected PatchApplyBegin event"); let patch_end_success = patch_end_success.expect("expected PatchApplyEnd event to capture success flag"); + assert!(patch_end_success); let req = second_mock.single_request(); let output_item = req.function_call_output(call_id); @@ -360,38 +368,21 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() ); let output_text = extract_output_text(&output_item).expect("output text present"); - if let Ok(exec_output) = serde_json::from_str::(output_text) { - let exit_code = exec_output["metadata"]["exit_code"] - .as_i64() - .expect("exit_code present"); - let summary = exec_output["output"].as_str().expect("output field"); - assert_eq!( - exit_code, 0, - "expected apply_patch exit_code=0, got {exit_code}, summary: {summary:?}" - ); - assert!( - patch_end_success, - "expected PatchApplyEnd success flag, summary: {summary:?}" - ); - assert!( - summary.contains("Success."), - "expected apply_patch summary to note success, got {summary:?}" - ); + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output_text); - let patched_path = cwd.path().join("notes.txt"); - let contents = std::fs::read_to_string(&patched_path) - .unwrap_or_else(|e| panic!("failed reading {}: {e}", patched_path.display())); - assert_eq!(contents, "Tool harness apply patch\n"); - } else { - assert!( - output_text.contains("codex-run-as-apply-patch"), - "expected apply_patch failure message to mention codex-run-as-apply-patch, got {output_text:?}" - ); - assert!( - !patch_end_success, - "expected PatchApplyEnd to report success=false when apply_patch invocation fails" - ); - } + let updated_contents = fs::read_to_string(file_path)?; + assert_eq!( + updated_contents, "Tool harness apply patch\n", + "expected updated file content" + ); Ok(()) } @@ -403,7 +394,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 99c70dbd61..ec07b0cdbd 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -2,6 +2,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -293,7 +294,11 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { let mock = mount_sse_sequence(&server, responses).await; let mut builder = test_codex().with_config(move |config| { - config.use_experimental_unified_exec_tool = use_unified_exec; + if use_unified_exec { + config.features.enable(Feature::UnifiedExec); + } else { + config.features.disable(Feature::UnifiedExec); + } }); let test = builder.build(&server).await?; @@ -403,79 +408,6 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_sandbox_denied_truncates_error_output() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex(); - let test = builder.build(&server).await?; - - let call_id = "shell-denied"; - let long_line = "this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz"; - let script = format!( - "for i in $(seq 1 500); do >&2 echo '{long_line}'; done; cat <<'EOF' > denied.txt\ncontent\nEOF", - ); - let args = json!({ - "command": ["/bin/sh", "-c", script], - "timeout_ms": 1_000, - }); - - mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - ) - .await; - let second_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - submit_turn( - &test, - "attempt to write in read-only sandbox", - AskForApproval::Never, - SandboxPolicy::ReadOnly, - ) - .await?; - - let denied_item = second_mock.single_request().function_call_output(call_id); - - let output = denied_item - .get("output") - .and_then(Value::as_str) - .expect("denied output string"); - - let sandbox_pattern = r#"(?s)^Exit code: -?\d+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: \d+ -Output: - -failed in sandbox: .*?(?:Operation not permitted|Permission denied|Read-only file system).*? -\[\.{3} omitted \d+ of \d+ lines \.{3}\] -.*this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz.* -\n?$"#; - let sandbox_regex = Regex::new(sandbox_pattern)?; - if !sandbox_regex.is_match(output) { - let fallback_pattern = r#"(?s)^Total output lines: \d+ - -failed in sandbox: this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz -.*this is a long stderr line that should trigger truncation 0123456789abcdefghijklmnopqrstuvwxyz.* -.*(?:Operation not permitted|Permission denied|Read-only file system).*$"#; - assert_regex_match(fallback_pattern, output); - } - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5afcc9f5fa..6298ab06de 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::Result; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -42,7 +43,13 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result> { if let Some(call_id) = item.get("call_id").and_then(Value::as_str) { let content = extract_output_text(item) .ok_or_else(|| anyhow::anyhow!("missing tool output content"))?; - let parsed: Value = serde_json::from_str(content)?; + let trimmed = content.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = serde_json::from_str(trimmed).map_err(|err| { + anyhow::anyhow!("failed to parse tool output content {trimmed:?}: {err}") + })?; outputs.insert(call_id.to_string(), parsed); } } @@ -59,7 +66,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex, @@ -168,7 +175,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_timeout_and_followup_poll() -> Result<()> { +async fn unified_exec_streams_after_lagged_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -176,6 +183,132 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let script = r#"python3 - <<'PY' +import sys +import time + +chunk = b'x' * (1 << 20) +for _ in range(4): + sys.stdout.buffer.write(chunk) + sys.stdout.flush() + +time.sleep(0.2) +for _ in range(5): + sys.stdout.write("TAIL-MARKER\n") + sys.stdout.flush() + time.sleep(0.05) + +time.sleep(0.2) +PY +"#; + + let first_call_id = "uexec-lag-start"; + let first_args = serde_json::json!({ + "input": ["/bin/sh", "-c", script], + "timeout_ms": 25, + }); + + let second_call_id = "uexec-lag-poll"; + let second_args = serde_json::json!({ + "input": Vec::::new(), + "session_id": "0", + "timeout_ms": 2_000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + first_call_id, + "unified_exec", + &serde_json::to_string(&first_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + second_call_id, + "unified_exec", + &serde_json::to_string(&second_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "lag handled"), + ev_completed("resp-3"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "exercise lag handling".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + + let start_output = outputs + .get(first_call_id) + .expect("missing initial unified_exec output"); + let session_id = start_output["session_id"].as_str().unwrap_or_default(); + assert!( + !session_id.is_empty(), + "expected session id from initial unified_exec response" + ); + + let poll_output = outputs + .get(second_call_id) + .expect("missing poll unified_exec output"); + let poll_text = poll_output["output"].as_str().unwrap_or_default(); + assert!( + poll_text.contains("TAIL-MARKER"), + "expected poll output to contain tail marker, got {poll_text:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_timeout_and_followup_poll() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 2ad87c7b6c..3390f4a65a 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -5,6 +5,7 @@ use std::os::unix::fs::PermissionsExt; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -17,8 +18,7 @@ use responses::ev_assistant_message; use responses::ev_completed; use responses::sse; use responses::start_mock_server; -use tokio::time::Duration; -use tokio::time::sleep; +use std::time::Duration; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<()> { @@ -60,14 +60,7 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#, wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // We fork the notify script, so we need to wait for it to write to the file. - for _ in 0..100u32 { - if notify_file.exists() { - break; - } - sleep(Duration::from_millis(100)).await; - } - - assert!(notify_file.exists()); + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; Ok(()) } diff --git a/codex-rs/default.nix b/codex-rs/default.nix index d2d4dfb6ba..3d598a9c2b 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -1,42 +1,31 @@ -{ pkgs, monorep-deps ? [], ... }: -let +{ + openssl, + rustPlatform, + pkg-config, + lib, + ... +}: +rustPlatform.buildRustPackage (finalAttrs: { env = { - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; + PKG_CONFIG_PATH = "${openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; }; -in -rec { - package = pkgs.rustPlatform.buildRustPackage { - inherit env; - pname = "codex-rs"; - version = "0.1.0"; - cargoLock.lockFile = ./Cargo.lock; - doCheck = false; - src = ./.; - nativeBuildInputs = with pkgs; [ - pkg-config - openssl - ]; - meta = with pkgs.lib; { - description = "OpenAI Codex command‑line interface rust implementation"; - license = licenses.asl20; - homepage = "https://github.com/openai/codex"; - }; + pname = "codex-rs"; + version = "0.1.0"; + cargoLock.lockFile = ./Cargo.lock; + doCheck = false; + src = ./.; + nativeBuildInputs = [ + pkg-config + openssl + ]; + + cargoLock.outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; }; - devShell = pkgs.mkShell { - inherit env; - name = "codex-rs-dev"; - packages = monorep-deps ++ [ - pkgs.cargo - package - ]; - shellHook = '' - echo "Entering development shell for codex-rs" - alias codex="cd ${package.src}/tui; cargo run; cd -" - ${pkgs.rustPlatform.cargoSetupHook} - ''; + + meta = with lib; { + description = "OpenAI Codex command‑line interface rust implementation"; + license = licenses.asl20; + homepage = "https://github.com/openai/codex"; }; - app = { - type = "app"; - program = "${package}/bin/codex"; - }; -} +}) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index d264eb61b0..67c072e021 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -68,8 +68,8 @@ pub struct Cli { pub json: bool, /// Whether to include the plan tool in the conversation. - #[arg(long = "include-plan-tool", default_value_t = false)] - pub include_plan_tool: bool, + #[arg(long = "include-plan-tool")] + pub include_plan_tool: Option, /// Specifies file where the last message from the agent should be written. #[arg(long = "output-last-message", short = 'o', value_name = "FILE")] diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 606cf9187a..032b912f86 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -57,11 +57,11 @@ pub struct TurnFailedEvent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)] pub struct Usage { /// The number of input tokens used during the turn. - pub input_tokens: u64, + pub input_tokens: i64, /// The number of cached input tokens used during the turn. - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, /// The number of output tokens used during the turn. - pub output_tokens: u64, + pub output_tokens: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 967da52b8a..afd9d44a73 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,8 +15,10 @@ use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::features::Feature; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; @@ -72,6 +74,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config_overrides, } = cli; + if include_plan_tool.is_some() { + eprintln!("include-plan-tool is deprecated. Plan tool is now enabled by default."); + } + // Determine the prompt source (parent or subcommand) and read from stdin if needed. let prompt_arg = match &command { // Allow prompt before the subcommand by falling back to the parent-level prompt @@ -168,19 +174,19 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model, review_model: None, config_profile, - // This CLI is intended to be headless and has no affordances for asking - // the user for approval. + // Default to never ask for approvals in headless mode. Feature flags can override. approval_policy: Some(AskForApproval::Never), sandbox_mode, cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), model_provider, codex_linux_sandbox_exe, base_instructions: None, - include_plan_tool: Some(include_plan_tool), + include_plan_tool: Some(include_plan_tool.unwrap_or(true)), include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { @@ -192,6 +198,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any }; let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; + let approve_all_enabled = config.features.enabled(Feature::ApproveAll); + + if let Err(err) = enforce_login_restrictions(&config).await { + eprintln!("{err}"); + std::process::exit(1); + } let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); @@ -360,6 +372,34 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any if matches!(event.msg, EventMsg::Error(_)) { error_seen = true; } + // Auto-approve requests when the approve_all feature is enabled. + if approve_all_enabled { + match &event.msg { + EventMsg::ExecApprovalRequest(_) => { + if let Err(e) = conversation + .submit(Op::ExecApproval { + id: event.id.clone(), + decision: codex_core::protocol::ReviewDecision::Approved, + }) + .await + { + error!("failed to auto-approve exec: {e}"); + } + } + EventMsg::ApplyPatchApprovalRequest(_) => { + if let Err(e) = conversation + .submit(Op::PatchApproval { + id: event.id.clone(), + decision: codex_core::protocol::ReviewDecision::Approved, + }) + .await + { + error!("failed to auto-approve patch: {e}"); + } + } + _ => {} + } + } let shutdown: CodexStatus = event_processor.process_event(event); match shutdown { CodexStatus::Running => continue, diff --git a/codex-rs/exec/tests/suite/approve_all.rs b/codex-rs/exec/tests/suite/approve_all.rs new file mode 100644 index 0000000000..ab5b407d9f --- /dev/null +++ b/codex-rs/exec/tests/suite/approve_all.rs @@ -0,0 +1,81 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use anyhow::Result; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex_exec::test_codex_exec; +use serde_json::Value; +use serde_json::json; + +async fn run_exec_with_args(args: &[&str]) -> Result { + let test = test_codex_exec(); + + let call_id = "exec-approve"; + let exec_args = json!({ + "command": [ + if cfg!(windows) { "cmd.exe" } else { "/bin/sh" }, + if cfg!(windows) { "/C" } else { "-lc" }, + "echo approve-all-ok", + ], + "timeout_ms": 1500, + "with_escalated_permissions": true + }); + + let response_streams = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell", &serde_json::to_string(&exec_args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + + let server = responses::start_mock_server().await; + let mock = mount_sse_sequence(&server, response_streams).await; + + test.cmd_with_server(&server).args(args).assert().success(); + + let requests = mock.requests(); + assert!(requests.len() >= 2, "expected at least two responses POSTs"); + let item = requests[1].function_call_output(call_id); + let output_str = item + .get("output") + .and_then(Value::as_str) + .expect("function_call_output.output should be a string"); + + Ok(output_str.to_string()) +} + +/// Setting `features.approve_all=true` should switch to auto-approvals. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approve_all_auto_accepts_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + + let output = run_exec_with_args(&[ + "--skip-git-repo-check", + "-c", + "features.approve_all=true", + "train", + ]) + .await?; + assert!( + output.contains("Exit code: 0"), + "expected Exit code: 0 in output: {output}" + ); + assert!( + output.contains("approve-all-ok"), + "expected command output in response: {output}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 052c43bf38..3e1e39d699 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod apply_patch; +mod approve_all; mod auth_env; mod originator; mod output_schema; diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml new file mode 100644 index 0000000000..b104f51225 --- /dev/null +++ b/codex-rs/feedback/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition.workspace = true +name = "codex-feedback" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-protocol = { workspace = true } +sentry = { version = "0.34" } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs new file mode 100644 index 0000000000..285ab0661d --- /dev/null +++ b/codex-rs/feedback/src/lib.rs @@ -0,0 +1,231 @@ +use std::collections::VecDeque; +use std::fs; +use std::io::Write; +use std::io::{self}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::ConversationId; +use tracing_subscriber::fmt::writer::MakeWriter; + +const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB +const SENTRY_DSN: &str = + "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; +const UPLOAD_TIMEOUT_SECS: u64 = 10; + +#[derive(Clone)] +pub struct CodexFeedback { + inner: Arc, +} + +impl Default for CodexFeedback { + fn default() -> Self { + Self::new() + } +} + +impl CodexFeedback { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_BYTES) + } + + pub(crate) fn with_capacity(max_bytes: usize) -> Self { + Self { + inner: Arc::new(FeedbackInner::new(max_bytes)), + } + } + + pub fn make_writer(&self) -> FeedbackMakeWriter { + FeedbackMakeWriter { + inner: self.inner.clone(), + } + } + + pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot { + let bytes = { + let guard = self.inner.ring.lock().expect("mutex poisoned"); + guard.snapshot_bytes() + }; + CodexLogSnapshot { + bytes, + thread_id: session_id + .map(|id| id.to_string()) + .unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()), + } + } +} + +struct FeedbackInner { + ring: Mutex, +} + +impl FeedbackInner { + fn new(max_bytes: usize) -> Self { + Self { + ring: Mutex::new(RingBuffer::new(max_bytes)), + } + } +} + +#[derive(Clone)] +pub struct FeedbackMakeWriter { + inner: Arc, +} + +impl<'a> MakeWriter<'a> for FeedbackMakeWriter { + type Writer = FeedbackWriter; + + fn make_writer(&'a self) -> Self::Writer { + FeedbackWriter { + inner: self.inner.clone(), + } + } +} + +pub struct FeedbackWriter { + inner: Arc, +} + +impl Write for FeedbackWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?; + guard.push_bytes(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct RingBuffer { + max: usize, + buf: VecDeque, +} + +impl RingBuffer { + fn new(capacity: usize) -> Self { + Self { + max: capacity, + buf: VecDeque::with_capacity(capacity), + } + } + + fn len(&self) -> usize { + self.buf.len() + } + + fn push_bytes(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // If the incoming chunk is larger than capacity, keep only the trailing bytes. + if data.len() >= self.max { + self.buf.clear(); + let start = data.len() - self.max; + self.buf.extend(data[start..].iter().copied()); + return; + } + + // Evict from the front if we would exceed capacity. + let needed = self.len() + data.len(); + if needed > self.max { + let to_drop = needed - self.max; + for _ in 0..to_drop { + let _ = self.buf.pop_front(); + } + } + + self.buf.extend(data.iter().copied()); + } + + fn snapshot_bytes(&self) -> Vec { + self.buf.iter().copied().collect() + } +} + +pub struct CodexLogSnapshot { + bytes: Vec, + pub thread_id: String, +} + +impl CodexLogSnapshot { + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn save_to_temp_file(&self) -> io::Result { + let dir = std::env::temp_dir(); + let filename = format!("codex-feedback-{}.log", self.thread_id); + let path = dir.join(filename); + fs::write(&path, self.as_bytes())?; + Ok(path) + } + + pub fn upload_to_sentry(&self) -> Result<()> { + use std::collections::BTreeMap; + use std::str::FromStr; + use std::sync::Arc; + + use sentry::Client; + use sentry::ClientOptions; + use sentry::protocol::Attachment; + use sentry::protocol::Envelope; + use sentry::protocol::EnvelopeItem; + use sentry::protocol::Event; + use sentry::protocol::Level; + use sentry::transports::DefaultTransportFactory; + use sentry::types::Dsn; + + let client = Client::from_config(ClientOptions { + dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?), + transport: Some(Arc::new(DefaultTransportFactory {})), + ..Default::default() + }); + + let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]); + + let event = Event { + level: Level::Error, + message: Some("Codex Log Upload ".to_string() + &self.thread_id), + tags, + ..Default::default() + }; + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Event(event)); + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: self.bytes.clone(), + filename: String::from("codex-logs.log"), + content_type: Some("text/plain".to_string()), + ty: None, + })); + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_buffer_drops_front_when_full() { + let fb = CodexFeedback::with_capacity(8); + { + let mut w = fb.make_writer().make_writer(); + w.write_all(b"abcdefgh").unwrap(); + w.write_all(b"ij").unwrap(); + } + let snap = fb.snapshot(None); + // Capacity 8: after writing 10 bytes, we should keep the last 8. + pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij"); + } +} diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index ac6ac445e4..c4b0767fb6 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -8,8 +8,10 @@ use crate::landlock::apply_sandbox_policy_to_current_thread; pub struct LandlockCommand { /// It is possible that the cwd used in the context of the sandbox policy /// is different from the cwd of the process to spawn. + #[arg(long = "sandbox-policy-cwd")] pub sandbox_policy_cwd: PathBuf, + #[arg(long = "sandbox-policy")] pub sandbox_policy: codex_core::protocol::SandboxPolicy, /// Full command args to run under landlock. diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 6af59ef0fa..a1a3758307 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -44,6 +44,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, + arg0: None, }; let sandbox_policy = SandboxPolicy::WorkspaceWrite { @@ -146,6 +147,7 @@ async fn assert_network_blocked(cmd: &[&str]) { env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, + arg0: None, }; let sandbox_policy = SandboxPolicy::new_read_only_policy(); diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 35828994c9..f1999a8303 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -186,6 +186,13 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; + if let Err(message) = crate::server::ensure_workspace_allowed( + opts.forced_chatgpt_workspace_id.as_deref(), + &tokens.id_token, + ) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, message)); + } + crate::server::persist_tokens_async( &opts.codex_home, None, diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 7808246a58..e0f508e8d4 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -38,10 +38,15 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, + pub forced_chatgpt_workspace_id: Option, } impl ServerOptions { - pub fn new(codex_home: PathBuf, client_id: String) -> Self { + pub fn new( + codex_home: PathBuf, + client_id: String, + forced_chatgpt_workspace_id: Option, + ) -> Self { Self { codex_home, client_id, @@ -49,6 +54,7 @@ impl ServerOptions { port: DEFAULT_PORT, open_browser: true, force_state: None, + forced_chatgpt_workspace_id, } } } @@ -104,7 +110,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let server = Arc::new(server); let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); - let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state); + let auth_url = build_authorize_url( + &opts.issuer, + &opts.client_id, + &redirect_uri, + &pkce, + &state, + opts.forced_chatgpt_workspace_id.as_deref(), + ); if opts.open_browser { let _ = webbrowser::open(&auth_url); @@ -240,6 +253,13 @@ async fn process_request( .await { Ok(tokens) => { + if let Err(message) = ensure_workspace_allowed( + opts.forced_chatgpt_workspace_id.as_deref(), + &tokens.id_token, + ) { + eprintln!("Workspace restriction error: {message}"); + return login_error_response(&message); + } // Obtain API key via token-exchange and persist let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) .await @@ -358,22 +378,35 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, + forced_chatgpt_workspace_id: Option<&str>, ) -> String { - let query = vec![ - ("response_type", "code"), - ("client_id", client_id), - ("redirect_uri", redirect_uri), - ("scope", "openid profile email offline_access"), - ("code_challenge", &pkce.code_challenge), - ("code_challenge_method", "S256"), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("state", state), - ("originator", originator().value.as_str()), + let mut query = vec![ + ("response_type".to_string(), "code".to_string()), + ("client_id".to_string(), client_id.to_string()), + ("redirect_uri".to_string(), redirect_uri.to_string()), + ( + "scope".to_string(), + "openid profile email offline_access".to_string(), + ), + ( + "code_challenge".to_string(), + pkce.code_challenge.to_string(), + ), + ("code_challenge_method".to_string(), "S256".to_string()), + ("id_token_add_organizations".to_string(), "true".to_string()), + ("codex_cli_simplified_flow".to_string(), "true".to_string()), + ("state".to_string(), state.to_string()), + ( + "originator".to_string(), + originator().value.as_str().to_string(), + ), ]; + if let Some(workspace_id) = forced_chatgpt_workspace_id { + query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + } let qs = query .into_iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .map(|(k, v)| format!("{k}={}", urlencoding::encode(&v))) .collect::>() .join("&"); format!("{issuer}/oauth/authorize?{qs}") @@ -616,6 +649,43 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { serde_json::Map::new() } +pub(crate) fn ensure_workspace_allowed( + expected: Option<&str>, + id_token: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + + let claims = jwt_auth_claims(id_token); + let Some(actual) = claims.get("chatgpt_account_id").and_then(JsonValue::as_str) else { + return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); + }; + + if actual == expected { + Ok(()) + } else { + Err(format!("Login is restricted to workspace id {expected}.")) + } +} + +// Respond to the oauth server with an error so the code becomes unusable by anybody else. +fn login_error_response(message: &str) -> HandledRequest { + let mut headers = Vec::new(); + if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..]) + { + headers.push(header); + } + HandledRequest::ResponseAndExit { + headers, + body: message.as_bytes().to_vec(), + result: Err(io::Error::new( + io::ErrorKind::PermissionDenied, + message.to_string(), + )), + } +} + pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 4bd5e2e36b..416921bfc5 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -97,7 +97,11 @@ async fn mock_oauth_token_single(server: &MockServer, jwt: String) { } fn server_opts(codex_home: &tempfile::TempDir, issuer: String) -> ServerOptions { - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; opts @@ -139,6 +143,42 @@ async fn device_code_login_integration_succeeds() { assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); } +#[tokio::test] +async fn device_code_login_rejects_workspace_mismatch() { + skip_if_no_network!(); + + let codex_home = tempdir().unwrap(); + let mock_server = MockServer::start().await; + + mock_usercode_success(&mock_server).await; + + mock_poll_token_two_step(&mock_server, Arc::new(AtomicUsize::new(0)), 404).await; + + let jwt = make_jwt(json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct_321", + "organization_id": "org-actual" + } + })); + + mock_oauth_token_single(&mock_server, jwt).await; + + let issuer = mock_server.uri(); + let mut opts = server_opts(&codex_home, issuer); + opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + + let err = run_device_code_login(opts) + .await + .expect_err("device code login should fail when workspace mismatches"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + + let auth_path = get_auth_file(codex_home.path()); + assert!( + !auth_path.exists(), + "auth.json should not be created when workspace validation fails" + ); +} + #[tokio::test] async fn device_code_login_integration_handles_usercode_http_failure() { skip_if_no_network!(); @@ -183,7 +223,11 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail let issuer = mock_server.uri(); - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; @@ -226,7 +270,11 @@ async fn device_code_login_integration_handles_error_payload() { let issuer = mock_server.uri(); - let mut opts = ServerOptions::new(codex_home.path().to_path_buf(), "client-id".to_string()); + let mut opts = ServerOptions::new( + codex_home.path().to_path_buf(), + "client-id".to_string(), + None, + ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index a6e0de26a6..3a5f869b5a 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -14,11 +14,12 @@ use tempfile::tempdir; // See spawn.rs for details -fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { +fn start_mock_issuer(chatgpt_account_id: &str) -> (SocketAddr, thread::JoinHandle<()>) { // Bind to a random available port let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let addr = listener.local_addr().unwrap(); let server = tiny_http::Server::from_listener(listener, None).unwrap(); + let chatgpt_account_id = chatgpt_account_id.to_string(); let handle = thread::spawn(move || { while let Ok(mut req) = server.recv() { @@ -41,7 +42,7 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { "email": "user@example.com", "https://api.openai.com/auth": { "chatgpt_plan_type": "pro", - "chatgpt_account_id": "acc-123" + "chatgpt_account_id": chatgpt_account_id, } }); let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); @@ -80,7 +81,8 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) { async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, issuer_handle) = start_mock_issuer(); + let chatgpt_account_id = "12345678-0000-0000-0000-000000000000"; + let (issuer_addr, issuer_handle) = start_mock_issuer(chatgpt_account_id); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -113,8 +115,15 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), + forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), }; let server = run_login_server(opts)?; + assert!( + server + .auth_url + .contains(format!("allowed_workspace_id={chatgpt_account_id}").as_str()), + "auth URL should include forced workspace parameter" + ); let login_port = server.actual_port; // Simulate browser callback, and follow redirect to /success @@ -138,7 +147,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { assert_eq!(json["OPENAI_API_KEY"], "access-123"); assert_eq!(json["tokens"]["access_token"], "access-123"); assert_eq!(json["tokens"]["refresh_token"], "refresh-123"); - assert_eq!(json["tokens"]["account_id"], "acc-123"); + assert_eq!(json["tokens"]["account_id"], chatgpt_account_id); // Stop mock issuer drop(issuer_handle); @@ -149,7 +158,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer(); + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -166,6 +175,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), + forced_chatgpt_workspace_id: None, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -185,11 +195,67 @@ async fn creates_missing_codex_home_dir() -> Result<()> { Ok(()) } +#[tokio::test] +async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-mismatch".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: Some("org-required".to_string()), + }; + let server = run_login_server(opts)?; + assert!( + server + .auth_url + .contains("allowed_workspace_id=org-required"), + "auth URL should include forced workspace parameter" + ); + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state={state}"); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("Login is restricted to workspace id org-required"), + "error body should mention workspace restriction" + ); + + let result = server.block_until_done().await; + assert!( + result.is_err(), + "login should fail due to workspace mismatch" + ); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when the workspace mismatches" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer(); + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let first_tmp = tempdir()?; @@ -202,6 +268,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { port: 0, open_browser: false, force_state: Some("cancel_state".to_string()), + forced_chatgpt_workspace_id: None, }; let first_server = run_login_server(first_opts)?; @@ -220,6 +287,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { port: login_port, open_browser: false, force_state: Some("cancel_state_2".to_string()), + forced_chatgpt_workspace_id: None, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/mcp-client/src/main.rs b/codex-rs/mcp-client/src/main.rs index f46058b99e..8e1f322dc7 100644 --- a/codex-rs/mcp-client/src/main.rs +++ b/codex-rs/mcp-client/src/main.rs @@ -49,7 +49,7 @@ async fn main() -> Result<()> { // Spawn the subprocess and connect the client. let program = args.remove(0); let env = None; - let client = McpClient::new_stdio_client(program, args, env) + let client = McpClient::new_stdio_client(program, args, env, &[], None) .await .with_context(|| format!("failed to spawn subprocess: {original_args:?}"))?; diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 27f96494ee..3be93f35f6 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -86,19 +87,26 @@ impl McpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> std::io::Result { - let mut child = Command::new(program) + let mut command = Command::new(program); + command .args(args) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) // As noted in the `kill_on_drop` documentation, the Tokio runtime makes // a "best effort" to reap-after-exit to avoid zombie processes, but it // is not a guarantee. - .kill_on_drop(true) - .spawn()?; + .kill_on_drop(true); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let mut child = command.spawn()?; let stdin = child .stdin @@ -447,12 +455,16 @@ const DEFAULT_ENV_VARS: &[&str] = &[ /// `config.toml`. fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() - .filter_map(|var| match std::env::var(var) { - Ok(value) => Some((var.to_string(), value)), - Err(_) => None, + .copied() + .chain(env_vars.iter().map(String::as_str)) + .filter_map(|var| { + std::env::var(var) + .ok() + .map(|value| (var.to_string(), value)) }) .chain(extra_env.unwrap_or_default()) .collect::>() @@ -462,14 +474,36 @@ fn create_env_for_mcp_server( mod tests { use super::*; + fn set_env_var(key: &str, value: &str) { + unsafe { + std::env::set_var(key, value); + } + } + + fn remove_env_var(key: &str) { + unsafe { + std::env::remove_var(key); + } + } + #[test] fn test_create_env_for_mcp_server() { let env_var = "USER"; let env_var_existing_value = std::env::var(env_var).unwrap_or_default(); let env_var_new_value = format!("{env_var_existing_value}-extra"); let extra_env = HashMap::from([(env_var.to_owned(), env_var_new_value.clone())]); - let mcp_server_env = create_env_for_mcp_server(Some(extra_env)); + let mcp_server_env = create_env_for_mcp_server(Some(extra_env), &[]); assert!(mcp_server_env.contains_key("PATH")); assert_eq!(Some(&env_var_new_value), mcp_server_env.get(env_var)); } + + #[test] + fn test_create_env_for_mcp_server_includes_extra_whitelisted_vars() { + let custom_var = "CUSTOM_TEST_VAR"; + let value = "value".to_string(); + set_env_var(custom_var, &value); + let mcp_server_env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(Some(&value), mcp_server_env.get(custom_var)); + remove_env_var(custom_var); + } } diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 3cd2cccdce..5b5242f2be 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -164,6 +164,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f09dc98cbc..370dde0e82 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -178,6 +178,7 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, + parsed_cmd, }) => { handle_exec_approval_request( command, @@ -188,6 +189,7 @@ async fn run_codex_tool_session_inner( request_id_str.clone(), event.id.clone(), call_id, + parsed_cmd, ) .await; continue; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 119481cda9..44607b754d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; use mcp_types::JSONRPCErrorError; @@ -35,6 +36,7 @@ pub struct ExecApprovalElicitRequestParams { pub codex_call_id: String, pub codex_command: Vec, pub codex_cwd: PathBuf, + pub codex_parsed_cmd: Vec, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -56,6 +58,7 @@ pub(crate) async fn handle_exec_approval_request( tool_call_id: String, event_id: String, call_id: String, + codex_parsed_cmd: Vec, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -77,6 +80,7 @@ pub(crate) async fn handle_exec_approval_request( codex_call_id: call_id, codex_command: command, codex_cwd: cwd, + codex_parsed_cmd, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 13ce3864e3..d7cd200f07 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -3,6 +3,7 @@ use std::env; use std::path::Path; use std::path::PathBuf; +use codex_core::parse_command; use codex_core::protocol::FileChange; use codex_core::protocol::ReviewDecision; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -176,6 +177,7 @@ fn create_expected_elicitation_request( shlex::try_join(command.iter().map(std::convert::AsRef::as_ref))?, workdir.to_string_lossy() ); + let codex_parsed_cmd = parse_command::parse_command(&command); Ok(JSONRPCRequest { jsonrpc: JSONRPC_VERSION.into(), id: elicitation_request_id, @@ -193,6 +195,7 @@ fn create_expected_elicitation_request( codex_command: command, codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), + codex_parsed_cmd, })?), }) } @@ -442,7 +445,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<() r#" model = "mock-model" approval_policy = "untrusted" -sandbox_policy = "read-only" +sandbox_policy = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/mcp-types/Cargo.toml b/codex-rs/mcp-types/Cargo.toml index c8dc5819cd..8ea4fdfee5 100644 --- a/codex-rs/mcp-types/Cargo.toml +++ b/codex-rs/mcp-types/Cargo.toml @@ -10,3 +10,4 @@ workspace = true serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } ts-rs = { workspace = true, features = ["serde-json-impl", "no-serde-warnings"] } +schemars = { workspace = true } diff --git a/codex-rs/mcp-types/generate_mcp_types.py b/codex-rs/mcp-types/generate_mcp_types.py index 60c261e828..1906033537 100755 --- a/codex-rs/mcp-types/generate_mcp_types.py +++ b/codex-rs/mcp-types/generate_mcp_types.py @@ -21,9 +21,9 @@ from typing import Any, Literal SCHEMA_VERSION = "2025-06-18" JSONRPC_VERSION = "2.0" -STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)]\n" +STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]\n" STANDARD_HASHABLE_DERIVE = ( - "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)]\n" + "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)]\n" ) # Will be populated with the schema's `definitions` map in `main()` so that @@ -94,6 +94,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "{SCHEMA_VERSION}"; diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs index 37d790ab6a..98dab7252b 100644 --- a/codex-rs/mcp-types/src/lib.rs +++ b/codex-rs/mcp-types/src/lib.rs @@ -10,6 +10,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::convert::TryFrom; +use schemars::JsonSchema; use ts_rs::TS; pub const MCP_SCHEMA_VERSION: &str = "2025-06-18"; @@ -33,7 +34,7 @@ fn default_jsonrpc() -> String { } /// Optional annotations for the client. The client can use annotations to inform how objects are used or displayed -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Annotations { #[serde(default, skip_serializing_if = "Option::is_none")] pub audience: Option>, @@ -48,7 +49,7 @@ pub struct Annotations { } /// Audio provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct AudioContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -59,14 +60,14 @@ pub struct AudioContent { } /// Base interface for metadata with name (identifier) and title (display name) properties. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BaseMetadata { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BlobResourceContents { pub blob: String, #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] @@ -74,7 +75,7 @@ pub struct BlobResourceContents { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct BooleanSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub default: Option, @@ -85,7 +86,7 @@ pub struct BooleanSchema { pub r#type: String, // &'static str = "boolean" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CallToolRequest {} impl ModelContextProtocolRequest for CallToolRequest { @@ -94,7 +95,7 @@ impl ModelContextProtocolRequest for CallToolRequest { type Result = CallToolResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -102,7 +103,7 @@ pub struct CallToolRequestParams { } /// The server's response to a tool call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CallToolResult { pub content: Vec, #[serde(rename = "isError", default, skip_serializing_if = "Option::is_none")] @@ -123,7 +124,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CancelledNotification {} impl ModelContextProtocolNotification for CancelledNotification { @@ -131,7 +132,7 @@ impl ModelContextProtocolNotification for CancelledNotification { type Params = CancelledNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CancelledNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, @@ -140,7 +141,7 @@ pub struct CancelledNotificationParams { } /// Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub elicitation: Option, @@ -153,7 +154,7 @@ pub struct ClientCapabilities { } /// Present if the client supports listing roots. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ClientCapabilitiesRoots { #[serde( rename = "listChanged", @@ -163,7 +164,7 @@ pub struct ClientCapabilitiesRoots { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientNotification { CancelledNotification(CancelledNotification), @@ -172,7 +173,7 @@ pub enum ClientNotification { RootsListChangedNotification(RootsListChangedNotification), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ClientRequest { #[serde(rename = "initialize")] @@ -205,7 +206,7 @@ pub enum ClientRequest { CompleteRequest(::Params), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ClientResult { Result(Result), @@ -214,7 +215,7 @@ pub enum ClientResult { ElicitResult(ElicitResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CompleteRequest {} impl ModelContextProtocolRequest for CompleteRequest { @@ -223,7 +224,7 @@ impl ModelContextProtocolRequest for CompleteRequest { type Result = CompleteResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParams { pub argument: CompleteRequestParamsArgument, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -232,20 +233,20 @@ pub struct CompleteRequestParams { } /// Additional, optional context for completions -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsContext { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, } /// The argument's information -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteRequestParamsArgument { pub name: String, pub value: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CompleteRequestParamsRef { PromptReference(PromptReference), @@ -253,12 +254,12 @@ pub enum CompleteRequestParamsRef { } /// The server's response to a completion/complete request -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResult { pub completion: CompleteResultCompletion, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CompleteResultCompletion { #[serde(rename = "hasMore", default, skip_serializing_if = "Option::is_none")] pub has_more: Option, @@ -275,7 +276,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ContentBlock { TextContent(TextContent), @@ -285,7 +286,7 @@ pub enum ContentBlock { EmbeddedResource(EmbeddedResource), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum CreateMessageRequest {} impl ModelContextProtocolRequest for CreateMessageRequest { @@ -294,7 +295,7 @@ impl ModelContextProtocolRequest for CreateMessageRequest { type Result = CreateMessageResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageRequestParams { #[serde( rename = "includeContext", @@ -330,7 +331,7 @@ pub struct CreateMessageRequestParams { } /// The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct CreateMessageResult { pub content: CreateMessageResultContent, pub model: String, @@ -343,7 +344,7 @@ pub struct CreateMessageResult { pub stop_reason: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum CreateMessageResultContent { TextContent(TextContent), @@ -359,10 +360,10 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Cursor(String); -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ElicitRequest {} impl ModelContextProtocolRequest for ElicitRequest { @@ -371,7 +372,7 @@ impl ModelContextProtocolRequest for ElicitRequest { type Result = ElicitResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParams { pub message: String, #[serde(rename = "requestedSchema")] @@ -380,7 +381,7 @@ pub struct ElicitRequestParams { /// A restricted subset of JSON Schema. /// Only top-level properties are allowed, without nesting. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitRequestParamsRequestedSchema { pub properties: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -389,7 +390,7 @@ pub struct ElicitRequestParamsRequestedSchema { } /// The client's response to an elicitation request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ElicitResult { pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -408,7 +409,7 @@ impl From for serde_json::Value { /// /// It is up to the client how best to render embedded resources for the benefit /// of the LLM and/or the user. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EmbeddedResource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -416,7 +417,7 @@ pub struct EmbeddedResource { pub r#type: String, // &'static str = "resource" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum EmbeddedResourceResource { TextResourceContents(TextResourceContents), @@ -425,7 +426,7 @@ pub enum EmbeddedResourceResource { pub type EmptyResult = Result; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct EnumSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -437,7 +438,7 @@ pub struct EnumSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum GetPromptRequest {} impl ModelContextProtocolRequest for GetPromptRequest { @@ -446,7 +447,7 @@ impl ModelContextProtocolRequest for GetPromptRequest { type Result = GetPromptResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option, @@ -454,7 +455,7 @@ pub struct GetPromptRequestParams { } /// The server's response to a prompts/get request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct GetPromptResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -470,7 +471,7 @@ impl From for serde_json::Value { } /// An image provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ImageContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -481,7 +482,7 @@ pub struct ImageContent { } /// Describes the name and version of an MCP implementation, with an optional title for UI representation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Implementation { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -492,7 +493,7 @@ pub struct Implementation { pub user_agent: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializeRequest {} impl ModelContextProtocolRequest for InitializeRequest { @@ -501,7 +502,7 @@ impl ModelContextProtocolRequest for InitializeRequest { type Result = InitializeResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeRequestParams { pub capabilities: ClientCapabilities, #[serde(rename = "clientInfo")] @@ -511,7 +512,7 @@ pub struct InitializeRequestParams { } /// After receiving an initialize request from the client, the server sends this response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct InitializeResult { pub capabilities: ServerCapabilities, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -530,7 +531,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum InitializedNotification {} impl ModelContextProtocolNotification for InitializedNotification { @@ -539,7 +540,7 @@ impl ModelContextProtocolNotification for InitializedNotification { } /// 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, @@ -547,7 +548,7 @@ pub struct JSONRPCError { pub jsonrpc: String, } -#[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")] @@ -556,7 +557,7 @@ pub struct JSONRPCErrorError { } /// 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), @@ -566,7 +567,7 @@ pub enum JSONRPCMessage { } /// 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 { #[serde(rename = "jsonrpc", default = "default_jsonrpc")] pub jsonrpc: String, @@ -576,7 +577,7 @@ pub struct JSONRPCNotification { } /// 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, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -587,7 +588,7 @@ pub struct JSONRPCRequest { } /// 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, #[serde(rename = "jsonrpc", default = "default_jsonrpc")] @@ -595,7 +596,7 @@ pub struct JSONRPCResponse { pub result: Result, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListPromptsRequest {} impl ModelContextProtocolRequest for ListPromptsRequest { @@ -604,14 +605,14 @@ impl ModelContextProtocolRequest for ListPromptsRequest { type Result = ListPromptsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a prompts/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListPromptsResult { #[serde( rename = "nextCursor", @@ -630,7 +631,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourceTemplatesRequest {} impl ModelContextProtocolRequest for ListResourceTemplatesRequest { @@ -639,14 +640,14 @@ impl ModelContextProtocolRequest for ListResourceTemplatesRequest { type Result = ListResourceTemplatesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/templates/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourceTemplatesResult { #[serde( rename = "nextCursor", @@ -666,7 +667,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListResourcesRequest {} impl ModelContextProtocolRequest for ListResourcesRequest { @@ -675,14 +676,14 @@ impl ModelContextProtocolRequest for ListResourcesRequest { type Result = ListResourcesResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a resources/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListResourcesResult { #[serde( rename = "nextCursor", @@ -701,7 +702,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListRootsRequest {} impl ModelContextProtocolRequest for ListRootsRequest { @@ -713,7 +714,7 @@ impl ModelContextProtocolRequest for ListRootsRequest { /// The client's response to a roots/list request from the server. /// This result contains an array of Root objects, each representing a root directory /// or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListRootsResult { pub roots: Vec, } @@ -726,7 +727,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ListToolsRequest {} impl ModelContextProtocolRequest for ListToolsRequest { @@ -735,14 +736,14 @@ impl ModelContextProtocolRequest for ListToolsRequest { type Result = ListToolsResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// The server's response to a tools/list request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ListToolsResult { #[serde( rename = "nextCursor", @@ -765,7 +766,7 @@ impl From for serde_json::Value { /// /// These map to syslog message severities, as specified in RFC-5424: /// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingLevel { #[serde(rename = "alert")] Alert, @@ -785,7 +786,7 @@ pub enum LoggingLevel { Warning, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingMessageNotification {} impl ModelContextProtocolNotification for LoggingMessageNotification { @@ -793,7 +794,7 @@ impl ModelContextProtocolNotification for LoggingMessageNotification { type Params = LoggingMessageNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct LoggingMessageNotificationParams { pub data: serde_json::Value, pub level: LoggingLevel, @@ -805,7 +806,7 @@ pub struct LoggingMessageNotificationParams { /// /// Keys not declared here are currently left unspecified by the spec and are up /// to the client to interpret. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelHint { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, @@ -822,7 +823,7 @@ pub struct ModelHint { /// These preferences are always advisory. The client MAY ignore them. It is also /// up to the client to decide how to interpret these preferences and how to /// balance them against other considerations. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ModelPreferences { #[serde( rename = "costPriority", @@ -846,14 +847,14 @@ pub struct ModelPreferences { pub speed_priority: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Notification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct NumberSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -866,20 +867,20 @@ pub struct NumberSchema { pub r#type: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequest { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PaginatedResult { #[serde( rename = "nextCursor", @@ -897,7 +898,7 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PingRequest {} impl ModelContextProtocolRequest for PingRequest { @@ -908,7 +909,7 @@ impl ModelContextProtocolRequest for PingRequest { /// Restricted schema definitions that only allow primitive types /// without nested objects or arrays. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum PrimitiveSchemaDefinition { StringSchema(StringSchema), @@ -917,7 +918,7 @@ pub enum PrimitiveSchemaDefinition { EnumSchema(EnumSchema), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ProgressNotification {} impl ModelContextProtocolNotification for ProgressNotification { @@ -925,7 +926,7 @@ impl ModelContextProtocolNotification for ProgressNotification { type Params = ProgressNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ProgressNotificationParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, @@ -936,7 +937,7 @@ pub struct ProgressNotificationParams { pub total: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum ProgressToken { String(String), @@ -944,7 +945,7 @@ pub enum ProgressToken { } /// A prompt or prompt template that the server offers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Prompt { #[serde(default, skip_serializing_if = "Option::is_none")] pub arguments: Option>, @@ -956,7 +957,7 @@ pub struct Prompt { } /// Describes an argument that a prompt can accept. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptArgument { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -967,7 +968,7 @@ pub struct PromptArgument { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum PromptListChangedNotification {} impl ModelContextProtocolNotification for PromptListChangedNotification { @@ -979,14 +980,14 @@ impl ModelContextProtocolNotification for PromptListChangedNotification { /// /// This is similar to `SamplingMessage`, but also supports the embedding of /// resources from the MCP server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptMessage { pub content: ContentBlock, pub role: Role, } /// Identifies a prompt. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct PromptReference { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -994,7 +995,7 @@ pub struct PromptReference { pub r#type: String, // &'static str = "ref/prompt" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ReadResourceRequest {} impl ModelContextProtocolRequest for ReadResourceRequest { @@ -1003,18 +1004,18 @@ impl ModelContextProtocolRequest for ReadResourceRequest { type Result = ReadResourceResult; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceRequestParams { pub uri: String, } /// The server's response to a resources/read request from the client. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ReadResourceResult { pub contents: Vec, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ReadResourceResultContents { TextResourceContents(TextResourceContents), @@ -1029,14 +1030,14 @@ impl From for serde_json::Value { } } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Request { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub params: Option, } -#[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), @@ -1044,7 +1045,7 @@ pub enum RequestId { } /// A known resource that the server is capable of reading. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Resource { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1061,7 +1062,7 @@ pub struct Resource { } /// The contents of a specific resource or sub-resource. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1071,7 +1072,7 @@ pub struct ResourceContents { /// A resource that the server is capable of reading, included in a prompt or tool call result. /// /// Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceLink { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1088,7 +1089,7 @@ pub struct ResourceLink { pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceListChangedNotification {} impl ModelContextProtocolNotification for ResourceListChangedNotification { @@ -1097,7 +1098,7 @@ impl ModelContextProtocolNotification for ResourceListChangedNotification { } /// A template description for resources available on the server. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplate { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1113,13 +1114,13 @@ pub struct ResourceTemplate { } /// A reference to a resource or resource template definition. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceTemplateReference { pub r#type: String, // &'static str = "ref/resource" pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ResourceUpdatedNotification {} impl ModelContextProtocolNotification for ResourceUpdatedNotification { @@ -1127,7 +1128,7 @@ impl ModelContextProtocolNotification for ResourceUpdatedNotification { type Params = ResourceUpdatedNotificationParams; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ResourceUpdatedNotificationParams { pub uri: String, } @@ -1135,7 +1136,7 @@ pub struct ResourceUpdatedNotificationParams { pub type Result = serde_json::Value; /// The sender or recipient of messages and data in a conversation. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum Role { #[serde(rename = "assistant")] Assistant, @@ -1144,14 +1145,14 @@ pub enum Role { } /// Represents a root directory or file that the server can operate on. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Root { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, pub uri: String, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum RootsListChangedNotification {} impl ModelContextProtocolNotification for RootsListChangedNotification { @@ -1160,13 +1161,13 @@ impl ModelContextProtocolNotification for RootsListChangedNotification { } /// Describes a message issued to or received from an LLM API. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SamplingMessage { pub content: SamplingMessageContent, pub role: Role, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum SamplingMessageContent { TextContent(TextContent), @@ -1175,7 +1176,7 @@ pub enum SamplingMessageContent { } /// Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilities { #[serde(default, skip_serializing_if = "Option::is_none")] pub completions: Option, @@ -1192,7 +1193,7 @@ pub struct ServerCapabilities { } /// Present if the server offers any tools to call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesTools { #[serde( rename = "listChanged", @@ -1203,7 +1204,7 @@ pub struct ServerCapabilitiesTools { } /// Present if the server offers any resources to read. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesResources { #[serde( rename = "listChanged", @@ -1216,7 +1217,7 @@ pub struct ServerCapabilitiesResources { } /// Present if the server offers any prompt templates. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ServerCapabilitiesPrompts { #[serde( rename = "listChanged", @@ -1226,7 +1227,7 @@ pub struct ServerCapabilitiesPrompts { pub list_changed: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "method", content = "params")] pub enum ServerNotification { #[serde(rename = "notifications/cancelled")] @@ -1255,7 +1256,7 @@ pub enum ServerNotification { ), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum ServerRequest { PingRequest(PingRequest), @@ -1264,7 +1265,7 @@ pub enum ServerRequest { ElicitRequest(ElicitRequest), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum ServerResult { @@ -1280,7 +1281,7 @@ pub enum ServerResult { CompleteResult(CompleteResult), } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SetLevelRequest {} impl ModelContextProtocolRequest for SetLevelRequest { @@ -1289,12 +1290,12 @@ impl ModelContextProtocolRequest for SetLevelRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SetLevelRequestParams { pub level: LoggingLevel, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct StringSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -1309,7 +1310,7 @@ pub struct StringSchema { pub r#type: String, // &'static str = "string" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum SubscribeRequest {} impl ModelContextProtocolRequest for SubscribeRequest { @@ -1318,13 +1319,13 @@ impl ModelContextProtocolRequest for SubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct SubscribeRequestParams { pub uri: String, } /// Text provided to or from an LLM. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1332,7 +1333,7 @@ pub struct TextContent { pub r#type: String, // &'static str = "text" } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct TextResourceContents { #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] pub mime_type: Option, @@ -1341,7 +1342,7 @@ pub struct TextResourceContents { } /// Definition for a tool the client can call. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct Tool { #[serde(default, skip_serializing_if = "Option::is_none")] pub annotations: Option, @@ -1362,7 +1363,7 @@ pub struct Tool { /// An optional JSON Schema object defining the structure of the tool's output returned in /// the structuredContent field of a CallToolResult. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolOutputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1372,7 +1373,7 @@ pub struct ToolOutputSchema { } /// A JSON Schema object defining the expected parameters for the tool. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolInputSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub properties: Option, @@ -1389,7 +1390,7 @@ pub struct ToolInputSchema { /// /// Clients should never make tool use decisions based on ToolAnnotations /// received from untrusted servers. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct ToolAnnotations { #[serde( rename = "destructiveHint", @@ -1419,7 +1420,7 @@ pub struct ToolAnnotations { pub title: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum ToolListChangedNotification {} impl ModelContextProtocolNotification for ToolListChangedNotification { @@ -1427,7 +1428,7 @@ impl ModelContextProtocolNotification for ToolListChangedNotification { type Params = Option; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum UnsubscribeRequest {} impl ModelContextProtocolRequest for UnsubscribeRequest { @@ -1436,7 +1437,7 @@ impl ModelContextProtocolRequest for UnsubscribeRequest { type Result = Result; } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct UnsubscribeRequestParams { pub uri: String, } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 2d6f278a79..666d83f822 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -33,6 +33,7 @@ pub struct OtelEventMetadata { conversation_id: ConversationId, auth_mode: Option, account_id: Option, + account_email: Option, model: String, slug: String, log_user_prompts: bool, @@ -46,11 +47,13 @@ pub struct OtelEventManager { } impl OtelEventManager { + #[allow(clippy::too_many_arguments)] pub fn new( conversation_id: ConversationId, model: &str, slug: &str, account_id: Option, + account_email: Option, auth_mode: Option, log_user_prompts: bool, terminal_type: String, @@ -60,6 +63,7 @@ impl OtelEventManager { conversation_id, auth_mode: auth_mode.map(|m| m.to_string()), account_id, + account_email, model: model.to_owned(), slug: slug.to_owned(), log_user_prompts, @@ -82,8 +86,8 @@ impl OtelEventManager { provider_name: &str, reasoning_effort: Option, reasoning_summary: ReasoningSummary, - context_window: Option, - max_output_tokens: Option, + context_window: Option, + max_output_tokens: Option, auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, @@ -98,6 +102,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -136,6 +141,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -205,6 +211,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -226,6 +233,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -240,6 +248,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -262,6 +271,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -271,11 +281,11 @@ impl OtelEventManager { pub fn sse_event_completed( &self, - input_token_count: u64, - output_token_count: u64, - cached_token_count: Option, - reasoning_token_count: Option, - tool_token_count: u64, + input_token_count: i64, + output_token_count: i64, + cached_token_count: Option, + reasoning_token_count: Option, + tool_token_count: i64, ) { tracing::event!( tracing::Level::INFO, @@ -286,6 +296,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -320,6 +331,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -343,6 +355,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -383,7 +396,8 @@ impl OtelEventManager { conversation.id = %self.metadata.conversation_id, app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, + user.account_id= self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -408,6 +422,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -437,6 +452,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 72cf984e96..f21d326610 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -19,6 +19,7 @@ mime_guess = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true, features = ["macros", "base64"] } +schemars = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } sys-locale = { workspace = true } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 633c845dd2..32950527bc 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; @@ -6,7 +7,18 @@ use ts_rs::TS; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( - Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter, + Debug, + Serialize, + Deserialize, + Default, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + EnumIter, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] @@ -21,7 +33,9 @@ pub enum ReasoningEffort { /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ReasoningSummary { @@ -35,7 +49,9 @@ pub enum ReasoningSummary { /// Controls output length/detail on GPT-5 models via the Responses API. /// Serialized with lowercase values to match the OpenAI API. -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Verbosity { @@ -45,7 +61,9 @@ pub enum Verbosity { High, } -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)] +#[derive( + Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, JsonSchema, TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum SandboxMode { @@ -59,3 +77,11 @@ pub enum SandboxMode { #[serde(rename = "danger-full-access")] DangerFullAccess, } + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ForcedLoginMethod { + Chatgpt, + Api, +} diff --git a/codex-rs/protocol/src/conversation_id.rs b/codex-rs/protocol/src/conversation_id.rs index 687237e0ab..db104d4531 100644 --- a/codex-rs/protocol/src/conversation_id.rs +++ b/codex-rs/protocol/src/conversation_id.rs @@ -1,5 +1,8 @@ use std::fmt::Display; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; @@ -57,6 +60,16 @@ impl<'de> Deserialize<'de> for ConversationId { } } +impl JsonSchema for ConversationId { + fn schema_name() -> String { + "ConversationId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index 4839cba3c2..7472d1b4c5 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -1,3 +1,4 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -9,7 +10,7 @@ use ts_rs::TS; /// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` pub const PROMPTS_CMD_PREFIX: &str = "prompts"; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct CustomPrompt { pub name: String, pub path: PathBuf, diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs index ba79993131..0d8bd8df4e 100644 --- a/codex-rs/protocol/src/message_history.rs +++ b/codex-rs/protocol/src/message_history.rs @@ -1,8 +1,9 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct HistoryEntry { pub conversation_id: String, pub ts: u64, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 4952aa01d9..285ae666f9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -9,8 +9,9 @@ use serde::ser::Serializer; use ts_rs::TS; use crate::protocol::InputItem; +use schemars::JsonSchema; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { Message { @@ -31,7 +32,7 @@ pub enum ResponseInputItem { }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { InputText { text: String }, @@ -39,7 +40,7 @@ pub enum ContentItem { OutputText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { @@ -159,7 +160,7 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum LocalShellStatus { Completed, @@ -167,13 +168,13 @@ pub enum LocalShellStatus { Incomplete, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LocalShellAction { Exec(LocalShellExecAction), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] pub struct LocalShellExecAction { pub command: Vec, pub timeout_ms: Option, @@ -182,7 +183,7 @@ pub struct LocalShellExecAction { pub user: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WebSearchAction { Search { @@ -192,13 +193,13 @@ pub enum WebSearchAction { Other, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemReasoningSummary { SummaryText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemContent { ReasoningText { text: String }, @@ -242,7 +243,7 @@ impl From> for ResponseInputItem { /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, TS)] +#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ShellToolCallParams { pub command: Vec, pub workdir: Option, @@ -256,7 +257,7 @@ pub struct ShellToolCallParams { pub justification: Option, } -#[derive(Debug, Clone, PartialEq, TS)] +#[derive(Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FunctionCallOutputPayload { pub content: String, // TODO(jif) drop this. diff --git a/codex-rs/protocol/src/num_format.rs b/codex-rs/protocol/src/num_format.rs index 72a4ee722d..2c64939b7a 100644 --- a/codex-rs/protocol/src/num_format.rs +++ b/codex-rs/protocol/src/num_format.rs @@ -22,27 +22,28 @@ fn formatter() -> &'static DecimalFormatter { FORMATTER.get_or_init(|| make_local_formatter().unwrap_or_else(make_en_us_formatter)) } -/// Format a u64 with locale-aware digit separators (e.g. "12345" -> "12,345" +/// Format an i64 with locale-aware digit separators (e.g. "12345" -> "12,345" /// for en-US). -pub fn format_with_separators(n: u64) -> String { +pub fn format_with_separators(n: i64) -> String { formatter().format(&Decimal::from(n)).to_string() } -fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> String { +fn format_si_suffix_with_formatter(n: i64, formatter: &DecimalFormatter) -> String { + let n = n.max(0); if n < 1000 { return formatter.format(&Decimal::from(n)).to_string(); } // Format `n / scale` with the requested number of fractional digits. - let format_scaled = |n: u64, scale: u64, frac_digits: u32| -> String { + let format_scaled = |n: i64, scale: i64, frac_digits: u32| -> String { let value = n as f64 / scale as f64; - let scaled: u64 = (value * 10f64.powi(frac_digits as i32)).round() as u64; + let scaled: i64 = (value * 10f64.powi(frac_digits as i32)).round() as i64; let mut dec = Decimal::from(scaled); dec.multiply_pow10(-(frac_digits as i16)); formatter.format(&dec).to_string() }; - const UNITS: [(u64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; + const UNITS: [(i64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")]; let f = n as f64; for &(scale, suffix) in &UNITS { if (100.0 * f / scale as f64).round() < 1000.0 { @@ -57,7 +58,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri // Above 1000G, keep whole‑G precision. format!( "{}G", - format_with_separators(((n as f64) / 1e9).round() as u64) + format_with_separators(((n as f64) / 1e9).round() as i64) ) } @@ -67,7 +68,7 @@ fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> Stri /// - 999 -> "999" /// - 1200 -> "1.20K" /// - 123456789 -> "123M" -pub fn format_si_suffix(n: u64) -> String { +pub fn format_si_suffix(n: i64) -> String { format_si_suffix_with_formatter(n, formatter()) } @@ -78,7 +79,7 @@ mod tests { #[test] fn kmg() { let formatter = make_en_us_formatter(); - let fmt = |n: u64| format_si_suffix_with_formatter(n, &formatter); + let fmt = |n: i64| format_si_suffix_with_formatter(n, &formatter); assert_eq!(fmt(0), "0"); assert_eq!(fmt(999), "999"); assert_eq!(fmt(1_000), "1.00K"); diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index d1fbe83508..77926f11b6 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,13 +1,20 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::path::PathBuf; use ts_rs::TS; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { cmd: String, name: String, + /// (Best effort) Path to the file being read by the command. When + /// possible, this is an absolute path, though when relative, it should + /// be resolved against the `cwd`` that will be used to run the command + /// to derive the absolute path. + path: PathBuf, }, ListFiles { cmd: String, diff --git a/codex-rs/protocol/src/plan_tool.rs b/codex-rs/protocol/src/plan_tool.rs index 79e672ac73..a9038eb03b 100644 --- a/codex-rs/protocol/src/plan_tool.rs +++ b/codex-rs/protocol/src/plan_tool.rs @@ -1,9 +1,10 @@ +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; // Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum StepStatus { Pending, @@ -11,14 +12,14 @@ pub enum StepStatus { Completed, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct PlanItemArg { pub step: String, pub status: StepStatus, } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct UpdatePlanArgs { #[serde(default)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3b6520be67..e22981745c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,7 +21,10 @@ use crate::num_format::format_with_separators; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use mcp_types::CallToolResult; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -38,7 +41,7 @@ pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, @@ -47,7 +50,7 @@ pub struct Submission { } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(clippy::large_enum_variant)] #[non_exhaustive] @@ -180,7 +183,20 @@ pub enum Op { /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display, TS)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + JsonSchema, + TS, +)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum AskForApproval { @@ -207,7 +223,7 @@ pub enum AskForApproval { } /// Determines execution restrictions for model shell commands. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[strum(serialize_all = "kebab-case")] #[serde(tag = "mode", rename_all = "kebab-case")] pub enum SandboxPolicy { @@ -250,7 +266,7 @@ pub enum SandboxPolicy { /// read‑only even when the root is writable. This is primarily used to ensure /// top‑level VCS metadata directories (e.g. `.git`) under a writable root are /// not modified by the agent. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] pub struct WritableRoot { /// Absolute path, by construction. pub root: PathBuf, @@ -389,7 +405,7 @@ impl SandboxPolicy { /// User input #[non_exhaustive] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputItem { Text { @@ -408,7 +424,7 @@ pub enum InputItem { } /// Event Queue Entry - events from agent -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct Event { /// Submission `id` that this event is correlated with. pub id: String, @@ -418,7 +434,7 @@ pub struct Event { /// Response event from the agent /// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen. -#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Display, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum EventMsg { @@ -524,55 +540,55 @@ pub enum EventMsg { ExitedReviewMode(ExitedReviewModeEvent), } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, } // Individual event payload types matching each `EventMsg` variant. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskStartedEvent { - pub model_context_window: Option, + pub model_context_window: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema, TS)] pub struct TokenUsage { #[ts(type = "number")] - pub input_tokens: u64, + pub input_tokens: i64, #[ts(type = "number")] - pub cached_input_tokens: u64, + pub cached_input_tokens: i64, #[ts(type = "number")] - pub output_tokens: u64, + pub output_tokens: i64, #[ts(type = "number")] - pub reasoning_output_tokens: u64, + pub reasoning_output_tokens: i64, #[ts(type = "number")] - pub total_tokens: u64, + pub total_tokens: i64, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, #[ts(type = "number | null")] - pub model_context_window: Option, + pub model_context_window: Option, } impl TokenUsageInfo { pub fn new_or_append( info: &Option, last: &Option, - model_context_window: Option, + model_context_window: Option, ) -> Option { if info.is_none() && last.is_none() { return None; @@ -597,9 +613,9 @@ impl TokenUsageInfo { self.last_token_usage = last.clone(); } - pub fn fill_to_context_window(&mut self, context_window: u64) { + pub fn fill_to_context_window(&mut self, context_window: i64) { let previous_total = self.total_token_usage.total_tokens; - let delta = context_window.saturating_sub(previous_total); + let delta = (context_window - previous_total).max(0); self.model_context_window = Some(context_window); self.total_token_usage = TokenUsage { @@ -612,7 +628,7 @@ impl TokenUsageInfo { }; } - pub fn full_context_window(context_window: u64) -> Self { + pub fn full_context_window(context_window: i64) -> Self { let mut info = Self { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), @@ -623,58 +639,57 @@ impl TokenUsageInfo { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TokenCountEvent { pub info: Option, pub rate_limits: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. pub used_percent: f64, /// Rolling window duration, in minutes. #[ts(type = "number | null")] - pub window_minutes: Option, - /// Seconds until the window resets. + pub window_minutes: Option, + /// Unix timestamp (seconds since epoch) when the window resets. #[ts(type = "number | null")] - pub resets_in_seconds: Option, + pub resets_at: Option, } // Includes prompts, tools and space to call compact. -const BASELINE_TOKENS: u64 = 12000; +const BASELINE_TOKENS: i64 = 12000; impl TokenUsage { pub fn is_zero(&self) -> bool { self.total_tokens == 0 } - pub fn cached_input(&self) -> u64 { - self.cached_input_tokens + pub fn cached_input(&self) -> i64 { + self.cached_input_tokens.max(0) } - pub fn non_cached_input(&self) -> u64 { - self.input_tokens.saturating_sub(self.cached_input()) + pub fn non_cached_input(&self) -> i64 { + (self.input_tokens - self.cached_input()).max(0) } /// Primary count for display as a single absolute value: non-cached input + output. - pub fn blended_total(&self) -> u64 { - self.non_cached_input() + self.output_tokens + pub fn blended_total(&self) -> i64 { + (self.non_cached_input() + self.output_tokens.max(0)).max(0) } /// For estimating what % of the model's context window is used, we need to account /// for reasoning output tokens from prior turns being dropped from the context window. /// We approximate this here by subtracting reasoning output tokens from the total. /// This will be off for the current turn and pending function calls. - pub fn tokens_in_context_window(&self) -> u64 { - self.total_tokens - .saturating_sub(self.reasoning_output_tokens) + pub fn tokens_in_context_window(&self) -> i64 { + (self.total_tokens - self.reasoning_output_tokens).max(0) } /// Estimate the remaining user-controllable percentage of the model's context window. @@ -687,17 +702,17 @@ impl TokenUsage { /// This normalizes both the numerator and denominator by subtracting the /// baseline, so immediately after the first prompt the UI shows 100% left /// and trends toward 0% as the user fills the effective window. - pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 { + pub fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 { if context_window <= BASELINE_TOKENS { return 0; } let effective_window = context_window - BASELINE_TOKENS; - let used = self - .tokens_in_context_window() - .saturating_sub(BASELINE_TOKENS); - let remaining = effective_window.saturating_sub(used); - ((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8 + let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0); + let remaining = (effective_window - used).max(0); + ((remaining as f64 / effective_window as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as i64 } /// In-place element-wise sum of token counts. @@ -710,7 +725,7 @@ impl TokenUsage { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct FinalOutput { pub token_usage: TokenUsage, } @@ -751,12 +766,12 @@ impl fmt::Display for FinalOutput { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum InputMessageKind { /// Plain user text (default) @@ -767,7 +782,7 @@ pub enum InputMessageKind { EnvironmentContext, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -819,35 +834,35 @@ fn ends_with_ignore_ascii_case(text: &str, suffix: &str) -> bool { .all(|(a, b)| a.eq_ignore_ascii_case(b)) } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningRawContentDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningSectionBreakEvent {} -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentReasoningDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, @@ -857,14 +872,14 @@ pub struct McpInvocation { pub arguments: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, pub invocation: McpInvocation, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, @@ -884,12 +899,12 @@ impl McpToolCallEndEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchBeginEvent { pub call_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct WebSearchEndEvent { pub call_id: String, pub query: String, @@ -897,20 +912,20 @@ pub struct WebSearchEndEvent { /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ConversationPathResponseEvent { pub conversation_id: ConversationId, pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ResumedHistory { pub conversation_id: ConversationId, pub history: Vec, pub rollout_path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub enum InitialHistory { New, Resumed(ResumedHistory), @@ -952,7 +967,7 @@ impl InitialHistory { } } -#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] #[serde(rename_all = "lowercase")] #[ts(rename_all = "lowercase")] pub enum SessionSource { @@ -965,7 +980,7 @@ pub enum SessionSource { Unknown, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ConversationId, pub timestamp: String, @@ -991,7 +1006,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -999,7 +1014,7 @@ pub struct SessionMetaLine { pub git: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { SessionMeta(SessionMetaLine), @@ -1009,7 +1024,7 @@ pub enum RolloutItem { EventMsg(EventMsg), } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct CompactedItem { pub message: String, } @@ -1026,7 +1041,7 @@ impl From for ResponseItem { } } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct TurnContextItem { pub cwd: PathBuf, pub approval_policy: AskForApproval, @@ -1037,14 +1052,14 @@ pub struct TurnContextItem { pub summary: ReasoningSummaryConfig, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, JsonSchema)] pub struct RolloutLine { pub timestamp: String, #[serde(flatten)] pub item: RolloutItem, } -#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")] @@ -1058,14 +1073,14 @@ pub struct GitInfo { } /// Review request sent to the review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewRequest { pub prompt: String, pub user_facing_hint: String, } /// Structured review result produced by a child review session. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewOutputEvent { pub findings: Vec, pub overall_correctness: String, @@ -1085,7 +1100,7 @@ impl Default for ReviewOutputEvent { } /// A single review finding describing an observed issue or recommendation. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewFinding { pub title: String, pub body: String, @@ -1095,20 +1110,20 @@ pub struct ReviewFinding { } /// Location of the code related to a review finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewCodeLocation { pub absolute_file_path: PathBuf, pub line_range: ReviewLineRange, } /// Inclusive line range in a file associated with the finding. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ReviewLineRange { pub start: u32, pub end: u32, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. pub call_id: String, @@ -1119,7 +1134,7 @@ pub struct ExecCommandBeginEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecCommandEndEvent { /// Identifier for the ExecCommandBegin that finished. pub call_id: String, @@ -1139,7 +1154,7 @@ pub struct ExecCommandEndEvent { pub formatted_output: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ViewImageToolCallEvent { /// Identifier for the originating tool call. pub call_id: String, @@ -1147,7 +1162,7 @@ pub struct ViewImageToolCallEvent { pub path: PathBuf, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecOutputStream { Stdout, @@ -1155,7 +1170,7 @@ pub enum ExecOutputStream { } #[serde_as] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct ExecCommandOutputDeltaEvent { /// Identifier for the ExecCommandBegin that produced this chunk. pub call_id: String, @@ -1163,11 +1178,12 @@ pub struct ExecCommandOutputDeltaEvent { pub stream: ExecOutputStream, /// Raw bytes from the stream (may not be valid UTF-8). #[serde_as(as = "serde_with::base64::Base64")] + #[schemars(with = "String")] #[ts(type = "string")] pub chunk: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. pub call_id: String, @@ -1178,9 +1194,10 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ApplyPatchApprovalRequestEvent { /// Responses API call id for the associated patch apply call, if available. pub call_id: String, @@ -1193,17 +1210,22 @@ pub struct ApplyPatchApprovalRequestEvent { pub grant_root: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct StreamErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct StreamInfoEvent { + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. pub call_id: String, @@ -1213,7 +1235,7 @@ pub struct PatchApplyBeginEvent { pub changes: HashMap, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct PatchApplyEndEvent { /// Identifier for the PatchApplyBegin that finished. pub call_id: String, @@ -1225,12 +1247,12 @@ pub struct PatchApplyEndEvent { pub success: bool, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct GetHistoryEntryResponseEvent { pub offset: usize, pub log_id: u64, @@ -1239,19 +1261,47 @@ pub struct GetHistoryEntryResponseEvent { pub entry: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, + /// Known resources grouped by server name. + pub resources: std::collections::HashMap>, + /// Known resource templates grouped by server name. + pub resource_templates: std::collections::HashMap>, + /// Authentication status for each configured MCP server. + pub auth_statuses: std::collections::HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth, +} + +impl fmt::Display for McpAuthStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + McpAuthStatus::Unsupported => "Unsupported", + McpAuthStatus::NotLoggedIn => "Not logged in", + McpAuthStatus::BearerToken => "Bearer token", + McpAuthStatus::OAuth => "OAuth", + }; + f.write_str(text) + } } /// Response payload for `Op::ListCustomPrompts`. -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } -#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, @@ -1278,7 +1328,9 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, TS)] +#[derive( + Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, +)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. @@ -1299,7 +1351,7 @@ pub enum ReviewDecision { Abort, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum FileChange { Add { @@ -1314,7 +1366,7 @@ pub enum FileChange { }, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct Chunk { /// 1-based line index of the first line in the original file pub orig_index: u32, @@ -1322,12 +1374,12 @@ pub struct Chunk { pub inserted_lines: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum TurnAbortReason { Interrupted, diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index ddbc70563e..c515cdbfa3 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -12,6 +12,7 @@ axum = { workspace = true, default-features = false, features = [ "http1", "tokio", ] } +codex-protocol = { workspace = true } keyring = { workspace = true, features = [ "apple-native", "crypto-rust", @@ -56,5 +57,7 @@ urlencoding = { workspace = true } webbrowser = { workspace = true } [dev-dependencies] +escargot = { workspace = true } pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs new file mode 100644 index 0000000000..77c33f6959 --- /dev/null +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::Error; +use anyhow::Result; +use codex_protocol::protocol::McpAuthStatus; +use reqwest::Client; +use reqwest::StatusCode; +use reqwest::Url; +use reqwest::header::HeaderMap; +use serde::Deserialize; +use tracing::debug; + +use crate::OAuthCredentialsStoreMode; +use crate::oauth::has_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; +const OAUTH_DISCOVERY_VERSION: &str = "2024-11-05"; + +/// Determine the authentication status for a streamable HTTP MCP server. +pub async fn determine_streamable_http_auth_status( + server_name: &str, + url: &str, + bearer_token_env_var: Option<&str>, + http_headers: Option>, + env_http_headers: Option>, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + if bearer_token_env_var.is_some() { + return Ok(McpAuthStatus::BearerToken); + } + + if has_oauth_tokens(server_name, url, store_mode)? { + return Ok(McpAuthStatus::OAuth); + } + + let default_headers = build_default_headers(http_headers, env_http_headers)?; + + match supports_oauth_login_with_headers(url, &default_headers).await { + Ok(true) => Ok(McpAuthStatus::NotLoggedIn), + Ok(false) => Ok(McpAuthStatus::Unsupported), + Err(error) => { + debug!( + "failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}" + ); + Ok(McpAuthStatus::Unsupported) + } + } +} + +/// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. +pub async fn supports_oauth_login(url: &str) -> Result { + supports_oauth_login_with_headers(url, &HeaderMap::new()).await +} + +async fn supports_oauth_login_with_headers(url: &str, default_headers: &HeaderMap) -> Result { + let base_url = Url::parse(url)?; + let builder = Client::builder().timeout(DISCOVERY_TIMEOUT); + let client = apply_default_headers(builder, default_headers).build()?; + + let mut last_error: Option = None; + for candidate_path in discovery_paths(base_url.path()) { + let mut discovery_url = base_url.clone(); + discovery_url.set_path(&candidate_path); + + let response = match client + .get(discovery_url.clone()) + .header(OAUTH_DISCOVERY_HEADER, OAUTH_DISCOVERY_VERSION) + .send() + .await + { + Ok(response) => response, + Err(err) => { + last_error = Some(err.into()); + continue; + } + }; + + if response.status() != StatusCode::OK { + continue; + } + + let metadata = match response.json::().await { + Ok(metadata) => metadata, + Err(err) => { + last_error = Some(err.into()); + continue; + } + }; + + if metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() { + return Ok(true); + } + } + + if let Some(err) = last_error { + debug!("OAuth discovery requests failed for {url}: {err:?}"); + } + + Ok(false) +} + +#[derive(Debug, Deserialize)] +struct OAuthDiscoveryMetadata { + #[serde(default)] + authorization_endpoint: Option, + #[serde(default)] + token_endpoint: Option, +} + +/// Implements RFC 8414 section 3.1 for discovering well-known oauth endpoints. +/// This is a requirement for MCP servers to support OAuth. +/// https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 +/// https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/auth.rs#L182 +fn discovery_paths(base_path: &str) -> Vec { + let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); + let canonical = "/.well-known/oauth-authorization-server".to_string(); + + if trimmed.is_empty() { + return vec![canonical]; + } + + let mut candidates = Vec::new(); + let mut push_unique = |candidate: String| { + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + }; + + push_unique(format!("{canonical}/{trimmed}")); + push_unique(format!("/{trimmed}/.well-known/oauth-authorization-server")); + push_unique(canonical); + + candidates +} diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 2d380fa54e..44ae50f02f 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -8,8 +8,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -20,15 +29,24 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } + +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { (tokio::io::stdin(), tokio::io::stdout()) } impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -51,6 +69,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -66,6 +114,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -85,6 +134,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 81a60404ba..30d8fd7fdf 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -18,8 +18,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -33,13 +42,22 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; + impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -62,6 +80,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -77,6 +125,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -96,6 +145,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 0d15584f0f..ca99a7bb90 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -1,3 +1,4 @@ +mod auth_status; mod find_codex_home; mod logging_client_handler; mod oauth; @@ -5,6 +6,9 @@ mod perform_oauth_login; mod rmcp_client; mod utils; +pub use auth_status::determine_streamable_http_auth_status; +pub use auth_status::supports_oauth_login; +pub use codex_protocol::protocol::McpAuthStatus; pub use oauth::OAuthCredentialsStoreMode; pub use oauth::StoredOAuthTokens; pub use oauth::WrappedOAuthTokenResponse; diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index 0534847651..afa0e907b1 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -162,6 +162,14 @@ pub(crate) fn load_oauth_tokens( } } +pub(crate) fn has_oauth_tokens( + server_name: &str, + url: &str, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + Ok(load_oauth_tokens(server_name, url, store_mode)?.is_some()) +} + fn load_oauth_tokens_from_keyring_with_fallback_to_file( keyring_store: &K, server_name: &str, diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index c2d39a214f..c5276227aa 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::string::String; use std::sync::Arc; use std::time::Duration; @@ -5,6 +6,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use reqwest::ClientBuilder; use rmcp::transport::auth::OAuthState; use tiny_http::Response; use tiny_http::Server; @@ -16,6 +18,8 @@ use crate::OAuthCredentialsStoreMode; use crate::StoredOAuthTokens; use crate::WrappedOAuthTokenResponse; use crate::save_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; struct CallbackServerGuard { server: Arc, @@ -31,6 +35,8 @@ pub async fn perform_oauth_login( server_name: &str, server_url: &str, store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, ) -> Result<()> { let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); let guard = CallbackServerGuard { @@ -51,7 +57,10 @@ pub async fn perform_oauth_login( let (tx, rx) = oneshot::channel(); spawn_callback_server(server, tx); - let mut oauth_state = OAuthState::new(server_url, None).await?; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; oauth_state .start_authorization(&[], &redirect_uri, Some("Codex")) .await?; diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 3d12e50868..bc1980f1f5 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::ffi::OsString; use std::io; +use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -12,11 +13,19 @@ use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::InitializeRequestParams; use mcp_types::InitializeResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; use mcp_types::ListToolsRequestParams; use mcp_types::ListToolsResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; +use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::InitializeRequestParam; use rmcp::model::PaginatedRequestParam; +use rmcp::model::ReadResourceRequestParam; use rmcp::service::RoleClient; use rmcp::service::RunningService; use rmcp::service::{self}; @@ -38,6 +47,8 @@ use crate::logging_client_handler::LoggingClientHandler; use crate::oauth::OAuthCredentialsStoreMode; use crate::oauth::OAuthPersistor; use crate::oauth::StoredOAuthTokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; use crate::utils::convert_call_tool_result; use crate::utils::convert_to_mcp; use crate::utils::convert_to_rmcp; @@ -76,6 +87,8 @@ impl RmcpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> io::Result { let program_name = program.to_string_lossy().into_owned(); let mut command = Command::new(&program); @@ -84,8 +97,11 @@ impl RmcpClient { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .args(&args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } let (transport, stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) @@ -116,12 +132,17 @@ impl RmcpClient { }) } + #[allow(clippy::too_many_arguments)] pub async fn new_streamable_http_client( server_name: &str, url: &str, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, ) -> Result { + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let initial_oauth_tokens = match bearer_token { Some(_) => None, None => match load_oauth_tokens(server_name, url, store_mode) { @@ -132,21 +153,30 @@ impl RmcpClient { } }, }; + let transport = if let Some(initial_tokens) = initial_oauth_tokens.clone() { - let (transport, oauth_persistor) = - create_oauth_transport_and_runtime(server_name, url, initial_tokens, store_mode) - .await?; + let (transport, oauth_persistor) = create_oauth_transport_and_runtime( + server_name, + url, + initial_tokens, + store_mode, + default_headers.clone(), + ) + .await?; PendingTransport::StreamableHttpWithOAuth { transport, oauth_persistor, } } else { let mut http_config = StreamableHttpClientTransportConfig::with_uri(url.to_string()); - if let Some(bearer_token) = bearer_token { + if let Some(bearer_token) = bearer_token.clone() { http_config = http_config.auth_header(bearer_token); } - let transport = StreamableHttpClientTransport::from_config(http_config); + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; + + let transport = StreamableHttpClientTransport::with_client(http_client, http_config); PendingTransport::StreamableHttp { transport } }; Ok(Self { @@ -241,6 +271,54 @@ impl RmcpClient { Ok(converted) } + pub async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resources(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resource_templates(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/templates/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params: ReadResourceRequestParam = convert_to_rmcp(params)?; + let fut = service.read_resource(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/read").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + pub async fn call_tool( &self, name: String, @@ -276,6 +354,8 @@ impl RmcpClient { } } + /// This should be called after every tool call so that if a given tool call triggered + /// a refresh of the OAuth tokens, they are persisted. async fn persist_oauth_tokens(&self) { if let Some(runtime) = self.oauth_persistor().await && let Err(error) = runtime.persist_if_needed().await @@ -290,11 +370,13 @@ async fn create_oauth_transport_and_runtime( url: &str, initial_tokens: StoredOAuthTokens, credentials_store: OAuthCredentialsStoreMode, + default_headers: HeaderMap, ) -> Result<( StreamableHttpClientTransport>, OAuthPersistor, )> { - let http_client = reqwest::Client::builder().build()?; + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; let mut oauth_state = OAuthState::new(url.to_string(), Some(http_client.clone())).await?; oauth_state diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index 6b7bd89424..17e050cb6f 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -6,6 +6,10 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use mcp_types::CallToolResult; +use reqwest::ClientBuilder; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; use rmcp::model::CallToolResult as RmcpCallToolResult; use rmcp::service::ServiceError; use serde_json::Value; @@ -70,14 +74,86 @@ where pub(crate) fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() + .copied() + .chain(env_vars.iter().map(String::as_str)) .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) .chain(extra_env.unwrap_or_default()) .collect() } +pub(crate) fn build_default_headers( + http_headers: Option>, + env_http_headers: Option>, +) -> Result { + let mut headers = HeaderMap::new(); + + if let Some(static_headers) = http_headers { + for (name, value) in static_headers { + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!("invalid HTTP header value for `{name}`: {err}"); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + + if let Some(env_headers) = env_http_headers { + for (name, env_var) in env_headers { + if let Ok(value) = env::var(&env_var) { + if value.trim().is_empty() { + continue; + } + + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + "invalid HTTP header value read from {env_var} for `{name}`: {err}" + ); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + } + + Ok(headers) +} + +pub(crate) fn apply_default_headers( + builder: ClientBuilder, + default_headers: &HeaderMap, +) -> ClientBuilder { + if default_headers.is_empty() { + builder + } else { + builder.default_headers(default_headers.clone()) + } +} + #[cfg(unix)] pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ "HOME", @@ -112,13 +188,59 @@ mod tests { use rmcp::model::CallToolResult as RmcpCallToolResult; use serde_json::json; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + unsafe { + std::env::set_var(&self.key, value); + } + } else { + unsafe { + std::env::remove_var(&self.key); + } + } + } + } + #[tokio::test] async fn create_env_honors_overrides() { let value = "custom".to_string(); - let env = create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())]))); + let env = + create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]); assert_eq!(env.get("TZ"), Some(&value)); } + #[test] + #[serial(extra_rmcp_env)] + fn create_env_includes_additional_whitelisted_variables() { + let custom_var = "EXTRA_RMCP_ENV"; + let value = "from-env"; + let _guard = EnvVarGuard::set(custom_var, value); + let env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(env.get(custom_var), Some(&value.to_string())); + } + #[test] fn convert_call_tool_result_defaults_missing_content() -> Result<()> { let structured_content = json!({ "key": "value" }); diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs new file mode 100644 index 0000000000..2117f9b14c --- /dev/null +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -0,0 +1,124 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::Duration; + +use codex_rmcp_client::RmcpClient; +use escargot::CargoBuild; +use mcp_types::ClientCapabilities; +use mcp_types::Implementation; +use mcp_types::InitializeRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResultContents; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextResourceContents; +use serde_json::json; + +const RESOURCE_URI: &str = "memo://codex/example-note"; + +fn stdio_server_bin() -> anyhow::Result { + let build = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()?; + Ok(build.path().to_path_buf()) +} + +fn init_params() -> InitializeRequestParams { + InitializeRequestParams { + capabilities: ClientCapabilities { + experimental: None, + roots: None, + sampling: None, + elicitation: Some(json!({})), + }, + client_info: Implementation { + name: "codex-test".into(), + version: "0.0.0-test".into(), + title: Some("Codex rmcp resource test".into()), + user_agent: None, + }, + protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_string(), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { + let client = RmcpClient::new_stdio_client( + stdio_server_bin()?.into(), + Vec::::new(), + None, + &[], + None, + ) + .await?; + + client + .initialize(init_params(), Some(Duration::from_secs(5))) + .await?; + + let list = client + .list_resources(None, Some(Duration::from_secs(5))) + .await?; + let memo = list + .resources + .iter() + .find(|resource| resource.uri == RESOURCE_URI) + .expect("memo resource present"); + assert_eq!( + memo, + &Resource { + annotations: None, + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + name: "example-note".to_string(), + size: None, + title: Some("Example Note".to_string()), + uri: RESOURCE_URI.to_string(), + } + ); + let templates = client + .list_resource_templates(None, Some(Duration::from_secs(5))) + .await?; + assert_eq!( + templates, + ListResourceTemplatesResult { + next_cursor: None, + resource_templates: vec![ResourceTemplate { + annotations: None, + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string() + ), + mime_type: Some("text/plain".to_string()), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + uri_template: "memo://codex/{slug}".to_string(), + }], + } + ); + + let read = client + .read_resource( + ReadResourceRequestParams { + uri: RESOURCE_URI.to_string(), + }, + Some(Duration::from_secs(5)), + ) + .await?; + let ReadResourceResultContents::TextResourceContents(text) = + read.contents.first().expect("resource contents present") + else { + panic!("expected text resource"); + }; + assert_eq!( + text, + &TextResourceContents { + text: "This is a sample MCP resource served by the rmcp test server.".to_string(), + uri: RESOURCE_URI.to_string(), + mime_type: Some("text/plain".to_string()), + } + ); + + Ok(()) +} diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml new file mode 100644 index 0000000000..4f7132883d --- /dev/null +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition = "2024" +name = "codex-stdio-to-uds" +version = { workspace = true } + +[[bin]] +name = "codex-stdio-to-uds" +path = "src/main.rs" + +[lib] +name = "codex_stdio_to_uds" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +uds_windows = { workspace = true } + +[dev-dependencies] +assert_cmd = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/stdio-to-uds/README.md b/codex-rs/stdio-to-uds/README.md new file mode 100644 index 0000000000..96ac67960b --- /dev/null +++ b/codex-rs/stdio-to-uds/README.md @@ -0,0 +1,20 @@ +# codex-stdio-to-uds + +Traditionally, there are two transport mechanisms for an MCP server: stdio and HTTP. + +This crate helps enable a third, which is UNIX domain socket, because it has the advantages that: + +- The UDS can be attached to long-running process, like an HTTP server. +- The UDS can leverage UNIX file permissions to restrict access. + +To that end, this crate provides an adapter between a UDS and stdio. The idea is that someone could start an MCP server that communicates over `/tmp/mcp.sock`. Then the user could specify this on the fly like so: + +``` +codex --config mcp_servers.example={command="codex-stdio-to-uds",args=["/tmp/mcp.sock"]} +``` + +Unfortunately, the Rust standard library does not provide support for UNIX domain sockets on Windows today even though support was added in October 2018 in Windows 10: + +https://github.com/rust-lang/rust/issues/56533 + +As a workaround, this crate leverages https://crates.io/crates/uds_windows as a dependency on Windows. diff --git a/codex-rs/stdio-to-uds/src/lib.rs b/codex-rs/stdio-to-uds/src/lib.rs new file mode 100644 index 0000000000..1190688844 --- /dev/null +++ b/codex-rs/stdio-to-uds/src/lib.rs @@ -0,0 +1,52 @@ +#![deny(clippy::print_stdout)] + +use std::io; +use std::io::Write; +use std::net::Shutdown; +use std::path::Path; +use std::thread; + +use anyhow::Context; +use anyhow::anyhow; + +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +#[cfg(windows)] +use uds_windows::UnixStream; + +/// Connects to the Unix Domain Socket at `socket_path` and relays data between +/// standard input/output and the socket. +pub fn run(socket_path: &Path) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path) + .with_context(|| format!("failed to connect to socket at {}", socket_path.display()))?; + + let mut reader = stream + .try_clone() + .context("failed to clone socket for reading")?; + + let stdout_thread = thread::spawn(move || -> io::Result<()> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + io::copy(&mut reader, &mut handle)?; + handle.flush()?; + Ok(()) + }); + + let stdin = io::stdin(); + { + let mut handle = stdin.lock(); + io::copy(&mut handle, &mut stream).context("failed to copy data from stdin to socket")?; + } + + stream + .shutdown(Shutdown::Write) + .context("failed to shutdown socket writer")?; + + let stdout_result = stdout_thread + .join() + .map_err(|_| anyhow!("thread panicked while copying socket data to stdout"))?; + stdout_result.context("failed to copy data from socket to stdout")?; + + Ok(()) +} diff --git a/codex-rs/stdio-to-uds/src/main.rs b/codex-rs/stdio-to-uds/src/main.rs new file mode 100644 index 0000000000..69987c31be --- /dev/null +++ b/codex-rs/stdio-to-uds/src/main.rs @@ -0,0 +1,19 @@ +use std::env; +use std::path::PathBuf; +use std::process; + +fn main() -> anyhow::Result<()> { + let mut args = env::args_os().skip(1); + let Some(socket_path) = args.next() else { + eprintln!("Usage: codex-stdio-to-uds "); + process::exit(1); + }; + + if args.next().is_some() { + eprintln!("Expected exactly one argument: "); + process::exit(1); + } + + let socket_path = PathBuf::from(socket_path); + codex_stdio_to_uds::run(&socket_path) +} diff --git a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs new file mode 100644 index 0000000000..11888a8134 --- /dev/null +++ b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs @@ -0,0 +1,68 @@ +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::Context; +use assert_cmd::Command; +use pretty_assertions::assert_eq; + +#[cfg(unix)] +use std::os::unix::net::UnixListener; + +#[cfg(windows)] +use uds_windows::UnixListener; + +#[test] +fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new().context("failed to create temp dir")?; + let socket_path = dir.path().join("socket"); + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!("skipping test: failed to bind unix socket: {err}"); + return Ok(()); + } + Err(err) => { + return Err(err).context("failed to bind test unix socket"); + } + }; + + let (tx, rx) = mpsc::channel(); + let server_thread = thread::spawn(move || -> anyhow::Result<()> { + let (mut connection, _) = listener + .accept() + .context("failed to accept test connection")?; + let mut received = Vec::new(); + connection + .read_to_end(&mut received) + .context("failed to read data from client")?; + tx.send(received) + .map_err(|_| anyhow::anyhow!("failed to send received bytes to test thread"))?; + connection + .write_all(b"response") + .context("failed to write response to client")?; + Ok(()) + }); + + Command::cargo_bin("codex-stdio-to-uds")? + .arg(&socket_path) + .write_stdin("request") + .assert() + .success() + .stdout("response"); + + let received = rx + .recv_timeout(Duration::from_secs(1)) + .context("server did not receive data in time")?; + assert_eq!(received, b"request"); + + let server_result = server_thread + .join() + .map_err(|_| anyhow::anyhow!("server thread panicked"))?; + server_result.context("server failed")?; + + Ok(()) +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2c6f32a20a..41ffa31030 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } @@ -78,6 +79,7 @@ tokio = { workspace = true, features = [ "signal", ] } tokio-stream = { workspace = true } +toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs new file mode 100644 index 0000000000..cc43f3294b --- /dev/null +++ b/codex-rs/tui/src/additional_dirs.rs @@ -0,0 +1,71 @@ +use codex_core::protocol::SandboxPolicy; +use std::path::PathBuf; + +/// Returns a warning describing why `--add-dir` entries will be ignored for the +/// resolved sandbox policy. The caller is responsible for presenting the +/// warning to the user (for example, printing to stderr). +pub fn add_dir_warning_message( + additional_dirs: &[PathBuf], + sandbox_policy: &SandboxPolicy, +) -> Option { + if additional_dirs.is_empty() { + return None; + } + + match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), + } +} + +fn format_warning(additional_dirs: &[PathBuf]) -> String { + let joined_paths = additional_dirs + .iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(", "); + format!( + "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ) +} + +#[cfg(test)] +mod tests { + use super::add_dir_warning_message; + use codex_core::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn returns_none_for_workspace_write() { + let sandbox = SandboxPolicy::new_workspace_write_policy(); + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn returns_none_for_danger_full_access() { + let sandbox = SandboxPolicy::DangerFullAccess; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + + #[test] + fn warns_for_read_only() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; + let message = add_dir_warning_message(&dirs, &sandbox) + .expect("expected warning for read-only sandbox"); + assert_eq!( + message, + "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." + ); + } + + #[test] + fn returns_none_when_no_additional_dirs() { + let sandbox = SandboxPolicy::ReadOnly; + let dirs: Vec = Vec::new(); + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..e1c2d062a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,3 +1,4 @@ +use crate::UpdateAction; use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -17,6 +18,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; +use codex_core::config::set_hide_full_access_warning; use codex_core::model_family::find_family_for_model; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; @@ -43,6 +45,7 @@ use tokio::sync::mpsc::unbounded_channel; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub update_action: Option, } pub(crate) struct App { @@ -71,9 +74,13 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + pub(crate) feedback: codex_feedback::CodexFeedback, + /// Set when the user confirms an update; propagated on exit. + pub(crate) pending_update_action: Option, } impl App { + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, @@ -82,6 +89,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -104,6 +112,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -126,6 +135,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new_from_existing( init, @@ -152,6 +162,8 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: feedback.clone(), + pending_update_action: None, }; let tui_events = tui.event_stream(); @@ -171,6 +183,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, }) } @@ -228,6 +241,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); @@ -327,6 +341,9 @@ impl App { AppEvent::OpenReasoningPopup { model, presets } => { self.chat_widget.open_reasoning_popup(model, presets); } + AppEvent::OpenFullAccessConfirmation { preset } => { + self.chat_widget.open_full_access_confirmation(preset); + } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match persist_model_selection(&self.config.codex_home, profile, &model, effort) @@ -372,6 +389,23 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } + AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { + self.chat_widget.set_full_access_warning_acknowledged(ack); + } + AppEvent::PersistFullAccessWarningAcknowledged => { + if let Err(err) = set_hide_full_access_warning(&self.config.codex_home, true) { + tracing::error!( + error = %err, + "failed to persist full access warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save full access confirmation preference: {err}" + )); + } + } + AppEvent::OpenApprovalsPopup => { + self.chat_widget.open_approvals_popup(); + } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } @@ -427,8 +461,9 @@ impl App { tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode - // with an empty composer. In any other state, forward Esc so the - // active UI (e.g. status indicator, modals, popups) handles it. + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, @@ -520,6 +555,8 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 5528fa7bc4..b3e948ecb3 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -82,15 +82,16 @@ impl App { /// Handle global Esc presses for backtracking when no overlay is present. pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { - // Only handle backtracking when composer is empty to avoid clobbering edits. - if self.chat_widget.composer_is_empty() { - if !self.backtrack.primed { - self.prime_backtrack(); - } else if self.overlay.is_none() { - self.open_backtrack_preview(tui); - } else if self.backtrack.overlay_preview_active { - self.step_backtrack_and_highlight(tui); - } + if !self.chat_widget.composer_is_empty() { + return; + } + + if !self.backtrack.primed { + self.prime_backtrack(); + } else if self.overlay.is_none() { + self.open_backtrack_preview(tui); + } else if self.backtrack.overlay_preview_active { + self.step_backtrack_and_highlight(tui); } } @@ -338,6 +339,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae13..402b0fa46d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use codex_common::approval_presets::ApprovalPreset; use codex_common::model_presets::ModelPreset; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; @@ -67,12 +68,26 @@ pub(crate) enum AppEvent { presets: Vec, }, + /// Open the confirmation prompt before enabling full access mode. + OpenFullAccessConfirmation { + preset: ApprovalPreset, + }, + /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update whether the full access warning prompt has been acknowledged. + UpdateFullAccessWarningAcknowledged(bool), + + /// Persist the acknowledgement flag for the full access warning prompt. + PersistFullAccessWarningAcknowledged, + + /// Re-open the approval presets popup. + OpenApprovalsPopup, + /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 6585124c52..e8ecc084c4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -16,7 +16,6 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; -use crate::text_formatting::truncate_text; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; @@ -105,9 +104,9 @@ impl ApprovalOverlay { ), }; - let header = Box::new(ColumnRenderable::new([ - Box::new(Line::from(title.bold())), - Box::new(Line::from("")), + let header = Box::new(ColumnRenderable::with([ + Line::from(title.bold()).into(), + Line::from("").into(), header, ])); @@ -160,11 +159,8 @@ impl ApprovalOverlay { } fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_user_approval_decision(lines), - ))); - } + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), decision, @@ -327,7 +323,7 @@ impl From for ApprovalRequestState { header.push(DiffSummary::new(changes, cwd).into()); Self { variant: ApprovalVariant::ApplyPatch { id }, - header: Box::new(ColumnRenderable::new(header)), + header: Box::new(ColumnRenderable::with(header)), } } } @@ -396,91 +392,11 @@ fn patch_options() -> Vec { ] } -fn build_exec_history_lines( - command: Vec, - decision: ReviewDecision, -) -> Option>> { - use ReviewDecision::*; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " this time".bold(), - ], - ) - } - ApprovedForSession => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " every time this session".bold(), - ], - ) - } - Denied => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ) - } - Abort => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "canceled".bold(), - " the request to run ".into(), - snippet, - ], - ) - } - }; - - let mut lines = Vec::new(); - let mut spans = Vec::new(); - spans.push(symbol); - spans.extend(summary); - lines.push(Line::from(spans)); - Some(lines) -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - #[cfg(test)] mod tests { use super::*; use crate::app_event::AppEvent; + use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; fn make_exec_request() -> ApprovalRequest { @@ -550,6 +466,33 @@ mod tests { ); } + #[test] + fn exec_history_cell_wraps_with_two_space_indent() { + let command = vec![ + "/bin/zsh".into(), + "-lc".into(), + "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), + ]; + let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); + let lines = cell.display_lines(28); + let rendered: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + let expected = vec![ + "✔ You approved codex to run".to_string(), + " git add tui/src/render/".to_string(), + " mod.rs tui/src/render/".to_string(), + " renderable.rs this time".to_string(), + ]; + assert_eq!(rendered, expected); + } + #[test] fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2574ded461..a7793205f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -108,7 +108,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, - context_window_percent: Option, + context_window_percent: Option, } /// Popup state – at most one can be visible at any time. @@ -149,7 +149,7 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutPrompt, + footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, context_window_percent: None, }; @@ -165,8 +165,9 @@ impl ChatComposer { .unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; self.textarea - .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + 2 + match &self.active_popup { ActivePopup::None => footer_total_height, @@ -197,7 +198,9 @@ impl ChatComposer { let [composer_rect, popup_rect] = Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); let mut textarea_rect = composer_rect; - textarea_rect.width = textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS); + textarea_rect.width = textarea_rect.width.saturating_sub( + LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ + ); textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); [composer_rect, textarea_rect, popup_rect] } @@ -313,14 +316,23 @@ impl ChatComposer { self.sync_file_search_popup(); } + pub(crate) fn clear_for_ctrl_c(&mut self) { + self.set_text_content(String::new()); + self.history.reset_navigation(); + } + /// Get the current composer text. pub(crate) fn current_text(&self) -> String { self.textarea.text().to_string() } /// Attempt to start a burst by retro-capturing recent chars before the cursor. - pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) { - let placeholder = format!("[image {width}x{height} {format_label}]"); + pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) { + let file_label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_string()); + let placeholder = format!("[{file_label} {width}x{height}]"); // Insert as an element to match large paste placeholder behavior: // styled distinctly and treated atomically for cursor/mutations. self.textarea.insert_element(&placeholder); @@ -845,10 +857,12 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); + if self.is_empty() { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } } } else { self.footer_mode = reset_mode_after_activity(self.footer_mode); @@ -958,6 +972,7 @@ impl ChatComposer { } let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); self.textarea.set_text(""); // Replace all pending pastes in the text @@ -971,6 +986,35 @@ impl ChatComposer { // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + } + } + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { Ok(expanded) => expanded, Err(err) => { @@ -1345,8 +1389,8 @@ impl ChatComposer { FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, - FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, - FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty, + FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, other => other, } } @@ -1467,7 +1511,7 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent != percent { self.context_window_percent = percent; } @@ -1755,6 +1799,35 @@ mod tests { }); } + #[test] + fn esc_hint_stays_hidden_with_draft_content() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['d']); + + assert!(!composer.is_empty()); + assert_eq!(composer.current_text(), "d"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(!composer.esc_backtrack_hint); + } + #[test] fn question_mark_only_toggles_on_first_char() { use crossterm::event::KeyCode; @@ -1779,11 +1852,11 @@ mod tests { // Toggle back to prompt mode so subsequent typing captures characters. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); type_chars_humanlike(&mut composer, &['h']); assert_eq!(composer.textarea.text(), "h"); - assert_eq!(composer.footer_mode(), FooterMode::Empty); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -1792,8 +1865,8 @@ mod tests { std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); let _ = composer.flush_paste_burst_if_due(); assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); - assert_eq!(composer.footer_mode(), FooterMode::Empty); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); } #[test] @@ -2581,7 +2654,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[image 32x16 PNG] hi"), + InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] hi"), _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -2604,7 +2677,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[image 10x5 PNG]"), + InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"), _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -2677,7 +2750,12 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(composer.attached_images.len(), 1); - assert!(composer.textarea.text().starts_with("[image 10x5 PNG]")); + assert!( + composer + .textarea + .text() + .starts_with("[image_multibyte.png 10x5]") + ); } #[test] @@ -2700,21 +2778,31 @@ mod tests { composer.handle_paste(" ".into()); composer.attach_image(path2.clone(), 10, 5, "PNG"); - let ph = composer.attached_images[0].placeholder.clone(); + let placeholder1 = composer.attached_images[0].placeholder.clone(); + let placeholder2 = composer.attached_images[1].placeholder.clone(); let text = composer.textarea.text().to_string(); - let start1 = text.find(&ph).expect("first placeholder present"); - let end1 = start1 + ph.len(); + let start1 = text.find(&placeholder1).expect("first placeholder present"); + let end1 = start1 + placeholder1.len(); composer.textarea.set_cursor(end1); // Backspace should delete the first placeholder and its mapping. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let new_text = composer.textarea.text().to_string(); - assert_eq!(1, new_text.matches(&ph).count(), "one placeholder remains"); + assert_eq!( + 0, + new_text.matches(&placeholder1).count(), + "first placeholder removed" + ); + assert_eq!( + 1, + new_text.matches(&placeholder2).count(), + "second placeholder remains" + ); assert_eq!( vec![AttachedImage { path: path2, - placeholder: "[image 10x5 PNG]".to_string() + placeholder: "[image_dup2.png 10x5]".to_string() }], composer.attached_images, "one image mapping remains" @@ -2741,7 +2829,12 @@ mod tests { let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[image 3x2 PNG] ")); + assert!( + composer + .textarea + .text() + .starts_with("[codex_tui_test_paste_image.png 3x2] ") + ); let imgs = composer.take_recent_submission_images(); assert_eq!(imgs, vec![tmp_path]); @@ -2853,6 +2946,76 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn slash_path_input_submits_without_command_error() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer + .textarea + .set_text("/Users/example/project/src/main.rs"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/Users/example/project/src/main.rs"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + + #[test] + fn slash_with_leading_space_submits_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.textarea.set_text(" /this-looks-like-a-command"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + if let InputResult::Submitted(text) = result { + assert_eq!(text, "/this-looks-like-a-command"); + } else { + panic!("expected Submitted"); + } + assert!(composer.textarea.is_empty()); + match rx.try_recv() { + Ok(event) => panic!("unexpected event: {event:?}"), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} + Err(err) => panic!("unexpected channel state: {err:?}"), + } + } + #[test] fn custom_prompt_invalid_args_reports_error() { let (tx, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 07e3df7cd7..991283a566 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -70,6 +70,12 @@ impl ChatComposerHistory { self.local_history.push(text.to_string()); } + /// Reset navigation tracking so the next Up key resumes from the latest entry. + pub fn reset_navigation(&mut self) { + self.history_cursor = None; + self.last_history_text = None; + } + /// Should Up/Down key presses be interpreted as history navigation given /// the current content and cursor position of `textarea`? pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { @@ -271,4 +277,24 @@ mod tests { history.on_entry_response(1, 1, Some("older".into())) ); } + + #[test] + fn reset_navigation_resets_cursor() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(1, 3); + history.fetched_history.insert(1, "command2".into()); + history.fetched_history.insert(2, "command3".into()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + assert_eq!(Some("command2".into()), history.navigate_up(&tx)); + + history.reset_navigation(); + assert!(history.history_cursor.is_none()); + assert!(history.last_history_text.is_none()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1842a999e3..d7501cebbc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -164,10 +164,17 @@ impl CommandPopup { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) } - CommandItem::UserPrompt(i) => ( - format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name), - "send saved prompt".to_string(), - ), + CommandItem::UserPrompt(i) => { + let prompt = &self.prompts[i]; + let description = prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()); + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + description, + ) + } }; GenericDisplayRow { name, @@ -221,6 +228,7 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn filter_includes_init_when_typing_prefix() { @@ -322,4 +330,35 @@ mod tests { "prompt with builtin name should be ignored" ); } + + #[test] + fn prompt_description_uses_frontmatter_metadata() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!( + description, + Some("Create feature branch, commit and open draft PR.") + ); + } + + #[test] + fn prompt_description_falls_back_when_missing() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!(description, Some("send saved prompt")); + } } diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs new file mode 100644 index 0000000000..9c75e32bdf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -0,0 +1,224 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::history_cell::PlainHistoryCell; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::path::PathBuf; + +use super::BottomPane; +use super::SelectionAction; +use super::SelectionItem; +use super::SelectionViewParams; + +const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +pub(crate) struct FeedbackView; + +impl FeedbackView { + pub fn show( + bottom_pane: &mut BottomPane, + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) { + bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot)); + } + + fn selection_params( + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) -> SelectionViewParams { + let header = FeedbackHeader::new(file_path); + + let thread_id = snapshot.thread_id.clone(); + + let upload_action_tread_id = thread_id.clone(); + let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + match snapshot.upload_to_sentry() { + Ok(()) => { + let issue_url = format!( + "{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}", + ); + tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ + Line::from( + "• Codex logs uploaded. Please open an issue using the following URL:", + ), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()]) + ])))); + } + Err(e) => { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload logs: {e}")), + ))); + } + } + }); + + let upload_item = SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![upload_action], + dismiss_on_select: true, + ..Default::default() + }; + + let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",); + + tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![ + Line::from("• Please open an issue using the following URL:"), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + thread_id.clone().bold(), + " in an existing issue.".into(), + ]), + ]), + ))); + }); + + let no_item = SelectionItem { + name: "No".to_string(), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }; + + let cancel_item = SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }; + + SelectionViewParams { + header: Box::new(header), + items: vec![upload_item, no_item, cancel_item], + ..Default::default() + } + } +} + +struct FeedbackHeader { + file_path: PathBuf, +} + +impl FeedbackHeader { + fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + fn lines(&self) -> Vec> { + vec![ + Line::from("Do you want to upload logs before reporting issue?".bold()), + "".into(), + Line::from( + "Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.", + ), + Line::from( + "These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.", + ), + "".into(), + Line::from(vec![ + "You can review the exact content of the logs before they’re uploaded at:".into(), + ]), + Line::from(self.file_path.display().to_string().dim()), + "".into(), + ] + } +} + +impl Renderable for FeedbackHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + for (i, line) in self.lines().into_iter().enumerate() { + let y = area.y.saturating_add(i as u16); + if y >= area.y.saturating_add(area.height) { + break; + } + let line_area = Rect::new(area.x, y, area.width, 1).intersection(area); + line.render(line_area, buf); + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines() + .iter() + .map(|line| line.desired_height(width)) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::style::user_message_style; + use codex_feedback::CodexFeedback; + use codex_protocol::ConversationId; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use tokio::sync::mpsc::unbounded_channel; + + fn buffer_to_string(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..buffer.area.width { + let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_feedback_view_header() { + let (tx_raw, _rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let snapshot = CodexFeedback::new().snapshot(Some( + ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(), + )); + let file_path = PathBuf::from("/tmp/codex-feedback.log"); + + let params = FeedbackView::selection_params(file_path.clone(), snapshot); + let view = ListSelectionView::new(params, app_event_tx); + + let width = 72; + let height = view.desired_height(width).max(1); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered = + buffer_to_string(&buf).replace(&file_path.display().to_string(), ""); + assert_snapshot!("feedback_view_render", rendered); + + let cell_style = buf[(area.x, area.y)].style(); + let expected_bg = user_message_style().bg.unwrap_or(Color::Reset); + assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg); + } +} diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index b4c5617ddf..79d7c60fa7 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -17,16 +17,16 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, - pub(crate) context_window_percent: Option, + pub(crate) context_window_percent: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum FooterMode { CtrlCReminder, - ShortcutPrompt, + ShortcutSummary, ShortcutOverlay, EscHint, - Empty, + ContextOnly, } pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { @@ -35,7 +35,7 @@ pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> Fo } match current { - FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary, _ => FooterMode::ShortcutOverlay, } } @@ -53,7 +53,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { FooterMode::EscHint | FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder - | FooterMode::Empty => FooterMode::ShortcutPrompt, + | FooterMode::ContextOnly => FooterMode::ShortcutSummary, other => other, } } @@ -72,26 +72,29 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { } fn footer_lines(props: FooterProps) -> Vec> { + // Show the context indicator on the left, appended after the primary hint + // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when + // the shortcut hint is hidden). Hide it only for the multi-line + // ShortcutOverlay. match props.mode { FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { is_task_running: props.is_task_running, })], - FooterMode::ShortcutPrompt => { - if props.is_task_running { - vec![context_window_line(props.context_window_percent)] - } else { - vec![Line::from(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ])] - } + FooterMode::ShortcutSummary => { + let mut line = context_window_line(props.context_window_percent); + line.push_span(" · ".dim()); + line.extend(vec![ + key_hint::plain(KeyCode::Char('?')).into(), + " for shortcuts".dim(), + ]); + vec![line] } FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, }), FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::Empty => Vec::new(), + FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)], } } @@ -218,19 +221,9 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let mut spans: Vec> = Vec::new(); - match percent { - Some(percent) => { - spans.push(format!("{percent}%").dim()); - spans.push(" context left".dim()); - } - None => { - spans.push(key_hint::plain(KeyCode::Char('?')).into()); - spans.push(" for shortcuts".dim()); - } - } - Line::from(spans) +fn context_window_line(percent: Option) -> Line<'static> { + let percent = percent.unwrap_or(100).clamp(0, 100); + Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -402,7 +395,7 @@ mod tests { snapshot_footer( "footer_shortcuts_default", FooterProps { - mode: FooterMode::ShortcutPrompt, + mode: FooterMode::ShortcutSummary, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, @@ -468,7 +461,7 @@ mod tests { snapshot_footer( "footer_shortcuts_context_running", FooterProps { - mode: FooterMode::ShortcutPrompt, + mode: FooterMode::ShortcutSummary, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 238f74d6e4..44d7b26402 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -37,6 +37,7 @@ pub(crate) struct SelectionItem { pub name: String, pub display_shortcut: Option, pub description: Option, + pub selected_description: Option, pub is_current: bool, pub actions: Vec, pub dismiss_on_select: bool, @@ -87,7 +88,7 @@ impl ListSelectionView { if params.title.is_some() || params.subtitle.is_some() { let title = params.title.map(|title| Line::from(title.bold())); let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = Box::new(ColumnRenderable::new([ + header = Box::new(ColumnRenderable::with([ header, Box::new(title), Box::new(subtitle), @@ -193,12 +194,16 @@ impl ListSelectionView { } else { format!("{prefix} {n}. {name_with_marker}") }; + let description = is_selected + .then(|| item.selected_description.clone()) + .flatten() + .or_else(|| item.description.clone()); GenericDisplayRow { name: display_name, display_shortcut: item.display_shortcut, match_indices: None, is_current: item.is_current, - description: item.description.clone(), + description, } }) }) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index db13a041cd..69405cd823 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -27,11 +27,13 @@ mod footer; mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; mod paste_burst; pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; +pub(crate) use feedback_view::FeedbackView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { @@ -68,7 +70,7 @@ pub(crate) struct BottomPane { status: Option, /// Queued user messages to show under the status indicator. queued_user_messages: Vec, - context_window_percent: Option, + context_window_percent: Option, } pub(crate) struct BottomPaneParams { @@ -236,7 +238,7 @@ impl BottomPane { CancellationEvent::NotHandled } else { self.view_stack.pop(); - self.set_composer_text(String::new()); + self.clear_composer_for_ctrl_c(); self.show_ctrl_c_quit_hint(); CancellationEvent::Handled } @@ -270,6 +272,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { + self.composer.clear_for_ctrl_c(); + self.request_redraw(); + } + /// Get the current composer text (for tests and programmatic checks). pub(crate) fn composer_text(&self) -> String { self.composer.current_text() @@ -350,7 +357,7 @@ impl BottomPane { } } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent == percent { return; } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index adb764b6c4..e4cc9ffefd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index db92427864..53e0aee4cf 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1938 expression: terminal.backend() --- " " @@ -12,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index a805fbf9f4..49ffb0d4c8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap index 750ba10192..7ecc5bba71 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap index 8c2d2bfd39..9cad17b864 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap index 5ddf39e315..2fce42cc26 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap index 8c2d2bfd39..9cad17b864 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap index dfeb98d619..67e616e917 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -10,3 +10,4 @@ expression: terminal.backend() " " " " " " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap index 5ddf39e315..2fce42cc26 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 4237a17ae0..6b018021ec 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 3edfc2ce22..40098faee0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 402740b8fa..498ed76936 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 0000000000..bafa94b09d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 817adb66d9..31a1b743b8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- " ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 50bf9b622d..9979372a1b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- " ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap index 172432a38e..b2333b025f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- " esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap index 69d79d5327..20f9b178b4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- " esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap index 77b3796c20..d05ac90a91 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 72% context left " +" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index b2de215488..c95a5dc0b3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" ? for shortcuts " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index ae96888f56..ecf9258330 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -8,4 +8,4 @@ expression: "render_snapshot(&pane, area)" › Ask Codex to do anything - ? for shortcuts + 100% context left · ? for sh diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap index 310c32b40c..52f96e8557 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area1)" --- -› Ask Codex to do an +› Ask Codex to do a diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap index ea0beeedf3..964bf7ed87 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap @@ -3,4 +3,4 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area2)" --- -› Ask Codex to do an +› Ask Codex to do a diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 269fa345e0..2571f7bb86 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -26,6 +26,7 @@ pub(crate) struct TextArea { wrap_cache: RefCell>, preferred_col: Option, elements: Vec, + kill_buffer: String, } #[derive(Debug, Clone)] @@ -48,6 +49,7 @@ impl TextArea { wrap_cache: RefCell::new(None), preferred_col: None, elements: Vec::new(), + kill_buffer: String::new(), } } @@ -57,6 +59,7 @@ impl TextArea { self.wrap_cache.replace(None); self.preferred_col = None; self.elements.clear(); + self.kill_buffer.clear(); } pub fn text(&self) -> &str { @@ -305,6 +308,13 @@ impl TextArea { } => { self.kill_to_end_of_line(); } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.yank(); + } // Cursor movement KeyEvent { @@ -437,7 +447,7 @@ impl TextArea { pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); - self.replace_range(start..self.cursor_pos, ""); + self.kill_range(start..self.cursor_pos); } /// Delete text to the right of the cursor using "word" semantics. @@ -448,32 +458,63 @@ impl TextArea { pub fn delete_forward_word(&mut self) { let end = self.end_of_next_word(); if end > self.cursor_pos { - self.replace_range(self.cursor_pos..end, ""); + self.kill_range(self.cursor_pos..end); } } pub fn kill_to_end_of_line(&mut self) { let eol = self.end_of_current_line(); - if self.cursor_pos == eol { + let range = if self.cursor_pos == eol { if eol < self.text.len() { - self.replace_range(self.cursor_pos..eol + 1, ""); + Some(self.cursor_pos..eol + 1) + } else { + None } } else { - self.replace_range(self.cursor_pos..eol, ""); + Some(self.cursor_pos..eol) + }; + + if let Some(range) = range { + self.kill_range(range); } } pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); - if self.cursor_pos == bol { - if bol > 0 { - self.replace_range(bol - 1..bol, ""); - } + let range = if self.cursor_pos == bol { + if bol > 0 { Some(bol - 1..bol) } else { None } } else { - self.replace_range(bol..self.cursor_pos, ""); + Some(bol..self.cursor_pos) + }; + + if let Some(range) = range { + self.kill_range(range); } } + pub fn yank(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn kill_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + + let removed = self.text[range.clone()].to_string(); + if removed.is_empty() { + return; + } + + self.kill_buffer = removed; + self.replace_range_raw(range, ""); + } + /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); @@ -799,16 +840,22 @@ impl TextArea { } pub(crate) fn beginning_of_previous_word(&self) -> usize { - if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace()) - { - let candidate = self.text[..first_non_ws] - .rfind(|c: char| c.is_whitespace()) - .map(|i| i + 1) - .unwrap_or(0); - self.adjust_pos_out_of_elements(candidate, true) - } else { - 0 - } + let prefix = &self.text[..self.cursor_pos]; + let Some((first_non_ws_idx, _)) = prefix + .char_indices() + .rev() + .find(|&(_, ch)| !ch.is_whitespace()) + else { + return 0; + }; + let before = &prefix[..first_non_ws_idx]; + let candidate = before + .char_indices() + .rev() + .find(|&(_, ch)| ch.is_whitespace()) + .map(|(idx, ch)| idx + ch.len_utf8()) + .unwrap_or(0); + self.adjust_pos_out_of_elements(candidate, true) } pub(crate) fn end_of_next_word(&self) -> usize { @@ -1192,6 +1239,39 @@ mod tests { assert_eq!(t.cursor(), elem_range.start); } + #[test] + fn yank_restores_last_kill() { + let mut t = ta_with("hello"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + t.yank(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), 11); + + let mut t = ta_with("hello"); + t.set_cursor(5); + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + } + #[test] fn cursor_left_and_right_handle_graphemes() { let mut t = ta_with("a👍b"); @@ -1262,6 +1342,15 @@ mod tests { assert_eq!(t.cursor(), 6); } + #[test] + fn delete_backward_word_handles_narrow_no_break_space() { + let mut t = ta_with("32\u{202F}AM"); + t.set_cursor(t.text().len()); + t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); + pretty_assertions::assert_eq!(t.cursor(), t.text().len()); + } + #[test] fn delete_forward_word_with_without_alt_modifier() { let mut t = ta_with("hello world"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..8dc6454e4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7,6 +7,7 @@ use codex_core::config::Config; use codex_core::config_types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -53,8 +54,13 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -81,6 +87,8 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -134,9 +142,9 @@ impl RateLimitWarningState { fn take_warnings( &mut self, secondary_used_percent: Option, - secondary_window_minutes: Option, + secondary_window_minutes: Option, primary_used_percent: Option, - primary_window_minutes: Option, + primary_window_minutes: Option, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); @@ -187,12 +195,14 @@ impl RateLimitWarningState { } } -pub(crate) fn get_limits_duration(windows_minutes: u64) -> String { - const MINUTES_PER_HOUR: u64 = 60; - const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: u64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: u64 = 3; +pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + const MINUTES_PER_HOUR: i64 = 60; + const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + const ROUNDING_BIAS_MINUTES: i64 = 3; + + let windows_minutes = windows_minutes.max(0); if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); @@ -216,6 +226,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, } pub(crate) struct ChatWidget { @@ -240,6 +251,10 @@ pub(crate) struct ChatWidget { reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, + // Current status header shown in the status indicator. + current_status_header: String, + // Previous status header to restore after a transient stream retry. + retry_status_header: Option, conversation_id: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -260,6 +275,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, } struct UserMessage { @@ -303,6 +320,14 @@ impl ChatWidget { } } + fn set_status_header(&mut self, header: String) { + if self.current_status_header == header { + return; + } + self.current_status_header = header.clone(); + self.bottom_pane.update_status_header(header); + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane @@ -352,7 +377,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. - self.bottom_pane.update_status_header(header); + self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. } @@ -386,6 +411,8 @@ impl ChatWidget { fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); + self.retry_status_header = None; + self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); @@ -621,9 +648,10 @@ impl ChatWidget { } fn on_stream_error(&mut self, message: String) { - // Show stream errors in the transcript so users see retry/backoff info. - self.add_to_history(history_cell::new_stream_error_event(message)); - self.request_redraw(); + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status_header.clone()); + } + self.set_status_header(message); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -894,6 +922,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -928,6 +957,8 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, @@ -938,6 +969,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -955,6 +987,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -991,6 +1024,8 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, @@ -1001,6 +1036,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -1015,20 +1051,20 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, + code: KeyCode::Char(c), + modifiers, kind: KeyEventKind::Press, .. - } => { + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { self.on_ctrl_c(); return; } KeyEvent { - code: KeyCode::Char('v'), - modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char(c), + modifiers, kind: KeyEventKind::Press, .. - } => { + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => { if let Ok((path, info)) = paste_image_to_temp_png() { self.attach_image(path, info.width, info.height, info.encoded_format.label()); } @@ -1104,10 +1140,37 @@ impl ChatWidget { return; } match cmd { + SlashCommand::Feedback => { + let snapshot = self.feedback.snapshot(self.conversation_id); + match snapshot.save_to_temp_file() { + Ok(path) => { + crate::bottom_pane::FeedbackView::show( + &mut self.bottom_pane, + path, + snapshot, + ); + self.request_redraw(); + } + Err(e) => { + self.add_to_history(history_cell::new_error_event(format!( + "Failed to save feedback logs: {e}" + ))); + self.request_redraw(); + } + } + } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, None); + return; + } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_text_message(INIT_PROMPT.to_string()); } @@ -1699,7 +1762,6 @@ impl ChatWidget { } else { default_choice }; - let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; @@ -1722,6 +1784,14 @@ impl ChatWidget { .map(|preset| preset.description.to_string()) }); + let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits."; + let show_warning = model_slug == "gpt-5-codex" && effort == ReasoningEffortConfig::High; + let selected_description = show_warning.then(|| { + description + .as_ref() + .map_or(warning.to_string(), |d| format!("{d}\n{warning}")) + }); + let model_for_action = model_slug.clone(); let effort_for_action = choice.stored; let actions: Vec = vec![Box::new(move |tx| { @@ -1751,6 +1821,7 @@ impl ChatWidget { items.push(SelectionItem { name: effort_label, description, + selected_description, is_current: is_current_model && choice.stored == highlight_choice, actions, dismiss_on_select: true, @@ -1758,9 +1829,13 @@ impl ChatWidget { }); } + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Reasoning Level".to_string()), - subtitle: Some(format!("Reasoning for model {model_slug}")), + header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() @@ -1776,22 +1851,24 @@ impl ChatWidget { for preset in presets.into_iter() { let is_current = current_approval == preset.approval && current_sandbox == preset.sandbox; - let approval = preset.approval; - let sandbox = preset.sandbox.clone(); let name = preset.label.to_string(); let description = Some(preset.description.to_string()); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox.clone()), - model: None, - effort: None, - summary: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone())); - })]; + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }; items.push(SelectionItem { name, description, @@ -1810,6 +1887,89 @@ impl ChatWidget { }); } + fn approval_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + ) -> Vec { + vec![Box::new(move |tx| { + let sandbox_clone = sandbox.clone(); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox_clone.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + })] + } + + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { + let approval = preset.approval; + let sandbox = preset.sandbox; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { self.config.approval_policy = policy; @@ -1820,6 +1980,10 @@ impl ChatWidget { self.config.sandbox_policy = policy; } + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; @@ -1906,7 +2070,13 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + ev.resources, + ev.resource_templates, + &ev.auth_statuses, + )); } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 0000000000..1d612966de --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Codex can read files and answer questions. Codex + requires approval to make edits, run commands, or + access network + 2. Auto Codex can read files, make edits, and run commands + in the workspace. Codex requires approval to work + outside the workspace or access network + 3. Full Access Codex can read files, make edits, and run commands + with network access, without approval. Exercise + caution + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap index e3121774f4..77738439a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1152 expression: "lines[start_idx..].join(\"\\n\")" --- • I need to check the codex-rs repository to explain why the project's binaries @@ -33,7 +32,7 @@ expression: "lines[start_idx..].join(\"\\n\")" │ … +1 lines └ --- ansi-escape/Cargo.toml [package] - … +7 lines + … +243 lines ] } tracing = { version diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index b83bf8a91a..c3bdf60bd2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -13,3 +13,5 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits + + 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap index 524e096824..f04e1f078a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -1,5 +1,7 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 495 expression: lines_to_single_string(&aborted_long) --- -✗ You canceled the request to run echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... +✗ You canceled the request to run echo + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 0000000000..71dac5f590 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 664ec0f50f..d2ef858a67 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -2,12 +2,13 @@ source: tui/src/chatwidget/tests.rs expression: popup --- - Select Reasoning Level - Reasoning for model gpt-5-codex + Select Reasoning Level for gpt-5-codex 1. Low Fastest responses with limited reasoning 2. Medium (default) Dynamically adjusts reasoning based on the task › 3. High (current) Maximizes reasoning depth for complex or ambiguous problems + ⚠ High reasoning effort can quickly consume Plus plan + rate limits. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index e288807b00..9fbebfb500 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -9,4 +9,4 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" ? for shortcuts " +" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap new file mode 100644 index 0000000000..6a49cb253c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + ✨ New version available! Would you like to update? + + Full release notes: https://github.com/openai/codex/releases/latest + + +› 1. Yes, update now + 2. No, not now + 3. Don't remind me + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2055445059..dd6e59af42 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -49,6 +50,7 @@ use std::io::BufRead; use std::io::BufReader; use std::path::PathBuf; use tempfile::NamedTempFile; +use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; @@ -233,6 +235,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + feedback: codex_feedback::CodexFeedback::new(), }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -276,6 +279,8 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, conversation_id: None, frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, @@ -287,6 +292,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), }; (widget, rx, op_rx) } @@ -390,6 +396,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-short".into(), @@ -431,6 +438,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-multi".into(), @@ -478,6 +486,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-long".into(), @@ -503,10 +512,7 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) { // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command) - .into_iter() - .map(Into::into) - .collect(); + let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); chat.handle_codex_event(Event { id: call_id.to_string(), msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { @@ -674,6 +680,18 @@ fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); } +#[test] +fn ctrl_c_shutdown_ignores_caps_lock() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + other => panic!("expected Op::Shutdown, got {other:?}"), + } +} + #[test] fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -755,6 +773,38 @@ fn review_popup_custom_prompt_action_sends_event() { assert!(found, "expected OpenReviewCustomPrompt event to be sent"); } +#[test] +fn slash_init_skips_when_project_doc_exists() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let tempdir = tempdir().unwrap(); + let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); + std::fs::write(&existing_path, "existing instructions").unwrap(); + chat.config.cwd = tempdir.path().to_path_buf(); + + chat.dispatch_command(SlashCommand::Init); + + match op_rx.try_recv() { + Err(TryRecvError::Empty) => {} + other => panic!("expected no Codex op to be sent, got {other:?}"), + } + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), + "info message should mention the existing file: {rendered:?}" + ); + assert!( + rendered.contains("Skipping /init"), + "info message should explain why /init was skipped: {rendered:?}" + ); + assert_eq!( + std::fs::read_to_string(existing_path).unwrap(), + "existing instructions" + ); +} + /// The commit picker shows only commit subjects (no timestamps). #[test] fn review_commit_picker_shows_subjects_without_timestamps() { @@ -1040,6 +1090,31 @@ fn model_selection_popup_snapshot() { assert_snapshot!("model_selection_popup", popup); } +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.notices.hide_full_access_warning = None; + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("approvals_selection_popup", popup); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .expect("full access preset"); + chat.open_full_access_confirmation(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("full_access_confirmation_popup", popup); +} + #[test] fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1191,10 +1266,7 @@ async fn binary_size_transcript_snapshot() { call_id: e.call_id.clone(), command: e.command, cwd: e.cwd, - parsed_cmd: parsed_cmd - .into_iter() - .map(std::convert::Into::into) - .collect(), + parsed_cmd, }), } } @@ -1309,6 +1381,7 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve".into(), @@ -1352,6 +1425,7 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve-noreason".into(), @@ -1557,10 +1631,11 @@ fn status_widget_and_approval_modal_snapshot() { let ev = ExecApprovalRequestEvent { call_id: "call-approve-exec".into(), command: vec!["echo".into(), "hello world".into()], - cwd: std::path::PathBuf::from("/tmp"), + cwd: PathBuf::from("/tmp"), reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve-exec".into(), @@ -2044,9 +2119,10 @@ fn plan_update_renders_history_cell() { } #[test] -fn stream_error_is_rendered_to_history() { +fn stream_error_updates_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…"; + chat.bottom_pane.set_task_running(true); + let msg = "Re-connecting... 2/5"; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { @@ -2055,11 +2131,15 @@ fn stream_error_is_rendered_to_history() { }); let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected a history cell for StreamError"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!(blob.contains('⚠')); - assert!(blob.contains("stream error:")); - assert!(blob.contains("idle timeout waiting for SSE")); + assert!( + cells.is_empty(), + "expected no history cell for StreamError event" + ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), msg); } #[test] @@ -2222,17 +2302,16 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), parsed_cmd: vec![ - codex_core::parse_command::ParsedCommand::Search { + ParsedCommand::Search { query: Some("Change Approved".into()), path: None, cmd: "rg \"Change Approved\"".into(), - } - .into(), - codex_core::parse_command::ParsedCommand::Read { + }, + ParsedCommand::Read { name: "diff_render.rs".into(), cmd: "cat diff_render.rs".into(), - } - .into(), + path: "diff_render.rs".into(), + }, ], }), }); diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index ee2a0355da..d86040b5c9 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,4 +1,5 @@ use clap::Parser; +use clap::ValueHint; use codex_common::ApprovalModeCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; @@ -72,6 +73,10 @@ pub struct Cli { #[arg(long = "search", default_value_t = false)] pub web_search: bool, + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] + pub add_dir: Vec, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index bbd8900632..bd37fe8155 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -411,28 +411,35 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { let next_buffer = &b.content; let mut updates = vec![]; - let mut last_nonblank_column = vec![0; a.area.height as usize]; + let mut last_nonblank_columns = vec![0; a.area.height as usize]; for y in 0..a.area.height { let row_start = y as usize * a.area.width as usize; let row_end = row_start + a.area.width as usize; let row = &next_buffer[row_start..row_end]; let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); - let x = row - .iter() - .rposition(|cell| { - cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() - }) - .unwrap_or(0); - last_nonblank_column[y as usize] = x as u16; - if x < (a.area.width as usize).saturating_sub(1) { - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); - updates.push(DrawCommand::ClearToEnd { - x: x_abs, - y: y_abs, - bg, - }); + // Scan the row to find the rightmost column that still matters: any non-space glyph, + // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. + // Multi-width glyphs extend that region through their full displayed width. + // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win + // versus emitting multiple space Put commands. + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = cell.symbol().width(); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + (width.saturating_sub(1)); + } + column += width.max(1); // treat zero-width symbols as width 1 } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + + last_nonblank_columns[y as usize] = last_nonblank_column as u16; } // Cells invalidated by drawing/replacing preceding multi-width characters: @@ -444,7 +451,7 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { let (x, y) = a.pos_of(i); let row = i / a.area.width as usize; - if x <= last_nonblank_column[row] { + if x <= last_nonblank_columns[row] { updates.push(DrawCommand::Put { x, y, @@ -592,6 +599,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use ratatui::layout::Rect; + use ratatui::style::Style; #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { @@ -620,4 +628,22 @@ mod tests { "expected diff_buffers to update the final cell; commands: {commands:?}", ); } + + #[test] + fn diff_buffers_clear_to_end_starts_after_wide_char() { + let area = Rect::new(0, 0, 10, 1); + let mut previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + previous.set_string(0, 0, "中文", Style::default()); + next.set_string(0, 0, "中", Style::default()); + + let commands = diff_buffers(&previous, &next); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), + "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" + ); + } } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index a4b7c87f73..665fad15f7 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -72,7 +72,7 @@ impl From for Box { ))); } - Box::new(ColumnRenderable::new(rows)) + Box::new(ColumnRenderable::with(rows)) } } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index a3cc8cacae..98d9d07256 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -48,10 +48,16 @@ pub(crate) fn new_active_exec_command( }) } +#[derive(Clone)] +pub(crate) struct OutputLines { + pub(crate) lines: Vec>, + pub(crate) omitted: Option, +} + pub(crate) fn output_lines( output: Option<&CommandOutput>, params: OutputLinesParams, -) -> Vec> { +) -> OutputLines { let OutputLinesParams { only_err, include_angle_pipe, @@ -63,9 +69,19 @@ pub(crate) fn output_lines( stderr, .. } = match output { - Some(output) if only_err && output.exit_code == 0 => return vec![], + Some(output) if only_err && output.exit_code == 0 => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } Some(output) => output, - None => return vec![], + None => { + return OutputLines { + lines: Vec::new(), + omitted: None, + }; + } }; let src = if *exit_code == 0 { stdout } else { stderr }; @@ -73,7 +89,7 @@ pub(crate) fn output_lines( let total = lines.len(); let limit = TOOL_CALL_MAX_LINES; - let mut out = Vec::new(); + let mut out: Vec> = Vec::new(); let head_end = total.min(limit); for (i, raw) in lines[..head_end].iter().enumerate() { @@ -93,6 +109,11 @@ pub(crate) fn output_lines( } let show_ellipsis = total > 2 * limit; + let omitted = if show_ellipsis { + Some(total - 2 * limit) + } else { + None + }; if show_ellipsis { let omitted = total - 2 * limit; out.push(format!("… +{omitted} lines").into()); @@ -114,7 +135,10 @@ pub(crate) fn output_lines( out.push(line); } - out + OutputLines { + lines: out, + omitted, + } } pub(crate) fn spinner(start_time: Option) -> Span<'static> { @@ -371,7 +395,7 @@ impl ExecCell { } if let Some(output) = call.output.as_ref() { - let raw_output_lines = output_lines( + let raw_output = output_lines( Some(output), OutputLinesParams { only_err: false, @@ -380,15 +404,18 @@ impl ExecCell { }, ); - if raw_output_lines.is_empty() { + if raw_output.lines.is_empty() { lines.extend(prefix_lines( vec![Line::from("(no output)".dim())], Span::from(layout.output_block.initial_prefix).dim(), Span::from(layout.output_block.subsequent_prefix), )); } else { - let trimmed_output = - Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines); + let trimmed_output = Self::truncate_lines_middle( + &raw_output.lines, + layout.output_max_lines, + raw_output.omitted, + ); let mut wrapped_output: Vec> = Vec::new(); let output_wrap_width = layout.output_block.wrap_width(width); @@ -427,7 +454,11 @@ impl ExecCell { out } - fn truncate_lines_middle(lines: &[Line<'static>], max: usize) -> Vec> { + fn truncate_lines_middle( + lines: &[Line<'static>], + max: usize, + omitted_hint: Option, + ) -> Vec> { if max == 0 { return Vec::new(); } @@ -435,7 +466,17 @@ impl ExecCell { return lines.to_vec(); } if max == 1 { - return vec![Self::ellipsis_line(lines.len())]; + // Carry forward any previously omitted count and add any + // additionally hidden content lines from this truncation. + let base = omitted_hint.unwrap_or(0); + // When an existing ellipsis is present, `lines` already includes + // that single representation line; exclude it from the count of + // additionally omitted content lines. + let extra = lines + .len() + .saturating_sub(usize::from(omitted_hint.is_some())); + let omitted = base + extra; + return vec![Self::ellipsis_line(omitted)]; } let head = (max - 1) / 2; @@ -446,8 +487,12 @@ impl ExecCell { out.extend(lines[..head].iter().cloned()); } - let omitted = lines.len().saturating_sub(head + tail); - out.push(Self::ellipsis_line(omitted)); + let base = omitted_hint.unwrap_or(0); + let additional = lines + .len() + .saturating_sub(head + tail) + .saturating_sub(usize::from(omitted_hint.is_some())); + out.push(Self::ellipsis_line(base + additional)); if tail > 0 { out.extend(lines[lines.len() - tail..].iter().cloned()); diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 5c9a0d039a..70ec4e84c7 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -8,9 +8,17 @@ pub(crate) fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } +fn is_login_shell_with_lc(shell: &str) -> bool { + let shell_name = std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell); + matches!(shell_name, "bash" | "zsh") +} + pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { match command { - [first, second, third] if first == "bash" && second == "-lc" => third.clone(), + [first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(), _ => escape_command(command), } } @@ -46,8 +54,24 @@ mod tests { #[test] fn test_strip_bash_lc_and_escape() { + // Test bash let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; let cmdline = strip_bash_lc_and_escape(&args); assert_eq!(cmdline, "echo hello"); + + // Test zsh + let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to zsh + let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); + + // Test absolute path to bash + let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; + let cmdline = strip_bash_lc_and_escape(&args); + assert_eq!(cmdline, "echo hello"); } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7c4d18e1a3..5df687b998 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -6,21 +6,26 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES; use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; +use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::MarkdownCitationContext; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; +use crate::render::line_utils::push_owned_lines; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; +use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::protocol::FileChange; +use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -30,7 +35,9 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; +use mcp_types::Resource; use mcp_types::ResourceLink; +use mcp_types::ResourceTemplate; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Style; @@ -110,7 +117,11 @@ impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); - let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); + let wrap_width = width + .saturating_sub( + LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ + ) + .max(1); let style = user_message_style(); @@ -121,7 +132,8 @@ impl HistoryCell for UserHistoryCell { .map(|l| Line::from(l).style(style)) .collect::>(), // Wrap algorithm matches textarea.rs. - RtOptions::new(wrap_width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); lines.push(Line::from("").style(style)); @@ -261,6 +273,126 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct PrefixedWrappedHistoryCell { + text: Text<'static>, + initial_prefix: Line<'static>, + subsequent_prefix: Line<'static>, +} + +impl PrefixedWrappedHistoryCell { + pub(crate) fn new( + text: impl Into>, + initial_prefix: impl Into>, + subsequent_prefix: impl Into>, + ) -> Self { + Self { + text: text.into(), + initial_prefix: initial_prefix.into(), + subsequent_prefix: subsequent_prefix.into(), + } + } +} + +impl HistoryCell for PrefixedWrappedHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + let opts = RtOptions::new(width.max(1) as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()); + let wrapped = word_wrap_lines(&self.text, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +fn truncate_exec_snippet(full_cmd: &str) -> String { + let mut snippet = match full_cmd.split_once('\n') { + Some((first, _)) => format!("{first} ..."), + None => full_cmd.to_string(), + }; + snippet = truncate_text(&snippet, 80); + snippet +} + +fn exec_snippet(command: &[String]) -> String { + let full_cmd = strip_bash_lc_and_escape(command); + truncate_exec_snippet(&full_cmd) +} + +pub fn new_approval_decision_cell( + command: Vec, + decision: codex_core::protocol::ReviewDecision, +) -> Box { + use codex_core::protocol::ReviewDecision::*; + + let (symbol, summary): (Span<'static>, Vec>) = match decision { + Approved => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " this time".bold(), + ], + ) + } + ApprovedForSession => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " every time this session".bold(), + ], + ) + } + Denied => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "did not approve".bold(), + " codex to run ".into(), + snippet, + ], + ) + } + Abort => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "canceled".bold(), + " the request to run ".into(), + snippet, + ], + ) + } + }; + + Box::new(PrefixedWrappedHistoryCell::new( + Line::from(summary), + symbol, + " ", + )) +} + /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { @@ -446,10 +578,6 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { UserHistoryCell { message } } -pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHistoryCell { - PlainHistoryCell { lines } -} - #[derive(Debug)] struct SessionHeaderHistoryCell { version: &'static str, @@ -726,7 +854,12 @@ impl HistoryCell for McpToolCallCell { } } Err(err) => { - let err_line = Line::from(format!("Error: {err}").dim()); + let err_text = format_and_truncate_tool_result( + &format!("Error: {err}"), + TOOL_CALL_MAX_LINES, + width as usize, + ); + let err_line = Line::from(err_text.dim()); let wrapped = word_wrap_line( &err_line, RtOptions::new((width as usize).saturating_sub(4)) @@ -849,7 +982,10 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { /// Render MCP tools grouped by connection using the fully-qualified tool names. pub(crate) fn new_mcp_tools_output( config: &Config, - tools: std::collections::HashMap, + tools: HashMap, + resources: HashMap>, + resource_templates: HashMap>, + auth_statuses: &HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ "/mcp".magenta().into(), @@ -865,7 +1001,7 @@ pub(crate) fn new_mcp_tools_output( } for (server, cfg) in config.mcp_servers.iter() { - let prefix = format!("{server}__"); + let prefix = format!("mcp__{server}__"); let mut names: Vec = tools .keys() .filter(|k| k.starts_with(&prefix)) @@ -873,10 +1009,27 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); + let status = auth_statuses + .get(server.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported); lines.push(vec![" • Server: ".into(), server.clone().into()].into()); + let status_line = if cfg.enabled { + vec![" • Status: ".into(), "enabled".green()].into() + } else { + vec![" • Status: ".into(), "disabled".red()].into() + }; + lines.push(status_line); + lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_suffix = if args.is_empty() { String::new() } else { @@ -885,31 +1038,113 @@ pub(crate) fn new_mcp_tools_output( let cmd_display = format!("{command}{args_suffix}"); lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); - if let Some(env) = env.as_ref() - && !env.is_empty() - { - let mut env_pairs: Vec = - env.iter().map(|(k, v)| format!("{k}={v}")).collect(); - env_pairs.sort(); - lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + if let Some(cwd) = cwd.as_ref() { + lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into()); + } + + let env_display = format_env_display(env.as_ref(), env_vars); + if env_display != "-" { + lines.push(vec![" • Env: ".into(), env_display.into()].into()); } } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + if let Some(headers) = http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join(", "); + lines.push(vec![" • HTTP headers: ".into(), display.into()].into()); + } + if let Some(headers) = env_http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, env_var)| format!("{name}={env_var}")) + .collect::>() + .join(", "); + lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into()); + } } } + if !cfg.enabled { + let disabled = "(disabled)".red(); + lines.push(vec![" • Tools: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resources: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resource templates: ".into(), disabled].into()); + lines.push(Line::from("")); + continue; + } + if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } + + let server_resources: Vec = + resources.get(server.as_str()).cloned().unwrap_or_default(); + if server_resources.is_empty() { + lines.push(" • Resources: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resources: ".into()]; + + for (idx, resource) in server_resources.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = resource.title.as_ref().unwrap_or(&resource.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", resource.uri).dim()); + } + + lines.push(spans.into()); + } + + let server_templates: Vec = resource_templates + .get(server.as_str()) + .cloned() + .unwrap_or_default(); + if server_templates.is_empty() { + lines.push(" • Resource templates: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resource templates: ".into()]; + + for (idx, template) in server_templates.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = template.title.as_ref().unwrap_or(&template.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", template.uri_template).dim()); + } + + lines.push(spans.into()); + } + lines.push(Line::from("")); } PlainHistoryCell { lines } } - pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { @@ -928,11 +1163,6 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } -pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { - let lines: Vec> = vec![vec![padded_emoji("⚠️").into(), message.dim()].into()]; - PlainHistoryCell { lines } -} - /// Render a user‑friendly plan update styled like a checkbox todo list. pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { let UpdatePlanArgs { explanation, plan } = update; @@ -1019,7 +1249,7 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { lines.push(Line::from("✘ Failed to apply patch".magenta().bold())); if !stderr.trim().is_empty() { - lines.extend(output_lines( + let output = output_lines( Some(&CommandOutput { exit_code: 1, stdout: String::new(), @@ -1031,7 +1261,8 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { include_angle_pipe: true, include_prefix: true, }, - )); + ); + lines.extend(output.lines); } PlainHistoryCell { lines } @@ -1188,6 +1419,29 @@ mod tests { assert_eq!(cell.desired_transcript_height(80), 1); } + #[test] + fn prefixed_wrapped_history_cell_indents_wrapped_lines() { + let summary = Line::from(vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + "echo something really long to ensure wrapping happens".dim(), + " this time".bold(), + ]); + let cell = PrefixedWrappedHistoryCell::new(summary, "✔ ".green(), " "); + let rendered = render_lines(&cell.display_lines(24)); + assert_eq!( + rendered, + vec![ + "✔ You approved codex".to_string(), + " to run echo something".to_string(), + " really long to ensure".to_string(), + " wrapping happens this".to_string(), + " time".to_string(), + ] + ); + } + #[test] fn active_mcp_tool_call_snapshot() { let invocation = McpInvocation { @@ -1433,10 +1687,12 @@ mod tests { ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }, ], output: None, @@ -1493,6 +1749,7 @@ mod tests { vec![ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }], ) .unwrap(); @@ -1514,6 +1771,7 @@ mod tests { vec![ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }], ) .unwrap(); @@ -1542,14 +1800,17 @@ mod tests { ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ], output: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5d7188c036..6b13d6f91f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3,6 +3,7 @@ // alternate‑screen mode starts; that file opts‑out locally via `allow`. #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] +use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; @@ -11,14 +12,11 @@ use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigToml; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; @@ -27,8 +25,10 @@ use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; +mod additional_dirs; mod app; mod app_backtrack; mod app_event; @@ -59,6 +59,7 @@ mod pager_overlay; pub mod public_widgets; mod render; mod resume_picker; +mod selection_list; mod session_log; mod shimmer; mod slash_command; @@ -70,7 +71,38 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; +mod update_prompt; mod version; + +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(&self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(&self) -> String { + let (command, args) = self.command_args(); + let args_str = args.join(" "); + format!("{command} {args_str}") + } +} + mod wrapping; #[cfg(test)] @@ -132,6 +164,7 @@ pub async fn run_main( // canonicalize the cwd let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)); + let additional_dirs = cli.add_dir.clone(); let overrides = ConfigOverrides { model, @@ -148,6 +181,7 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + additional_writable_roots: additional_dirs, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; @@ -160,52 +194,23 @@ pub async fn run_main( } }; - let mut config = { - // Load configuration and support CLI overrides. + let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + if let Some(warning) = add_dir_warning_message(&cli.add_dir, &config.sandbox_policy) { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides).await { - Ok(config) => config, - Err(err) => { - eprintln!("Error loading configuration: {err}"); - std::process::exit(1); - } + { + eprintln!("Error adding directories: {warning}"); + std::process::exit(1); } - }; + } - // we load config.toml here to determine project state. #[allow(clippy::print_stderr)] - let config_toml = { - let codex_home = match find_codex_home() { - Ok(codex_home) => codex_home, - Err(err) => { - eprintln!("Error finding codex home: {err}"); - std::process::exit(1); - } - }; - - match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await { - Ok(config_toml) => config_toml, - Err(err) => { - eprintln!("Error loading config.toml: {err}"); - std::process::exit(1); - } - } - }; - - let cli_profile_override = cli.config_profile.clone(); - let active_profile = cli_profile_override - .clone() - .or_else(|| config_toml.profile.clone()); - - let should_show_trust_screen = determine_repo_trust_state( - &mut config, - &config_toml, - approval_policy, - sandbox_mode, - cli_profile_override, - )?; + if let Err(err) = enforce_login_restrictions(&config).await { + eprintln!("{err}"); + std::process::exit(1); + } + let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -234,13 +239,21 @@ pub async fn run_main( }) }; - // Build layered subscriber: let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); + let feedback = codex_feedback::CodexFeedback::new(); + let targets = Targets::new().with_default(tracing::Level::TRACE); + + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(targets); + if cli.oss { codex_ollama::ensure_oss_ready(&config) .await @@ -265,24 +278,36 @@ pub async fn run_main( let _ = tracing_subscriber::registry() .with(file_layer) + .with(feedback_layer) .with(otel_layer) .try_init(); } else { - let _ = tracing_subscriber::registry().with(file_layer).try_init(); + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(feedback_layer) + .try_init(); }; - run_ratatui_app(cli, config, active_profile, should_show_trust_screen) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + active_profile, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, - config: Config, + initial_config: Config, + overrides: ConfigOverrides, + cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, - should_show_trust_screen: bool, + feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { - let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -299,18 +324,36 @@ async fn run_ratatui_app( let mut tui = Tui::new(terminal); + #[cfg(not(debug_assertions))] + { + use crate::update_prompt::UpdatePromptOutcome; + + let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); + if !skip_update_prompt { + match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { + UpdatePromptOutcome::Continue => {} + UpdatePromptOutcome::RunUpdate(action) => { + crate::tui::restore()?; + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: Some(action), + }); + } + } + } + } + // Show update banner in terminal history (instead of stderr) so it is visible // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { + if let Some(latest_version) = updates::get_upgrade_version(&initial_config) { use crate::history_cell::padded_emoji; use crate::history_cell::with_border_with_inner_width; use ratatui::style::Stylize as _; use ratatui::text::Line; let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); let mut content_lines: Vec> = vec![ Line::from(vec![ @@ -330,20 +373,10 @@ async fn run_ratatui_app( Line::from(""), ]; - if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; + if let Some(update_action) = get_update_action() { content_lines.push(Line::from(vec![ "Run ".into(), - npm_cmd.cyan(), - " to update.".into(), - ])); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - content_lines.push(Line::from(vec![ - "Run ".into(), - brew_cmd.cyan(), + update_action.command_str().cyan(), " to update.".into(), ])); } else { @@ -362,27 +395,29 @@ async fn run_ratatui_app( } // Initialize high-fidelity session event logging if enabled. - session_log::maybe_init(&config); + session_log::maybe_init(&initial_config); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); - let login_status = get_login_status(&config); + let auth_manager = AuthManager::shared(initial_config.codex_home.clone(), false); + let login_status = get_login_status(&initial_config); + let should_show_trust_screen = should_show_trust_screen(&initial_config); let should_show_windows_wsl_screen = - cfg!(target_os = "windows") && !config.windows_wsl_setup_acknowledged; + cfg!(target_os = "windows") && !initial_config.windows_wsl_setup_acknowledged; let should_show_onboarding = should_show_onboarding( login_status, - &config, + &initial_config, should_show_trust_screen, should_show_windows_wsl_screen, ); - if should_show_onboarding { + + let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { + show_login_screen: should_show_login_screen(login_status, &initial_config), show_windows_wsl_screen: should_show_windows_wsl_screen, - show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, auth_manager: auth_manager.clone(), - config: config.clone(), + config: initial_config.clone(), }, &mut tui, ) @@ -397,16 +432,23 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } - if should_show_windows_wsl_screen { - config.windows_wsl_setup_acknowledged = true; + // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly + if should_show_windows_wsl_screen + || onboarding_result + .directory_trust_decision + .map(|d| d == TrustDirectorySelection::Trust) + .unwrap_or(false) + { + load_config_or_exit(cli_kv_overrides, overrides).await + } else { + initial_config } - if let Some(TrustDirectorySelection::Trust) = onboarding_result.directory_trust_decision { - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - } + } else { + initial_config + }; // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { @@ -414,7 +456,20 @@ async fn run_ratatui_app( Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); - resume_picker::ResumeSelection::StartFresh + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!( + std::io::stdout(), + "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + ) { + error!("Failed to write resume error message: {err}"); + } + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + }); } } } else if cli.resume_last { @@ -441,6 +496,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } other => other, @@ -459,6 +515,7 @@ async fn run_ratatui_app( prompt, images, resume_selection, + feedback, ) .await; @@ -469,6 +526,47 @@ async fn run_ratatui_app( app_result } +/// Get the update action from the environment. +/// Returns `None` if not managed by npm, bun, or brew. +#[cfg(not(debug_assertions))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +#[test] +#[cfg(not(debug_assertions))] +fn test_get_update_action() { + let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); + + // First: no npm var -> expect None (we do not run from brew in CI) + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + assert_eq!(get_update_action(), None); + + // Then: with npm var -> expect NpmGlobalLatest + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; + assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); + + // Restore prior value to avoid leaking state + if let Some(v) = prev { + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; + } else { + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + } +} + #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -505,39 +603,31 @@ fn get_login_status(config: &Config) -> LoginStatus { } } -/// Determine if user has configured a sandbox / approval policy, -/// or if the current cwd project is trusted, and updates the config -/// accordingly. -fn determine_repo_trust_state( - config: &mut Config, - config_toml: &ConfigToml, - approval_policy_overide: Option, - sandbox_mode_override: Option, - config_profile_override: Option, -) -> std::io::Result { - let config_profile = config_toml.get_config_profile(config_profile_override)?; +async fn load_config_or_exit( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, +) -> Config { + #[allow(clippy::print_stderr)] + match Config::load_with_cli_overrides(cli_kv_overrides, overrides).await { + Ok(config) => config, + Err(err) => { + eprintln!("Error loading configuration: {err}"); + std::process::exit(1); + } + } +} - if approval_policy_overide.is_some() || sandbox_mode_override.is_some() { +/// Determine if user has configured a sandbox / approval policy, +/// or if the current cwd project is already trusted. If not, we need to +/// show the trust screen. +fn should_show_trust_screen(config: &Config) -> bool { + if config.did_user_set_custom_approval_policy_or_sandbox_mode { // if the user has overridden either approval policy or sandbox mode, // skip the trust flow - Ok(false) - } else if config_profile.approval_policy.is_some() { - // if the user has specified settings in a config profile, skip the trust flow - // todo: profile sandbox mode? - Ok(false) - } else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() { - // if the user has specified either approval policy or sandbox mode in config.toml - // skip the trust flow - Ok(false) - } else if config_toml.is_cwd_trusted(&config.cwd) { - // if the current cwd project is trusted and no config has been set - // skip the trust flow and set the approval policy and sandbox mode - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - Ok(false) + false } else { - // if none of the above conditions are met, show the trust screen - Ok(true) + // otherwise, skip iff the active project is trusted + !config.active_project.is_trusted() } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index cf481e6712..05687bb1bd 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -28,6 +28,7 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; use std::sync::RwLock; use crate::LoginStatus; @@ -50,6 +51,8 @@ pub(crate) enum SignInState { ApiKeyConfigured, } +const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; + #[derive(Clone, Default)] pub(crate) struct ApiKeyInputState { value: String, @@ -79,25 +82,41 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - self.highlighted_mode = AuthMode::ChatGPT; + if self.is_chatgpt_login_allowed() { + self.highlighted_mode = AuthMode::ChatGPT; + } } KeyCode::Down | KeyCode::Char('j') => { - self.highlighted_mode = AuthMode::ApiKey; + if self.is_api_login_allowed() { + self.highlighted_mode = AuthMode::ApiKey; + } } KeyCode::Char('1') => { - self.start_chatgpt_login(); + if self.is_chatgpt_login_allowed() { + self.start_chatgpt_login(); + } + } + KeyCode::Char('2') => { + if self.is_api_login_allowed() { + self.start_api_key_entry(); + } else { + self.disallow_api_login(); + } } - KeyCode::Char('2') => self.start_api_key_entry(), KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT => { + AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { self.start_chatgpt_login(); } - AuthMode::ApiKey => { + AuthMode::ApiKey if self.is_api_login_allowed() => { self.start_api_key_entry(); } + AuthMode::ChatGPT => {} + AuthMode::ApiKey => { + self.disallow_api_login(); + } }, SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; @@ -131,9 +150,26 @@ pub(crate) struct AuthModeWidget { pub codex_home: PathBuf, pub login_status: LoginStatus, pub auth_manager: Arc, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, } impl AuthModeWidget { + fn is_api_login_allowed(&self) -> bool { + !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) + } + + fn is_chatgpt_login_allowed(&self) -> bool { + !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) + } + + fn disallow_api_login(&mut self) { + self.highlighted_mode = AuthMode::ChatGPT; + self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); + } + fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ @@ -176,20 +212,34 @@ impl AuthModeWidget { vec![line1, line2] }; + let chatgpt_description = if self.is_chatgpt_login_allowed() { + "Usage included with Plus, Pro, and Team plans" + } else { + "ChatGPT login is disabled" + }; lines.extend(create_mode_item( 0, AuthMode::ChatGPT, "Sign in with ChatGPT", - "Usage included with Plus, Pro, and Team plans", - )); - lines.push("".into()); - lines.extend(create_mode_item( - 1, - AuthMode::ApiKey, - "Provide your own API key", - "Pay for what you use", + chatgpt_description, )); lines.push("".into()); + if self.is_api_login_allowed() { + lines.extend(create_mode_item( + 1, + AuthMode::ApiKey, + "Provide your own API key", + "Pay for what you use", + )); + lines.push("".into()); + } else { + lines.push( + " API key login is disabled by this workspace. Sign in with ChatGPT to continue." + .dim() + .into(), + ); + lines.push("".into()); + } lines.push( // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. @@ -428,6 +478,10 @@ impl AuthModeWidget { } fn start_api_key_entry(&mut self) { + if !self.is_api_login_allowed() { + self.disallow_api_login(); + return; + } self.error = None; let prefill_from_env = read_openai_api_key_from_env(); let mut guard = self.sign_in_state.write().unwrap(); @@ -454,6 +508,10 @@ impl AuthModeWidget { } fn save_api_key(&mut self, api_key: String) { + if !self.is_api_login_allowed() { + self.disallow_api_login(); + return; + } match login_with_api_key(&self.codex_home, &api_key) { Ok(()) => { self.error = None; @@ -491,7 +549,11 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.forced_chatgpt_workspace_id.clone(), + ); match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -571,3 +633,54 @@ impl WidgetRef for AuthModeWidget { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { + let codex_home = TempDir::new().unwrap(); + let codex_home_path = codex_home.path().to_path_buf(); + let widget = AuthModeWidget { + request_frame: FrameRequester::test_dummy(), + highlighted_mode: AuthMode::ChatGPT, + error: None, + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), + codex_home: codex_home_path.clone(), + login_status: LoginStatus::NotAuthenticated, + auth_manager: AuthManager::shared(codex_home_path, false), + forced_chatgpt_workspace_id: None, + forced_login_method: Some(ForcedLoginMethod::Chatgpt), + }; + (widget, codex_home) + } + + #[test] + fn api_key_flow_disabled_when_chatgpt_forced() { + let (mut widget, _tmp) = widget_forced_chatgpt(); + + widget.start_api_key_entry(); + + assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + } + + #[test] + fn saving_api_key_is_blocked_when_chatgpt_forced() { + let (mut widget, _tmp) = widget_forced_chatgpt(); + + widget.save_api_key("sk-test".to_string()); + + assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); + } +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 69225d97f1..369e6ddbb0 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -12,6 +12,7 @@ use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; @@ -83,6 +84,8 @@ impl OnboardingScreen { config, } = args; let cwd = config.cwd.clone(); + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let forced_login_method = config.forced_login_method; let codex_home = config.codex_home; let mut steps: Vec = Vec::new(); if show_windows_wsl_screen { @@ -93,14 +96,20 @@ impl OnboardingScreen { tui.frame_requester(), ))); if show_login_screen { + let highlighted_mode = match forced_login_method { + Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, + _ => AuthMode::ChatGPT, + }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, auth_manager, + forced_chatgpt_workspace_id, + forced_login_method, })) } let is_git_repo = get_git_repo_root(&cwd).is_some(); diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap new file mode 100644 index 0000000000..71cbc3d6ea --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_git_repo.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/onboarding/trust_directory.rs +expression: terminal.backend() +--- +> You are running Codex in /workspace/project + + Since this folder is version controlled, you may wish to allow Codex + to work in this folder without asking for approval. + +› 1. Yes, allow Codex to work in this folder without asking for + approval + 2. No, ask me to approve edits and commands + + Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 1058208bee..8a7541c022 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -7,20 +7,22 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::prelude::Widget; -use ratatui::style::Color; -use ratatui::style::Modifier; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use crate::key_hint; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; use super::onboarding_screen::StepState; - pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, pub cwd: PathBuf, @@ -38,73 +40,79 @@ pub enum TrustDirectorySelection { impl WidgetRef for &TrustDirectoryWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let mut lines: Vec = vec![ - Line::from(vec![ - "> ".into(), - "You are running Codex in ".bold(), - self.cwd.to_string_lossy().to_string().into(), - ]), - "".into(), - ]; + let mut column = ColumnRenderable::new(); - if self.is_git_repo { - lines.push( - " Since this folder is version controlled, you may wish to allow Codex".into(), - ); - lines.push(" to work in this folder without asking for approval.".into()); + column.push(Line::from(vec![ + "> ".into(), + "You are running Codex in ".bold(), + self.cwd.to_string_lossy().to_string().into(), + ])); + column.push(""); + + let guidance = if self.is_git_repo { + "Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval." } else { - lines.push( - " Since this folder is not version controlled, we recommend requiring".into(), - ); - lines.push(" approval of all edits and commands.".into()); - } - lines.push("".into()); + "Since this folder is not version controlled, we recommend requiring approval of all edits and commands." + }; - let create_option = - |idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> { - let is_selected = self.highlighted == option; - if is_selected { - Line::from(format!("> {}. {text}", idx + 1)).cyan() - } else { - Line::from(format!(" {}. {}", idx + 1, text)) - } - }; + column.push( + Paragraph::new(guidance.to_string()) + .wrap(Wrap { trim: true }) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new(); if self.is_git_repo { - lines.push(create_option( - 0, - TrustDirectorySelection::Trust, + options.push(( "Yes, allow Codex to work in this folder without asking for approval", + TrustDirectorySelection::Trust, )); - lines.push(create_option( - 1, - TrustDirectorySelection::DontTrust, + options.push(( "No, ask me to approve edits and commands", + TrustDirectorySelection::DontTrust, )); } else { - lines.push(create_option( - 0, - TrustDirectorySelection::Trust, + options.push(( "Allow Codex to work in this folder without asking for approval", + TrustDirectorySelection::Trust, )); - lines.push(create_option( - 1, - TrustDirectorySelection::DontTrust, + options.push(( "Require approval of edits and commands", + TrustDirectorySelection::DontTrust, )); } - lines.push("".into()); - if let Some(error) = &self.error { - lines.push(Line::from(format!(" {error}")).fg(Color::Red)); - lines.push("".into()); - } - // AE: Following styles.md, this should probably be Cyan because it's a user input tip. - // But leaving this for a future cleanup. - lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM)); - Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .render(area, buf); + for (idx, (text, selection)) in options.iter().enumerate() { + column.push(selection_option_row( + idx, + text.to_string(), + self.highlighted == *selection, + )); + } + + column.push(""); + + if let Some(error) = &self.error { + column.push( + Paragraph::new(error.to_string()) + .red() + .wrap(Wrap { trim: true }) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + } + + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + + column.render(area, buf); } } @@ -121,8 +129,8 @@ impl KeyboardHandler for TrustDirectoryWidget { KeyCode::Down | KeyCode::Char('j') => { self.highlighted = TrustDirectorySelection::DontTrust; } - KeyCode::Char('1') => self.handle_trust(), - KeyCode::Char('2') => self.handle_dont_trust(), + KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(), + KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(), KeyCode::Enter => match self.highlighted { TrustDirectorySelection::Trust => self.handle_trust(), TrustDirectorySelection::DontTrust => self.handle_dont_trust(), @@ -161,12 +169,16 @@ impl TrustDirectoryWidget { #[cfg(test)] mod tests { + use crate::test_backend::VT100Backend; + use super::*; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; + use ratatui::Terminal; + use std::path::PathBuf; #[test] @@ -191,4 +203,23 @@ mod tests { widget.handle_key_event(press); assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust)); } + + #[test] + fn renders_snapshot_for_git_repo() { + let widget = TrustDirectoryWidget { + codex_home: PathBuf::from("."), + cwd: PathBuf::from("/workspace/project"), + is_git_repo: true, + selection: None, + highlighted: TrustDirectorySelection::Trust, + error: None, + }; + + let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + + insta::assert_snapshot!(terminal.backend()); + } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 46c6c19a18..84c325b6cb 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -26,6 +26,7 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; pub(crate) enum Overlay { Transcript(TranscriptOverlay), @@ -329,9 +330,9 @@ struct CachedRenderable { } impl CachedRenderable { - fn new(renderable: Box) -> Self { + fn new(renderable: impl Into>) -> Self { Self { - renderable, + renderable: renderable.into(), height: std::cell::Cell::new(None), last_width: std::cell::Cell::new(None), } @@ -400,19 +401,19 @@ impl TranscriptOverlay { .flat_map(|(i, c)| { let mut v: Vec> = Vec::new(); let mut cell_renderable = if c.as_any().is::() { - Box::new(CachedRenderable::new(Box::new(CellRenderable { + Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: if highlight_cell == Some(i) { user_message_style().reversed() } else { user_message_style() }, - }))) as Box + })) as Box } else { - Box::new(CachedRenderable::new(Box::new(CellRenderable { + Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: Style::default(), - }))) as Box + })) as Box }; if !c.is_stream_continuation() && i > 0 { cell_renderable = Box::new(InsetRenderable::new( @@ -496,12 +497,8 @@ pub(crate) struct StaticOverlay { impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { - Self::with_renderables( - vec![Box::new(CachedRenderable::new(Box::new(Paragraph::new( - Text::from(lines), - ))))], - title, - ) + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title) } pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { @@ -582,6 +579,7 @@ fn render_offset_content( #[cfg(test)] mod tests { use super::*; + use codex_core::protocol::ReviewDecision; use insta::assert_snapshot; use std::collections::HashMap; use std::path::PathBuf; @@ -589,6 +587,7 @@ mod tests { use std::time::Duration; use crate::exec_cell::CommandOutput; + use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_core::protocol::FileChange; @@ -712,10 +711,8 @@ mod tests { cells.push(apply_begin_cell); let apply_end_cell: Arc = - Arc::new(crate::history_cell::new_user_approval_decision(vec![ - "✓ Patch applied".green().bold().into(), - "src/foo.txt".dim().into(), - ])); + history_cell::new_approval_decision_cell(vec!["ls".into()], ReviewDecision::Approved) + .into(); cells.push(apply_end_cell); let mut exec_cell = crate::exec_cell::new_active_exec_command( @@ -812,6 +809,18 @@ mod tests { assert_snapshot!(term.backend()); } + #[test] + fn static_overlay_wraps_long_lines() { + let mut overlay = StaticOverlay::with_title( + vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], + "S T A T I C".to_string(), + ); + let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } + #[test] fn pager_view_content_height_counts_renderables() { let pv = PagerView::new( diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index bf4fb553c5..a2e4920586 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -6,10 +6,10 @@ pub mod renderable; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Insets { - pub left: u16, - pub top: u16, - pub right: u16, - pub bottom: u16, + left: u16, + top: u16, + right: u16, + bottom: u16, } impl Insets { diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 62c2720685..df06449555 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; @@ -45,6 +46,15 @@ impl Renderable for String { } } +impl<'a> Renderable for Span<'a> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_ref(area, buf); + } + fn desired_height(&self, _width: u16) -> u16 { + 1 + } +} + impl<'a> Renderable for Line<'a> { fn render(&self, area: Rect, buf: &mut Buffer) { WidgetRef::render_ref(self, area, buf); @@ -114,11 +124,64 @@ impl Renderable for ColumnRenderable { } impl ColumnRenderable { - pub fn new(children: impl IntoIterator>) -> Self { + pub fn new() -> Self { + Self::with(vec![]) + } + + pub fn with(children: impl IntoIterator>) -> Self { Self { children: children.into_iter().collect(), } } + + pub fn push(&mut self, child: impl Into>) { + self.children.push(child.into()); + } +} + +pub struct RowRenderable { + children: Vec<(u16, Box)>, +} + +impl Renderable for RowRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + let mut x = area.x; + for (width, child) in &self.children { + let available_width = area.width.saturating_sub(x - area.x); + let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height); + if child_area.is_empty() { + break; + } + child.render(child_area, buf); + x = x.saturating_add(*width); + } + } + fn desired_height(&self, width: u16) -> u16 { + let mut max_height = 0; + let mut width_remaining = width; + for (child_width, child) in &self.children { + let w = (*child_width).min(width_remaining); + if w == 0 { + break; + } + let height = child.desired_height(w); + if height > max_height { + max_height = height; + } + width_remaining = width_remaining.saturating_sub(w); + } + max_height + } +} + +impl RowRenderable { + pub fn new() -> Self { + Self { children: vec![] } + } + + pub fn push(&mut self, width: u16, child: impl Into>) { + self.children.push((width, child.into())); + } } pub struct InsetRenderable { @@ -146,3 +209,16 @@ impl InsetRenderable { } } } + +pub trait RenderableExt { + fn inset(self, insets: Insets) -> Box; +} + +impl>> RenderableExt for R { + fn inset(self, insets: Insets) -> Box { + Box::new(InsetRenderable { + child: self.into(), + insets, + }) + } +} diff --git a/codex-rs/tui/src/selection_list.rs b/codex-rs/tui/src/selection_list.rs new file mode 100644 index 0000000000..4816735437 --- /dev/null +++ b/codex-rs/tui/src/selection_list.rs @@ -0,0 +1,35 @@ +use crate::render::renderable::Renderable; +use crate::render::renderable::RowRenderable; +use ratatui::style::Style; +use ratatui::style::Styled as _; +use ratatui::style::Stylize as _; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +pub(crate) fn selection_option_row( + index: usize, + label: String, + is_selected: bool, +) -> Box { + let prefix = if is_selected { + format!("› {}. ", index + 1) + } else { + format!(" {}. ", index + 1) + }; + let style = if is_selected { + Style::default().cyan() + } else { + Style::default() + }; + let prefix_width = UnicodeWidthStr::width(prefix.as_str()) as u16; + let mut row = RowRenderable::new(); + row.push(prefix_width, prefix.set_style(style)); + row.push( + u16::MAX, + Paragraph::new(label) + .style(style) + .wrap(Wrap { trim: false }), + ); + row.into() +} diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index a6a16f2ce6..ba081e7c49 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -8,10 +8,8 @@ use ratatui::style::Style; use ratatui::text::Span; use crate::color::blend; +use crate::terminal_palette::default_bg; use crate::terminal_palette::default_fg; -use crate::terminal_palette::terminal_palette; - -const FALLBACK_DARK_GRAY: (u8, u8, u8) = (103, 103, 103); static PROCESS_START: OnceLock = OnceLock::new(); @@ -35,11 +33,11 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) .map(|level| level.has_16m) .unwrap_or(false); - let band_half_width = 3.0; + let band_half_width = 5.0; let mut spans: Vec> = Vec::with_capacity(chars.len()); - let default_fg = default_fg(); - let palette_dark_gray = terminal_palette().map(|palette| palette[8]); + let base_color = default_fg().unwrap_or((128, 128, 128)); + let highlight_color = default_bg().unwrap_or((255, 255, 255)); for (i, ch) in chars.iter().enumerate() { let i_pos = i as isize + padding as isize; let pos = pos as isize; @@ -52,11 +50,8 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { 0.0 }; let style = if has_true_color { - let base = palette_dark_gray - .or(default_fg) - .unwrap_or(FALLBACK_DARK_GRAY); let highlight = t.clamp(0.0, 1.0); - let (r, g, b) = blend((255, 255, 255), base, highlight); + let (r, g, b) = blend(highlight_color, base_color, highlight * 0.9); // Allow custom RGB colors, as the implementation is thoughtfully // adjusting the level of the default foreground color. #[allow(clippy::disallowed_methods)] diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a736d..22da09f358 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Mcp, Logout, Quit, + Feedback, #[cfg(debug_assertions)] TestApproval, } @@ -33,6 +34,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Feedback => "send logs to maintainers", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", @@ -72,6 +74,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Feedback | SlashCommand::Quit => true, #[cfg(debug_assertions)] @@ -85,13 +88,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { let show_beta_features = beta_features_enabled(); SlashCommand::iter() - .filter(|cmd| { - if *cmd == SlashCommand::Undo { - show_beta_features - } else { - true - } - }) + .filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features) .map(|c| (c.command(), c)) .collect() } diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap index 3fd59095ce..9870dddb06 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -3,6 +3,6 @@ source: tui/src/history_cell.rs expression: rendered --- › one two - three four - five six - seven + three + four five + six seven diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap new file mode 100644 index 0000000000..2dfab2aa7d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_wraps_long_lines.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/pager_overlay.rs +assertion_line: 798 +expression: term.backend() +--- +"/ S T A T I C / / / / / " +"a very long line that " +"should wrap when " +"rendered within a narrow" +"─────────────────── 0% ─" +" ↑/↓ to scroll pgup/pg" +" q to quit " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap new file mode 100644 index 0000000000..24d8831c95 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/update_prompt.rs +expression: terminal.backend() +--- + ✨ Update available! 0.0.0 -> 9.9.9 + + Release notes: https://github.com/openai/codex/releases/latest + +› 1. Update now (runs `npm install -g @openai/codex@latest`) + 2. Skip + 3. Skip until next version + + Press enter to continue diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 4832713bf9..a077a92661 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -31,16 +31,16 @@ use super::rate_limits::render_status_limit_progress_bar; #[derive(Debug, Clone)] struct StatusContextWindowData { - percent_remaining: u8, - tokens_in_context: u64, - window: u64, + percent_remaining: i64, + tokens_in_context: i64, + window: i64, } #[derive(Debug, Clone)] pub(crate) struct StatusTokenUsageData { - total: u64, - input: u64, - output: u64, + total: i64, + input: i64, + output: i64, context_window: Option, } diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 1889d7f4f4..59362e2e2b 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -103,7 +103,8 @@ pub(crate) fn compose_account_display(config: &Config) -> Option String { +pub(crate) fn format_tokens_compact(value: i64) -> String { + let value = value.max(0); if value == 0 { return "0".to_string(); } @@ -111,14 +112,15 @@ pub(crate) fn format_tokens_compact(value: u64) -> String { return value.to_string(); } + let value_f64 = value as f64; let (scaled, suffix) = if value >= 1_000_000_000_000 { - (value as f64 / 1_000_000_000_000.0, "T") + (value_f64 / 1_000_000_000_000.0, "T") } else if value >= 1_000_000_000 { - (value as f64 / 1_000_000_000.0, "B") + (value_f64 / 1_000_000_000.0, "B") } else if value >= 1_000_000 { - (value as f64 / 1_000_000.0, "M") + (value_f64 / 1_000_000.0, "M") } else { - (value as f64 / 1_000.0, "K") + (value_f64 / 1_000.0, "K") }; let decimals = if scaled < 10.0 { diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 875001ef8a..ea63f189f3 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -2,11 +2,10 @@ use crate::chatwidget::get_limits_duration; use super::helpers::format_reset_timestamp; use chrono::DateTime; -use chrono::Duration as ChronoDuration; use chrono::Local; +use chrono::Utc; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; -use std::convert::TryFrom; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -29,15 +28,15 @@ pub(crate) enum StatusRateLimitData { pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, pub resets_at: Option, - pub window_minutes: Option, + pub window_minutes: Option, } impl RateLimitWindowDisplay { fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { let resets_at = window - .resets_in_seconds - .and_then(|seconds| i64::try_from(seconds).ok()) - .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) + .resets_at + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) + .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); Self { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 395d952f41..b8c945eeec 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,7 +1,9 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; +use chrono::Duration as ChronoDuration; use chrono::TimeZone; +use chrono::Utc; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -60,6 +62,12 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { + (*captured_at + ChronoDuration::seconds(seconds)) + .with_timezone(&Utc) + .timestamp() +} + #[test] fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); @@ -85,22 +93,22 @@ fn status_snapshot_includes_reasoning_details() { total_tokens: 2_250, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), - resets_in_seconds: Some(600), - }), - secondary: Some(RateLimitWindow { - used_percent: 45.0, - window_minutes: Some(10080), - resets_in_seconds: Some(1_200), - }), - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(10080), + resets_at: Some(reset_at_from(&captured_at, 1_200)), + }), + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -130,18 +138,18 @@ fn status_snapshot_includes_monthly_limit() { total_tokens: 1_200, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 12.0, - window_minutes: Some(43_200), - resets_in_seconds: Some(86_400), - }), - secondary: None, - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 12.0, + window_minutes: Some(43_200), + resets_at: Some(reset_at_from(&captured_at, 86_400)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -197,18 +205,18 @@ fn status_snapshot_truncates_in_narrow_terminal() { total_tokens: 2_250, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 72.5, - window_minutes: Some(300), - resets_in_seconds: Some(600), - }), - secondary: None, - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index ce4f6eabd8..4bcc5cf267 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -103,6 +103,11 @@ impl StatusIndicatorWidget { } } + #[cfg(test)] + pub(crate) fn header(&self) -> &str { + &self.header + } + /// Replace the queued messages displayed beneath the header. pub(crate) fn set_queued_messages(&mut self, queued: Vec) { self.queued_messages = queued; diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 01861e1e15..e50d59791e 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -1,8 +1,7 @@ use crate::color::blend; use crate::color::is_light; -use crate::color::perceptual_distance; +use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; -use crate::terminal_palette::terminal_palette; use ratatui::style::Color; use ratatui::style::Style; @@ -25,25 +24,5 @@ pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { } else { (255, 255, 255) }; - let bottom = terminal_bg; - let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else { - return Color::default(); - }; - - let target = blend(top, bottom, 0.1); - if color_level.has_16m { - let (r, g, b) = target; - Color::Rgb(r, g, b) - } else if color_level.has_256 - && let Some(palette) = terminal_palette() - && let Some((i, _)) = palette.into_iter().enumerate().min_by(|(_, a), (_, b)| { - perceptual_distance(*a, target) - .partial_cmp(&perceptual_distance(*b, target)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - { - Color::Indexed(i as u8) - } else { - Color::default() - } + best_color(blend(top, terminal_bg, 0.1)) } diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index c35b2cfff4..05d842d5f1 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,5 +1,28 @@ -pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { - imp::terminal_palette() +use crate::color::perceptual_distance; +use ratatui::style::Color; + +/// Returns the closest color to the target color that the terminal can display. +pub fn best_color(target: (u8, u8, u8)) -> Color { + let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else { + return Color::default(); + }; + if color_level.has_16m { + let (r, g, b) = target; + #[allow(clippy::disallowed_methods)] + Color::Rgb(r, g, b) + } else if color_level.has_256 + && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + { + #[allow(clippy::disallowed_methods)] + Color::Indexed(i as u8) + } else { + #[allow(clippy::disallowed_methods)] + Color::default() + } } pub fn requery_default_colors() { @@ -8,7 +31,6 @@ pub fn requery_default_colors() { #[derive(Clone, Copy)] pub struct DefaultColors { - #[allow(dead_code)] fg: (u8, u8, u8), bg: (u8, u8, u8), } @@ -17,7 +39,6 @@ pub fn default_colors() -> Option { imp::default_colors() } -#[allow(dead_code)] pub fn default_fg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.fg) } @@ -29,8 +50,6 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; - use std::mem::MaybeUninit; - use std::os::fd::RawFd; use std::sync::Mutex; use std::sync::OnceLock; @@ -69,14 +88,6 @@ mod imp { CACHE.get_or_init(|| Mutex::new(Cache::default())) } - pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { - static CACHE: OnceLock> = OnceLock::new(); - *CACHE.get_or_init(|| match query_terminal_palette() { - Ok(Some(palette)) => Some(palette), - _ => None, - }) - } - pub(super) fn default_colors() -> Option { let cache = default_colors_cache(); let mut cache = cache.lock().ok()?; @@ -89,84 +100,6 @@ mod imp { } } - #[allow(dead_code)] - fn query_terminal_palette() -> std::io::Result> { - use std::fs::OpenOptions; - use std::io::ErrorKind; - use std::io::IsTerminal; - use std::io::Read; - use std::io::Write; - use std::os::fd::AsRawFd; - use std::time::Duration; - use std::time::Instant; - - if !std::io::stdout().is_terminal() { - return Ok(None); - } - - let mut tty = match OpenOptions::new().read(true).write(true).open("/dev/tty") { - Ok(file) => file, - Err(_) => return Ok(None), - }; - - for index in 0..256 { - write!(tty, "\x1b]4;{index};?\x07")?; - } - tty.flush()?; - - let fd = tty.as_raw_fd(); - let _termios_guard = unsafe { suppress_echo(fd) }; - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags >= 0 { - libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } - - let mut palette: [Option<(u8, u8, u8)>; 256] = [None; 256]; - let mut buffer = Vec::new(); - let mut remaining = palette.len(); - let read_deadline = Instant::now() + Duration::from_millis(1500); - - while remaining > 0 && Instant::now() < read_deadline { - let mut chunk = [0u8; 512]; - match tty.read(&mut chunk) { - Ok(0) => break, - Ok(read) => { - buffer.extend_from_slice(&chunk[..read]); - let newly = apply_palette_responses(&mut buffer, &mut palette); - if newly > 0 { - remaining = remaining.saturating_sub(newly); - } - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(err) if err.kind() == ErrorKind::Interrupted => continue, - Err(_) => return Ok(None), - } - } - - remaining = remaining.saturating_sub(apply_palette_responses(&mut buffer, &mut palette)); - remaining = remaining.saturating_sub(drain_remaining(&mut tty, &mut buffer, &mut palette)); - - if remaining > 0 { - return Ok(None); - } - - let mut colors = [(0, 0, 0); 256]; - for (slot, value) in colors.iter_mut().zip(palette.into_iter()) { - if let Some(rgb) = value { - *slot = rgb; - } else { - return Ok(None); - } - } - - Ok(Some(colors)) - } - - #[allow(dead_code)] fn query_default_colors() -> std::io::Result> { use std::fs::OpenOptions; use std::io::ErrorKind; @@ -236,144 +169,6 @@ mod imp { Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })) } - fn drain_remaining( - tty: &mut std::fs::File, - buffer: &mut Vec, - palette: &mut [Option<(u8, u8, u8)>; 256], - ) -> usize { - use std::io::ErrorKind; - use std::io::Read; - use std::time::Duration; - use std::time::Instant; - - let mut chunk = [0u8; 512]; - let mut idle_deadline = Instant::now() + Duration::from_millis(50); - let mut newly_filled = 0usize; - - loop { - match tty.read(&mut chunk) { - Ok(0) => break, - Ok(read) => { - buffer.extend_from_slice(&chunk[..read]); - newly_filled += apply_palette_responses(buffer, palette); - idle_deadline = Instant::now() + Duration::from_millis(50); - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - if Instant::now() >= idle_deadline { - break; - } - std::thread::sleep(Duration::from_millis(5)); - } - Err(err) if err.kind() == ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - - buffer.clear(); - newly_filled - } - - struct TermiosGuard { - fd: RawFd, - original: libc::termios, - } - - impl Drop for TermiosGuard { - fn drop(&mut self) { - unsafe { - libc::tcsetattr(self.fd, libc::TCSANOW, &self.original); - } - } - } - - unsafe fn suppress_echo(fd: RawFd) -> Option { - let mut termios = MaybeUninit::::uninit(); - if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } != 0 { - return None; - } - let termios = unsafe { termios.assume_init() }; - let mut modified = termios; - modified.c_lflag &= !(libc::ECHO | libc::ECHONL); - if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &modified) } != 0 { - return None; - } - Some(TermiosGuard { - fd, - original: termios, - }) - } - - fn apply_palette_responses( - buffer: &mut Vec, - palette: &mut [Option<(u8, u8, u8)>; 256], - ) -> usize { - let mut newly_filled = 0; - - while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) { - if start > 0 { - buffer.drain(..start); - continue; - } - - let mut index = 2; // skip ESC ] - let mut terminator_len = None; - while index < buffer.len() { - match buffer[index] { - 0x07 => { - terminator_len = Some(1); - break; - } - 0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => { - terminator_len = Some(2); - break; - } - _ => index += 1, - } - } - - let Some(terminator_len) = terminator_len else { - break; - }; - - let end = index; - let parsed = std::str::from_utf8(&buffer[2..end]) - .ok() - .and_then(parse_palette_message); - let processed = end + terminator_len; - buffer.drain(..processed); - - if let Some((slot, color)) = parsed - && palette[slot].is_none() - { - palette[slot] = Some(color); - newly_filled += 1; - } - } - - newly_filled - } - - fn parse_palette_message(message: &str) -> Option<(usize, (u8, u8, u8))> { - let mut parts = message.splitn(3, ';'); - if parts.next()? != "4" { - return None; - } - let index: usize = parts.next()?.trim().parse().ok()?; - if index >= 256 { - return None; - } - let payload = parts.next()?; - let (model, values) = payload.split_once(':')?; - if model != "rgb" && model != "rgba" { - return None; - } - let mut components = values.split('/'); - let r = parse_component(components.next()?)?; - let g = parse_component(components.next()?)?; - let b = parse_component(components.next()?)?; - Some((index, (r, g, b))) - } - fn parse_component(component: &str) -> Option { let trimmed = component.trim(); if trimmed.is_empty() { @@ -433,13 +228,277 @@ mod imp { mod imp { use super::DefaultColors; - pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { - None - } - pub(super) fn default_colors() -> Option { None } pub(super) fn requery_default_colors() {} } + +/// The subset of Xterm colors that are usually consistent across terminals. +fn xterm_fixed_colors() -> impl Iterator { + XTERM_COLORS.into_iter().enumerate().skip(16) +} + +// Xterm colors; derived from https://ss64.com/bash/syntax-colors.html +pub const XTERM_COLORS: [(u8, u8, u8); 256] = [ + // The first 16 colors vary based on terminal theme, so these are likely not the actual colors + // that are displayed when using these indices. + (0, 0, 0), // 0 Black (SYSTEM) + (128, 0, 0), // 1 Maroon (SYSTEM) + (0, 128, 0), // 2 Green (SYSTEM) + (128, 128, 0), // 3 Olive (SYSTEM) + (0, 0, 128), // 4 Navy (SYSTEM) + (128, 0, 128), // 5 Purple (SYSTEM) + (0, 128, 128), // 6 Teal (SYSTEM) + (192, 192, 192), // 7 Silver (SYSTEM) + (128, 128, 128), // 8 Grey (SYSTEM) + (255, 0, 0), // 9 Red (SYSTEM) + (0, 255, 0), // 10 Lime (SYSTEM) + (255, 255, 0), // 11 Yellow (SYSTEM) + (0, 0, 255), // 12 Blue (SYSTEM) + (255, 0, 255), // 13 Fuchsia (SYSTEM) + (0, 255, 255), // 14 Aqua (SYSTEM) + (255, 255, 255), // 15 White (SYSTEM) + // The rest of the colors are consistent in most terminals. + (0, 0, 0), // 16 Grey0 + (0, 0, 95), // 17 NavyBlue + (0, 0, 135), // 18 DarkBlue + (0, 0, 175), // 19 Blue3 + (0, 0, 215), // 20 Blue3 + (0, 0, 255), // 21 Blue1 + (0, 95, 0), // 22 DarkGreen + (0, 95, 95), // 23 DeepSkyBlue4 + (0, 95, 135), // 24 DeepSkyBlue4 + (0, 95, 175), // 25 DeepSkyBlue4 + (0, 95, 215), // 26 DodgerBlue3 + (0, 95, 255), // 27 DodgerBlue2 + (0, 135, 0), // 28 Green4 + (0, 135, 95), // 29 SpringGreen4 + (0, 135, 135), // 30 Turquoise4 + (0, 135, 175), // 31 DeepSkyBlue3 + (0, 135, 215), // 32 DeepSkyBlue3 + (0, 135, 255), // 33 DodgerBlue1 + (0, 175, 0), // 34 Green3 + (0, 175, 95), // 35 SpringGreen3 + (0, 175, 135), // 36 DarkCyan + (0, 175, 175), // 37 LightSeaGreen + (0, 175, 215), // 38 DeepSkyBlue2 + (0, 175, 255), // 39 DeepSkyBlue1 + (0, 215, 0), // 40 Green3 + (0, 215, 95), // 41 SpringGreen3 + (0, 215, 135), // 42 SpringGreen2 + (0, 215, 175), // 43 Cyan3 + (0, 215, 215), // 44 DarkTurquoise + (0, 215, 255), // 45 Turquoise2 + (0, 255, 0), // 46 Green1 + (0, 255, 95), // 47 SpringGreen2 + (0, 255, 135), // 48 SpringGreen1 + (0, 255, 175), // 49 MediumSpringGreen + (0, 255, 215), // 50 Cyan2 + (0, 255, 255), // 51 Cyan1 + (95, 0, 0), // 52 DarkRed + (95, 0, 95), // 53 DeepPink4 + (95, 0, 135), // 54 Purple4 + (95, 0, 175), // 55 Purple4 + (95, 0, 215), // 56 Purple3 + (95, 0, 255), // 57 BlueViolet + (95, 95, 0), // 58 Orange4 + (95, 95, 95), // 59 Grey37 + (95, 95, 135), // 60 MediumPurple4 + (95, 95, 175), // 61 SlateBlue3 + (95, 95, 215), // 62 SlateBlue3 + (95, 95, 255), // 63 RoyalBlue1 + (95, 135, 0), // 64 Chartreuse4 + (95, 135, 95), // 65 DarkSeaGreen4 + (95, 135, 135), // 66 PaleTurquoise4 + (95, 135, 175), // 67 SteelBlue + (95, 135, 215), // 68 SteelBlue3 + (95, 135, 255), // 69 CornflowerBlue + (95, 175, 0), // 70 Chartreuse3 + (95, 175, 95), // 71 DarkSeaGreen4 + (95, 175, 135), // 72 CadetBlue + (95, 175, 175), // 73 CadetBlue + (95, 175, 215), // 74 SkyBlue3 + (95, 175, 255), // 75 SteelBlue1 + (95, 215, 0), // 76 Chartreuse3 + (95, 215, 95), // 77 PaleGreen3 + (95, 215, 135), // 78 SeaGreen3 + (95, 215, 175), // 79 Aquamarine3 + (95, 215, 215), // 80 MediumTurquoise + (95, 215, 255), // 81 SteelBlue1 + (95, 255, 0), // 82 Chartreuse2 + (95, 255, 95), // 83 SeaGreen2 + (95, 255, 135), // 84 SeaGreen1 + (95, 255, 175), // 85 SeaGreen1 + (95, 255, 215), // 86 Aquamarine1 + (95, 255, 255), // 87 DarkSlateGray2 + (135, 0, 0), // 88 DarkRed + (135, 0, 95), // 89 DeepPink4 + (135, 0, 135), // 90 DarkMagenta + (135, 0, 175), // 91 DarkMagenta + (135, 0, 215), // 92 DarkViolet + (135, 0, 255), // 93 Purple + (135, 95, 0), // 94 Orange4 + (135, 95, 95), // 95 LightPink4 + (135, 95, 135), // 96 Plum4 + (135, 95, 175), // 97 MediumPurple3 + (135, 95, 215), // 98 MediumPurple3 + (135, 95, 255), // 99 SlateBlue1 + (135, 135, 0), // 100 Yellow4 + (135, 135, 95), // 101 Wheat4 + (135, 135, 135), // 102 Grey53 + (135, 135, 175), // 103 LightSlateGrey + (135, 135, 215), // 104 MediumPurple + (135, 135, 255), // 105 LightSlateBlue + (135, 175, 0), // 106 Yellow4 + (135, 175, 95), // 107 DarkOliveGreen3 + (135, 175, 135), // 108 DarkSeaGreen + (135, 175, 175), // 109 LightSkyBlue3 + (135, 175, 215), // 110 LightSkyBlue3 + (135, 175, 255), // 111 SkyBlue2 + (135, 215, 0), // 112 Chartreuse2 + (135, 215, 95), // 113 DarkOliveGreen3 + (135, 215, 135), // 114 PaleGreen3 + (135, 215, 175), // 115 DarkSeaGreen3 + (135, 215, 215), // 116 DarkSlateGray3 + (135, 215, 255), // 117 SkyBlue1 + (135, 255, 0), // 118 Chartreuse1 + (135, 255, 95), // 119 LightGreen + (135, 255, 135), // 120 LightGreen + (135, 255, 175), // 121 PaleGreen1 + (135, 255, 215), // 122 Aquamarine1 + (135, 255, 255), // 123 DarkSlateGray1 + (175, 0, 0), // 124 Red3 + (175, 0, 95), // 125 DeepPink4 + (175, 0, 135), // 126 MediumVioletRed + (175, 0, 175), // 127 Magenta3 + (175, 0, 215), // 128 DarkViolet + (175, 0, 255), // 129 Purple + (175, 95, 0), // 130 DarkOrange3 + (175, 95, 95), // 131 IndianRed + (175, 95, 135), // 132 HotPink3 + (175, 95, 175), // 133 MediumOrchid3 + (175, 95, 215), // 134 MediumOrchid + (175, 95, 255), // 135 MediumPurple2 + (175, 135, 0), // 136 DarkGoldenrod + (175, 135, 95), // 137 LightSalmon3 + (175, 135, 135), // 138 RosyBrown + (175, 135, 175), // 139 Grey63 + (175, 135, 215), // 140 MediumPurple2 + (175, 135, 255), // 141 MediumPurple1 + (175, 175, 0), // 142 Gold3 + (175, 175, 95), // 143 DarkKhaki + (175, 175, 135), // 144 NavajoWhite3 + (175, 175, 175), // 145 Grey69 + (175, 175, 215), // 146 LightSteelBlue3 + (175, 175, 255), // 147 LightSteelBlue + (175, 215, 0), // 148 Yellow3 + (175, 215, 95), // 149 DarkOliveGreen3 + (175, 215, 135), // 150 DarkSeaGreen3 + (175, 215, 175), // 151 DarkSeaGreen2 + (175, 215, 215), // 152 LightCyan3 + (175, 215, 255), // 153 LightSkyBlue1 + (175, 255, 0), // 154 GreenYellow + (175, 255, 95), // 155 DarkOliveGreen2 + (175, 255, 135), // 156 PaleGreen1 + (175, 255, 175), // 157 DarkSeaGreen2 + (175, 255, 215), // 158 DarkSeaGreen1 + (175, 255, 255), // 159 PaleTurquoise1 + (215, 0, 0), // 160 Red3 + (215, 0, 95), // 161 DeepPink3 + (215, 0, 135), // 162 DeepPink3 + (215, 0, 175), // 163 Magenta3 + (215, 0, 215), // 164 Magenta3 + (215, 0, 255), // 165 Magenta2 + (215, 95, 0), // 166 DarkOrange3 + (215, 95, 95), // 167 IndianRed + (215, 95, 135), // 168 HotPink3 + (215, 95, 175), // 169 HotPink2 + (215, 95, 215), // 170 Orchid + (215, 95, 255), // 171 MediumOrchid1 + (215, 135, 0), // 172 Orange3 + (215, 135, 95), // 173 LightSalmon3 + (215, 135, 135), // 174 LightPink3 + (215, 135, 175), // 175 Pink3 + (215, 135, 215), // 176 Plum3 + (215, 135, 255), // 177 Violet + (215, 175, 0), // 178 Gold3 + (215, 175, 95), // 179 LightGoldenrod3 + (215, 175, 135), // 180 Tan + (215, 175, 175), // 181 MistyRose3 + (215, 175, 215), // 182 Thistle3 + (215, 175, 255), // 183 Plum2 + (215, 215, 0), // 184 Yellow3 + (215, 215, 95), // 185 Khaki3 + (215, 215, 135), // 186 LightGoldenrod2 + (215, 215, 175), // 187 LightYellow3 + (215, 215, 215), // 188 Grey84 + (215, 215, 255), // 189 LightSteelBlue1 + (215, 255, 0), // 190 Yellow2 + (215, 255, 95), // 191 DarkOliveGreen1 + (215, 255, 135), // 192 DarkOliveGreen1 + (215, 255, 175), // 193 DarkSeaGreen1 + (215, 255, 215), // 194 Honeydew2 + (215, 255, 255), // 195 LightCyan1 + (255, 0, 0), // 196 Red1 + (255, 0, 95), // 197 DeepPink2 + (255, 0, 135), // 198 DeepPink1 + (255, 0, 175), // 199 DeepPink1 + (255, 0, 215), // 200 Magenta2 + (255, 0, 255), // 201 Magenta1 + (255, 95, 0), // 202 OrangeRed1 + (255, 95, 95), // 203 IndianRed1 + (255, 95, 135), // 204 IndianRed1 + (255, 95, 175), // 205 HotPink + (255, 95, 215), // 206 HotPink + (255, 95, 255), // 207 MediumOrchid1 + (255, 135, 0), // 208 DarkOrange + (255, 135, 95), // 209 Salmon1 + (255, 135, 135), // 210 LightCoral + (255, 135, 175), // 211 PaleVioletRed1 + (255, 135, 215), // 212 Orchid2 + (255, 135, 255), // 213 Orchid1 + (255, 175, 0), // 214 Orange1 + (255, 175, 95), // 215 SandyBrown + (255, 175, 135), // 216 LightSalmon1 + (255, 175, 175), // 217 LightPink1 + (255, 175, 215), // 218 Pink1 + (255, 175, 255), // 219 Plum1 + (255, 215, 0), // 220 Gold1 + (255, 215, 95), // 221 LightGoldenrod2 + (255, 215, 135), // 222 LightGoldenrod2 + (255, 215, 175), // 223 NavajoWhite1 + (255, 215, 215), // 224 MistyRose1 + (255, 215, 255), // 225 Thistle1 + (255, 255, 0), // 226 Yellow1 + (255, 255, 95), // 227 LightGoldenrod1 + (255, 255, 135), // 228 Khaki1 + (255, 255, 175), // 229 Wheat1 + (255, 255, 215), // 230 Cornsilk1 + (255, 255, 255), // 231 Grey100 + (8, 8, 8), // 232 Grey3 + (18, 18, 18), // 233 Grey7 + (28, 28, 28), // 234 Grey11 + (38, 38, 38), // 235 Grey15 + (48, 48, 48), // 236 Grey19 + (58, 58, 58), // 237 Grey23 + (68, 68, 68), // 238 Grey27 + (78, 78, 78), // 239 Grey30 + (88, 88, 88), // 240 Grey35 + (98, 98, 98), // 241 Grey39 + (108, 108, 108), // 242 Grey42 + (118, 118, 118), // 243 Grey46 + (128, 128, 128), // 244 Grey50 + (138, 138, 138), // 245 Grey54 + (148, 148, 148), // 246 Grey58 + (158, 158, 158), // 247 Grey62 + (168, 168, 168), // 248 Grey66 + (178, 178, 178), // 249 Grey70 + (188, 188, 188), // 250 Grey74 + (198, 198, 198), // 251 Grey78 + (208, 208, 208), // 252 Grey82 + (218, 218, 218), // 253 Grey85 + (228, 228, 228), // 254 Grey89 + (238, 238, 238), // 255 Grey93 +]; diff --git a/codex-rs/tui/src/test_backend.rs b/codex-rs/tui/src/test_backend.rs index 52e7254711..a5460af2e0 100644 --- a/codex-rs/tui/src/test_backend.rs +++ b/codex-rs/tui/src/test_backend.rs @@ -91,7 +91,8 @@ impl Backend for VT100Backend { } fn size(&self) -> io::Result { - Ok(self.vt100().screen().size().into()) + let (rows, cols) = self.vt100().screen().size(); + Ok(Size::new(cols, rows)) } fn window_size(&mut self) -> io::Result { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 743a9fe2f7..f6e1c2c5d5 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -277,7 +277,6 @@ impl Tui { let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); - let _ = crate::terminal_palette::terminal_palette(); let _ = crate::terminal_palette::default_colors(); Self { diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs new file mode 100644 index 0000000000..5da8462684 --- /dev/null +++ b/codex-rs/tui/src/update_prompt.rs @@ -0,0 +1,313 @@ +#![cfg(not(debug_assertions))] + +use crate::UpdateAction; +use crate::history_cell::padded_emoji; +use crate::key_hint; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use crate::updates; +use codex_core::config::Config; +use color_eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::WidgetRef; +use tokio_stream::StreamExt; + +pub(crate) enum UpdatePromptOutcome { + Continue, + RunUpdate(UpdateAction), +} + +pub(crate) async fn run_update_prompt_if_needed( + tui: &mut Tui, + config: &Config, +) -> Result { + let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { + return Ok(UpdatePromptOutcome::Continue); + }; + let Some(update_action) = crate::get_update_action() else { + return Ok(UpdatePromptOutcome::Continue); + }; + + let mut screen = + UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action); + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + } + } + } else { + break; + } + } + + match screen.selection() { + Some(UpdateSelection::UpdateNow) => { + tui.terminal.clear()?; + Ok(UpdatePromptOutcome::RunUpdate(update_action)) + } + Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue), + Some(UpdateSelection::DontRemind) => { + if let Err(err) = updates::dismiss_version(config, screen.latest_version()).await { + tracing::error!("Failed to persist update dismissal: {err}"); + } + Ok(UpdatePromptOutcome::Continue) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpdateSelection { + UpdateNow, + NotNow, + DontRemind, +} + +struct UpdatePromptScreen { + request_frame: FrameRequester, + latest_version: String, + current_version: String, + update_action: UpdateAction, + highlighted: UpdateSelection, + selection: Option, +} + +impl UpdatePromptScreen { + fn new( + request_frame: FrameRequester, + latest_version: String, + update_action: UpdateAction, + ) -> Self { + Self { + request_frame, + latest_version, + current_version: env!("CARGO_PKG_VERSION").to_string(), + update_action, + highlighted: UpdateSelection::UpdateNow, + selection: None, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.select(UpdateSelection::NotNow); + return; + } + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), + KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Char('1') => self.select(UpdateSelection::UpdateNow), + KeyCode::Char('2') => self.select(UpdateSelection::NotNow), + KeyCode::Char('3') => self.select(UpdateSelection::DontRemind), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(UpdateSelection::NotNow), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: UpdateSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: UpdateSelection) { + self.highlighted = selection; + self.selection = Some(selection); + self.request_frame.schedule_frame(); + } + + fn is_done(&self) -> bool { + self.selection.is_some() + } + + fn selection(&self) -> Option { + self.selection + } + + fn latest_version(&self) -> &str { + self.latest_version.as_str() + } +} + +impl UpdateSelection { + fn next(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::NotNow, + UpdateSelection::NotNow => UpdateSelection::DontRemind, + UpdateSelection::DontRemind => UpdateSelection::UpdateNow, + } + } + + fn prev(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::DontRemind, + UpdateSelection::NotNow => UpdateSelection::UpdateNow, + UpdateSelection::DontRemind => UpdateSelection::NotNow, + } + } +} + +impl WidgetRef for &UpdatePromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let update_command = self.update_action.command_str(); + + column.push(""); + column.push(Line::from(vec![ + padded_emoji(" ✨").bold().cyan(), + "Update available!".bold(), + " ".into(), + format!( + "{current} -> {latest}", + current = self.current_version, + latest = self.latest_version + ) + .dim(), + ])); + column.push(""); + column.push( + Line::from(vec![ + "Release notes: ".dim(), + "https://github.com/openai/codex/releases/latest" + .dim() + .underlined(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Update now (runs `{update_command}`)"), + self.highlighted == UpdateSelection::UpdateNow, + )); + column.push(selection_option_row( + 1, + "Skip".to_string(), + self.highlighted == UpdateSelection::NotNow, + )); + column.push(selection_option_row( + 2, + "Skip until next version".to_string(), + self.highlighted == UpdateSelection::DontRemind, + )); + column.push(""); + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + + fn new_prompt() -> UpdatePromptScreen { + UpdatePromptScreen::new( + FrameRequester::test_dummy(), + "9.9.9".into(), + UpdateAction::NpmGlobalLatest, + ) + } + + #[test] + fn update_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render update prompt"); + insta::assert_snapshot!("update_prompt_modal", terminal.backend()); + } + + #[test] + fn update_prompt_confirm_selects_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::UpdateNow)); + } + + #[test] + fn update_prompt_dismiss_option_leaves_prompt_in_normal_state() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_dont_remind_selects_dismissal() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::DontRemind)); + } + + #[test] + fn update_prompt_ctrl_c_skips_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_navigation_wraps_between_entries() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::DontRemind); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::UpdateNow); + } +} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 7344e24c78..ca004a36df 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -45,6 +45,8 @@ struct VersionInfo { latest_version: String, // ISO-8601 timestamp (RFC3339) last_checked_at: DateTime, + #[serde(default)] + dismissed_version: Option, } #[derive(Deserialize, Debug, Clone)] @@ -75,12 +77,15 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { .json::() .await?; + // Preserve any previously dismissed version if present. + let prev_info = read_version_info(version_file).ok(); let info = VersionInfo { latest_version: latest_tag_name .strip_prefix("rust-v") .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? .into(), last_checked_at: Utc::now(), + dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; let json_line = format!("{}\n", serde_json::to_string(&info)?); @@ -98,6 +103,37 @@ fn is_newer(latest: &str, current: &str) -> Option { } } +/// Returns the latest version to show in a popup, if it should be shown. +/// This respects the user's dismissal choice for the current latest version. +pub fn get_upgrade_version_for_popup(config: &Config) -> Option { + let version_file = version_filepath(config); + let latest = get_upgrade_version(config)?; + // If the user dismissed this exact version previously, do not show the popup. + if let Ok(info) = read_version_info(&version_file) + && info.dismissed_version.as_deref() == Some(latest.as_str()) + { + return None; + } + Some(latest) +} + +/// Persist a dismissal for the current latest version so we don't show +/// the update popup again for this version. +pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> { + let version_file = version_filepath(config); + let mut info = match read_version_info(&version_file) { + Ok(info) => info, + Err(_) => return Ok(()), + }; + info.dismissed_version = Some(version.to_string()); + let json_line = format!("{}\n", serde_json::to_string(&info)?); + if let Some(parent) = version_file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(version_file, json_line).await?; + Ok(()) +} + fn parse_version(v: &str) -> Option<(u64, u64, u64)> { let mut iter = v.trim().split('.'); let maj = iter.next()?.parse::().ok()?; diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 70ca2e46ca..3c3b775eb6 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -154,6 +154,7 @@ impl<'a> RtOptions<'a> { } } +#[must_use] pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec> where O: Into>, diff --git a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl index 392cf55250..2e6bdd40ea 100644 --- a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl +++ b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl @@ -2492,7 +2492,7 @@ {"ts":"2025-08-09T15:51:59.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:59.858Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"} -{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace."}}} +{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -4172,7 +4172,7 @@ {"ts":"2025-08-09T15:53:09.375Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:53:09.376Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"} -{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes."}}} +{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -7776,7 +7776,7 @@ {"ts":"2025-08-09T15:58:28.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:58:28.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test -p codex-core shell::tests::test_current_she"} -{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell."}}} +{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -8730,7 +8730,7 @@ {"ts":"2025-08-09T15:59:01.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:59:01.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test --all-features\"],\"with_escalated_permissions"} -{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge."}}} +{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml new file mode 100644 index 0000000000..117d626318 --- /dev/null +++ b/codex-rs/utils/pty/Cargo.toml @@ -0,0 +1,16 @@ +[package] +edition = "2021" +name = "codex-utils-pty" +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +portable-pty = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", + "sync", +] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs new file mode 100644 index 0000000000..740a502ba9 --- /dev/null +++ b/codex-rs/utils/pty/src/lib.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use anyhow::Result; +use portable_pty::native_pty_system; +use portable_pty::CommandBuilder; +use portable_pty::PtySize; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::Mutex as TokioMutex; +use tokio::task::JoinHandle; + +#[derive(Debug)] +pub struct ExecCommandSession { + writer_tx: mpsc::Sender>, + output_tx: broadcast::Sender>, + killer: StdMutex>>, + reader_handle: StdMutex>>, + writer_handle: StdMutex>>, + wait_handle: StdMutex>>, + exit_status: Arc, + exit_code: Arc>>, +} + +impl ExecCommandSession { + #[allow(clippy::too_many_arguments)] + pub fn new( + writer_tx: mpsc::Sender>, + output_tx: broadcast::Sender>, + killer: Box, + reader_handle: JoinHandle<()>, + writer_handle: JoinHandle<()>, + wait_handle: JoinHandle<()>, + exit_status: Arc, + exit_code: Arc>>, + ) -> (Self, broadcast::Receiver>) { + let initial_output_rx = output_tx.subscribe(); + ( + Self { + writer_tx, + output_tx, + killer: StdMutex::new(Some(killer)), + reader_handle: StdMutex::new(Some(reader_handle)), + writer_handle: StdMutex::new(Some(writer_handle)), + wait_handle: StdMutex::new(Some(wait_handle)), + exit_status, + exit_code, + }, + initial_output_rx, + ) + } + + pub fn writer_sender(&self) -> mpsc::Sender> { + self.writer_tx.clone() + } + + pub fn output_receiver(&self) -> broadcast::Receiver> { + self.output_tx.subscribe() + } + + pub fn has_exited(&self) -> bool { + self.exit_status.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn exit_code(&self) -> Option { + self.exit_code.lock().ok().and_then(|guard| *guard) + } +} + +impl Drop for ExecCommandSession { + fn drop(&mut self) { + if let Ok(mut killer_opt) = self.killer.lock() { + if let Some(mut killer) = killer_opt.take() { + let _ = killer.kill(); + } + } + + if let Ok(mut h) = self.reader_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + if let Ok(mut h) = self.writer_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + if let Ok(mut h) = self.wait_handle.lock() { + if let Some(handle) = h.take() { + handle.abort(); + } + } + } +} + +#[derive(Debug)] +pub struct SpawnedPty { + pub session: ExecCommandSession, + pub output_rx: broadcast::Receiver>, + pub exit_rx: oneshot::Receiver, +} + +pub async fn spawn_pty_process( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, +) -> Result { + if program.is_empty() { + anyhow::bail!("missing program for PTY spawn"); + } + + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + + let mut command_builder = CommandBuilder::new(program); + command_builder.cwd(cwd); + command_builder.env_clear(); + for arg in args { + command_builder.arg(arg); + } + for (key, value) in env { + command_builder.env(key, value); + } + + let mut child = pair.slave.spawn_command(command_builder)?; + let killer = child.clone_killer(); + + let (writer_tx, mut writer_rx) = mpsc::channel::>(128); + let (output_tx, _) = broadcast::channel::>(256); + + let mut reader = pair.master.try_clone_reader()?; + let output_tx_clone = output_tx.clone(); + let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8_192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = output_tx_clone.send(buf[..n].to_vec()); + } + Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, + Err(ref e) if e.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(_) => break, + } + } + }); + + let writer = pair.master.take_writer()?; + let writer = Arc::new(TokioMutex::new(writer)); + let writer_handle: JoinHandle<()> = tokio::spawn({ + let writer = Arc::clone(&writer); + async move { + while let Some(bytes) = writer_rx.recv().await { + let mut guard = writer.lock().await; + use std::io::Write; + let _ = guard.write_all(&bytes); + let _ = guard.flush(); + } + } + }); + + let (exit_tx, exit_rx) = oneshot::channel::(); + let exit_status = Arc::new(AtomicBool::new(false)); + let wait_exit_status = Arc::clone(&exit_status); + let exit_code = Arc::new(StdMutex::new(None)); + let wait_exit_code = Arc::clone(&exit_code); + let wait_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || { + let code = match child.wait() { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + }; + wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst); + if let Ok(mut guard) = wait_exit_code.lock() { + *guard = Some(code); + } + let _ = exit_tx.send(code); + }); + + let (session, output_rx) = ExecCommandSession::new( + writer_tx, + output_tx, + killer, + reader_handle, + writer_handle, + wait_handle, + exit_status, + exit_code, + ); + + Ok(SpawnedPty { + session, + output_rx, + exit_rx, + }) +} diff --git a/docs/advanced.md b/docs/advanced.md index 7c986f583a..ebcd6e34fd 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,6 +1,12 @@ ## Advanced -## Tracing / verbose logging +If you already lean on Codex every day and just need a little more control, this page collects the knobs you are most likely to reach for: tweak defaults in [Config](./config.md), add extra tools through [Model Context Protocol support](./advanced.md#model-context-protocol), and script full runs with [`codex exec`](./exec.md). Jump to the section you need and keep building. + +## Config quickstart {#config-quickstart} + +Most day-to-day tuning lives in `config.toml`: set approval + sandbox presets, pin model defaults, and add MCP server launchers. The [Config guide](./config.md) walks through every option and provides copy-paste examples for common setups. + +## Tracing / verbose logging {#tracing-verbose-logging} Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. @@ -14,15 +20,15 @@ By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=err See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options. -## Model Context Protocol (MCP) +## Model Context Protocol (MCP) {#model-context-protocol} The Codex CLI and IDE extension is a MCP client which means that it can be configured to connect to MCP servers. For more information, refer to the [`config docs`](./config.md#connecting-to-mcp-servers). -## Using Codex as an MCP Server +## Using Codex as an MCP Server {#mcp-server} The Codex CLI can also be run as an MCP _server_ via `codex mcp-server`. For example, you can use `codex mcp-server` to make Codex available as a tool inside of a multi-agent framework like the OpenAI [Agents SDK](https://platform.openai.com/docs/guides/agents). Use `codex mcp` separately to add/list/get/remove MCP server launchers in your configuration. -### Codex MCP Server Quickstart +### Codex MCP Server Quickstart {#mcp-server-quickstart} You can launch a Codex MCP server with the [Model Context Protocol Inspector](https://modelcontextprotocol.io/legacy/tools/inspector): @@ -34,17 +40,16 @@ Send a `tools/list` request and you will see that there are two tools available: **`codex`** - Run a Codex session. Accepts configuration parameters matching the Codex Config struct. The `codex` tool takes the following properties: -| Property | Type | Description | -| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | -| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | -| `base-instructions` | string | The set of instructions to use instead of the default ones. | -| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | -| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | -| `include-plan-tool` | boolean | Whether to include the plan tool in the conversation. | -| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | -| `profile` | string | Configuration profile from `config.toml` to specify default options. | -| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | +| Property | Type | Description | +| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | +| **`prompt`** (required) | string | The initial user prompt to start the Codex conversation. | +| `approval-policy` | string | Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`. | +| `base-instructions` | string | The set of instructions to use instead of the default ones. | +| `config` | object | Individual [config settings](https://github.com/openai/codex/blob/main/docs/config.md#config) that will override what is in `$CODEX_HOME/config.toml`. | +| `cwd` | string | Working directory for the session. If relative, resolved against the server process's current directory. | | +| `model` | string | Optional override for the model name (e.g. `o3`, `o4-mini`). | +| `profile` | string | Configuration profile from `config.toml` to specify default options. | +| `sandbox` | string | Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. | **`codex-reply`** - Continue a Codex session by providing the conversation id and prompt. The `codex-reply` tool takes the following properties: @@ -53,7 +58,7 @@ Send a `tools/list` request and you will see that there are two tools available: | **`prompt`** (required) | string | The next user prompt to continue the Codex conversation. | | **`conversationId`** (required) | string | The id of the conversation to continue. | -### Trying it Out +### Trying it Out {#mcp-server-trying-it-out} > [!TIP] > Codex often takes a few minutes to run. To accommodate this, adjust the MCP inspector's Request and Total timeouts to 600000ms (10 minutes) under ⛭ Configuration. diff --git a/docs/agents_md.md b/docs/agents_md.md new file mode 100644 index 0000000000..e3949d6b1a --- /dev/null +++ b/docs/agents_md.md @@ -0,0 +1,51 @@ +# AGENTS.md Discovery + +Codex uses [`AGENTS.md`](https://agents.md/) files to gather helpful guidance before it starts assisting you. This page explains how those files are discovered and combined, so you can decide where to place your instructions. + +## Global Instructions (`~/.codex`) + +- Codex looks for global guidance in your Codex home directory (usually `~/.codex`; set `CODEX_HOME` to change it). For a quick overview, see the [Memory with AGENTS.md section](../docs/getting-started.md#memory-with-agentsmd) in the getting started guide. +- If an `AGENTS.override.md` file exists there, it takes priority. If not, Codex falls back to `AGENTS.md`. +- Only the first non-empty file is used. Other filenames, such as `instructions.md`, have no effect unless Codex is specifically instructed to use them. +- Whatever Codex finds here stays active for the whole session, and Codex combines it with any project-specific instructions it discovers. + +## Project Instructions (per-repository) + +When you work inside a project, Codex builds on those global instructions by collecting project docs: + +- The search starts at the repository root and continues down to your current directory. If a Git root is not found, only the current directory is checked. +- In each directory along that path, Codex looks for `AGENTS.override.md` first, then `AGENTS.md`, and then any fallback names listed in your Codex configuration (see [`project_doc_fallback_filenames`](../docs/config.md#project_doc_fallback_filenames)). At most one file per directory is included. +- Files are read in order from root to leaf and joined together with blank lines. Empty files are skipped, and very large files are truncated once the combined size reaches 32 KiB (the default [`project_doc_max_bytes`](../docs/config.md#project_doc_max_bytes) limit). If you need more space, split guidance across nested directories or raise the limit in your configuration. + +## How They Come Together + +Before Codex gets to work, the instructions are ingested in precedence order: global guidance from `~/.codex` comes first, then each project doc from the repository root down to your current directory. Guidance in deeper directories overrides earlier layers, so the most specific file controls the final behavior. + +### Priority Summary + +1. Global `AGENTS.override.md` (if present), otherwise global `AGENTS.md`. +2. For each directory from the repository root to your working directory: `AGENTS.override.md`, then `AGENTS.md`, then configured fallback names. + +Only these filenames are considered. To use a different name, add it to the fallback list in your Codex configuration or rename the file accordingly. + +## Fallback Filenames + +Codex can look for additional instruction filenames beyond the two defaults if you add them to `project_doc_fallback_filenames` in your Codex configuration. Each fallback is checked after `AGENTS.override.md` and `AGENTS.md` in every directory along the search path. + +Example: suppose your configuration lists `["TEAM_GUIDE.md", ".agents.md"]`. Inside each directory Codex will look in this order: + +1. `AGENTS.override.md` +2. `AGENTS.md` +3. `TEAM_GUIDE.md` +4. `.agents.md` + +If the repository root contains `TEAM_GUIDE.md` and the `backend/` directory contains `AGENTS.override.md`, the overall instructions will combine the root `TEAM_GUIDE.md` (because no override or default file was present there) with the `backend/AGENTS.override.md` file (which takes precedence over the fallback names). + +You can configure those fallbacks in `~/.codex/config.toml` (or another profile) like this: + +```toml +[project] +project_doc_fallback_filenames = ["TEAM_GUIDE.md", ".agents.md"] +``` + +For additional configuration details, see [Config](../docs/config.md) and revisit the [Memory with AGENTS.md guide](../docs/getting-started.md#memory-with-agentsmd) for practical usage tips. diff --git a/docs/config.md b/docs/config.md index aa45acc8dd..8d495214c3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,5 +1,16 @@ # Config +Codex configuration gives you fine-grained control over the model, execution environment, and integrations available to the CLI. Use this guide alongside the workflows in [`codex exec`](./exec.md), the guardrails in [Sandbox & approvals](./sandbox.md), and project guidance from [AGENTS.md discovery](./agents_md.md). + +## Quick navigation + +- [Model selection](#model-selection) +- [Execution environment](#execution-environment) +- [MCP integration](#mcp-integration) +- [Observability and telemetry](#observability-and-telemetry) +- [Profiles and overrides](#profiles-and-overrides) +- [Reference table](#config-reference) + Codex supports several mechanisms for setting config values: - Config-specific command-line flags, such as `--model o3` (highest precedence). @@ -15,17 +26,22 @@ Codex supports several mechanisms for setting config values: Both the `--config` flag and the `config.toml` file support the following options: -## model +## Model selection + +### model The model that Codex should use. ```toml -model = "o3" # overrides the default of "gpt-5-codex" +model = "gpt-5" # overrides the default ("gpt-5-codex" on macOS/Linux, "gpt-5" on Windows) ``` -## model_providers +### model_providers -This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider. +This option lets you add to the default set of model providers bundled with Codex. The map key becomes the value you use with `model_provider` to select the provider. + +> [!NOTE] +> Built-in providers are not overwritten when you reuse their key. Entries you add only take effect when the key is **new**; for example `[model_providers.openai]` leaves the original OpenAI definition untouched. To customize the bundled OpenAI provider, prefer the dedicated knobs (for example the `OPENAI_BASE_URL` environment variable) or register a new provider key and point `model_provider` at it. For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration: @@ -84,7 +100,7 @@ http_headers = { "X-Example-Header" = "example-value" } env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" } ``` -### Azure model provider example +#### Azure model provider example Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: @@ -100,7 +116,7 @@ wire_api = "responses" Export your key before launching Codex: `export AZURE_OPENAI_API_KEY=…` -### Per-provider network tuning +#### Per-provider network tuning The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.) @@ -117,19 +133,19 @@ stream_max_retries = 10 # retry dropped SSE streams stream_idle_timeout_ms = 300000 # 5m idle timeout ``` -#### request_max_retries +##### request_max_retries How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`. -#### stream_max_retries +##### stream_max_retries Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `5`. -#### stream_idle_timeout_ms +##### stream_idle_timeout_ms How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes). -## model_provider +### model_provider Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. You can override the `base_url` for the built-in `openai` provider via the `OPENAI_BASE_URL` environment variable. @@ -142,7 +158,73 @@ model_provider = "ollama" model = "mistral" ``` -## approval_policy +### model_reasoning_effort + +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: + +- `"minimal"` +- `"low"` +- `"medium"` (default) +- `"high"` + +Note: to minimize reasoning, choose `"minimal"`. + +### model_reasoning_summary + +If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to: + +- `"auto"` (default) +- `"concise"` +- `"detailed"` + +To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config: + +```toml +model_reasoning_summary = "none" # disable reasoning summaries +``` + +### model_verbosity + +Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values: + +- `"low"` +- `"medium"` (default when omitted) +- `"high"` + +When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`. + +Example: + +```toml +model = "gpt-5" +model_verbosity = "low" +``` + +Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected. + +### model_supports_reasoning_summaries + +By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`: + +```toml +model_supports_reasoning_summaries = true +``` + +### model_context_window + +The size of the context window for the model, in tokens. + +In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. + +### model_max_output_tokens + +This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. + +> See also [`codex exec`](./exec.md) to see how these model settings influence non-interactive runs. + +## Execution environment + +### approval_policy Determines when the user should be prompted to approve whether Codex can execute a command: @@ -179,104 +261,7 @@ Alternatively, you can have the model run until it is done, and never ask to run approval_policy = "never" ``` -## profiles - -A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you -want to use at runtime via the `--profile` flag. - -Here is an example of a `config.toml` that defines multiple profiles: - -```toml -model = "o3" -approval_policy = "untrusted" - -# Setting `profile` is equivalent to specifying `--profile o3` on the command -# line, though the `--profile` flag can still be used to override this value. -profile = "o3" - -[model_providers.openai-chat-completions] -name = "OpenAI using Chat Completions" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -wire_api = "chat" - -[profiles.o3] -model = "o3" -model_provider = "openai" -approval_policy = "never" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" - -[profiles.gpt3] -model = "gpt-3.5-turbo" -model_provider = "openai-chat-completions" - -[profiles.zdr] -model = "o3" -model_provider = "openai" -approval_policy = "on-failure" -``` - -Users can specify config values at multiple levels. Order of precedence is as follows: - -1. custom command-line argument, e.g., `--model o3` -2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) -3. as an entry in `config.toml`, e.g., `model = "o3"` -4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`) - -## model_reasoning_effort - -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - -- `"minimal"` -- `"low"` -- `"medium"` (default) -- `"high"` - -Note: to minimize reasoning, choose `"minimal"`. - -## model_reasoning_summary - -If the model name starts with `"o"` (as in `"o3"` or `"o4-mini"`) or `"codex"`, reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries), this can be set to: - -- `"auto"` (default) -- `"concise"` -- `"detailed"` - -To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in your config: - -```toml -model_reasoning_summary = "none" # disable reasoning summaries -``` - -## model_verbosity - -Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values: - -- `"low"` -- `"medium"` (default when omitted) -- `"high"` - -When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`. - -Example: - -```toml -model = "gpt-5" -model_verbosity = "low" -``` - -Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected. - -## model_supports_reasoning_summaries - -By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`: - -```toml -model_supports_reasoning_summaries = true -``` - -## sandbox_mode +### sandbox_mode Codex executes model-generated shell commands inside an OS-level sandbox. @@ -325,7 +310,19 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. -## Approval presets +### tools.\* + +Use the optional `[tools]` table to toggle built-in tools that the agent may call. Both keys default to `false` (tools stay disabled) unless you opt in: + +```toml +[tools] +web_search = true # allow Codex to issue first-party web searches without prompting you +view_image = true # let Codex attach local images (paths in your workspace) to the model request +``` + +`web_search` is also recognized under the legacy name `web_search_request`. The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. + +### approval_presets Codex provides three main Approval Presets: @@ -335,113 +332,9 @@ Codex provides three main Approval Presets: You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options. -## Connecting to MCP servers +> See also [Sandbox & approvals](./sandbox.md) for in-depth examples and platform-specific behaviour. -You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services. - -### Server configuration - -#### STDIO - -[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer. - -```toml -# The top-level table name must be `mcp_servers` -# The sub-table name (`server-name` in this example) can be anything you would like. -[mcp_servers.server_name] -command = "npx" -# Optional -args = ["-y", "mcp-server"] -# Optional: propagate additional env vars to the MVP server. -# A default whitelist of env vars will be propagated to the MCP server. -# https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82 -env = { "API_KEY" = "value" } -# or -[mcp_servers.server_name.env] -API_KEY = "value" -``` - -#### Streamable HTTP - -[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). - -```toml -# Streamable HTTP requires the experimental rmcp client -experimental_use_rmcp_client = true -[mcp_servers.figma] -url = "https://mcp.linear.app/mcp" -# Optional bearer token to be passed into an `Authorization: Bearer ` header -# Use this with caution because the token is in plaintext and can be read by Codex itself. -bearer_token = "" -``` - -For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` - -### Other configuration options - -```toml -# Optional: override the default 10s startup timeout -startup_timeout_sec = 20 -# Optional: override the default 60s per-tool timeout -tool_timeout_sec = 30 -``` - -### Experimental RMCP client - -Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). - -The flag enabled OAuth support for streamable HTTP servers and uses a new STDIO client implementation. - -Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` - -```toml -experimental_use_rmcp_client = true - -[mcp_servers.server_name] -… -``` - -### MCP CLI commands - -```shell -# List all available commands -codex mcp --help - -# Add a server (env can be repeated; `--` separates the launcher command) -codex mcp add docs -- docs-server --port 4000 - -# List configured servers (pretty table or JSON) -codex mcp list -codex mcp list --json - -# Show one server (table or JSON) -codex mcp get docs -codex mcp get docs --json - -# Remove a server -codex mcp remove docs - -# Log in to a streamable HTTP server that supports oauth -codex mcp login SERVER_NAME - -# Log out from a streamable HTTP server that supports oauth -codex mcp logout SERVER_NAME -``` - -## Examples of useful MCPs - -There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. - -Some of the most common MCPs we've seen are: - -- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation -- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs -- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright -- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser -- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs -- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.) - -## shell_environment_policy +### shell_environment_policy Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`: @@ -483,7 +376,131 @@ set = { PATH = "/usr/bin", MY_FLAG = "1" } Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. -## otel +## MCP integration + +### mcp_servers + +You can configure Codex to use [MCP servers](https://modelcontextprotocol.io/about) to give Codex access to external applications, resources, or services. + +#### Server configuration + +##### STDIO + +[STDIO servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) are MCP servers that you can launch directly via commands on your computer. + +```toml +# The top-level table name must be `mcp_servers` +# The sub-table name (`server-name` in this example) can be anything you would like. +[mcp_servers.server_name] +command = "npx" +# Optional +args = ["-y", "mcp-server"] +# Optional: propagate additional env vars to the MVP server. +# A default whitelist of env vars will be propagated to the MCP server. +# https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/utils.rs#L82 +env = { "API_KEY" = "value" } +# or +[mcp_servers.server_name.env] +API_KEY = "value" +# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment. +env_vars = ["API_KEY2"] + +# Optional: cwd that the command will be run from +cwd = "/Users//code/my-server" +``` + +##### Streamable HTTP + +[Streamable HTTP servers](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) enable Codex to talk to resources that are accessed via a http url (either on localhost or another domain). + +```toml +[mcp_servers.figma] +url = "https://mcp.linear.app/mcp" +# Optional environment variable containing a bearer token to use for auth +bearer_token_env_var = "" +# Optional map of headers with hard-coded values. +http_headers = { "HEADER_NAME" = "HEADER_VALUE" } +# Optional map of headers whose values will be replaced with the environment variable. +env_http_headers = { "HEADER_NAME" = "ENV_VAR" } +``` + +Streamable HTTP connections always use the experimental Rust MCP client under the hood, so expect occasional rough edges. OAuth login flows are gated on the `experimental_use_rmcp_client = true` flag: + +```toml +experimental_use_rmcp_client = true +``` + +After enabling it, run `codex mcp login ` when the server supports OAuth. + +#### Other configuration options + +```toml +# Optional: override the default 10s startup timeout +startup_timeout_sec = 20 +# Optional: override the default 60s per-tool timeout +tool_timeout_sec = 30 +# Optional: disable a server without removing it +enabled = false +``` + +#### Experimental RMCP client + +Codex is transitioning to the [official Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk). + +This flag enables OAuth support for streamable HTTP servers and switches STDIO servers over to the new client implementation. + +Please try and report issues with the new client. To enable it, add this to the top level of your `config.toml` + +```toml +experimental_use_rmcp_client = true + +[mcp_servers.server_name] +… +``` + +#### MCP CLI commands + +```shell +# List all available commands +codex mcp --help + +# Add a server (env can be repeated; `--` separates the launcher command) +codex mcp add docs -- docs-server --port 4000 + +# List configured servers (pretty table or JSON) +codex mcp list +codex mcp list --json + +# Show one server (table or JSON) +codex mcp get docs +codex mcp get docs --json + +# Remove a server +codex mcp remove docs + +# Log in to a streamable HTTP server that supports oauth +codex mcp login SERVER_NAME + +# Log out from a streamable HTTP server that supports oauth +codex mcp logout SERVER_NAME +``` + +### Examples of useful MCPs + +There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. + +Some of the most common MCPs we've seen are: + +- [Context7](https://github.com/upstash/context7) — connect to a wide range of up-to-date developer documentation +- Figma [Local](https://developers.figma.com/docs/figma-mcp-server/local-server-installation/) and [Remote](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/) - access to your Figma designs +- [Playwright](https://www.npmjs.com/package/@playwright/mcp) - control and inspect a browser using Playwright +- [Chrome Developer Tools](https://github.com/ChromeDevTools/chrome-devtools-mcp/) — control and inspect a Chrome browser +- [Sentry](https://docs.sentry.io/product/sentry-mcp/#codex) — access to your Sentry logs +- [GitHub](https://github.com/github/github-mcp-server) — Control over your GitHub account beyond what git allows (like controlling PRs, issues, etc.) + +## Observability and telemetry + +### otel Codex can emit [OpenTelemetry](https://opentelemetry.io/) **log events** that describe each run: outbound API requests, streamed responses, user input, @@ -508,7 +525,7 @@ crate—the events listed below—is forwarded to the exporter. Every event shares a common set of metadata fields: `event.timestamp`, `conversation.id`, `app.version`, `auth_mode` (when available), -`user.account_id` (when available), `terminal.type`, `model`, and `slug`. +`user.account_id` (when available), `user.email` (when available), `terminal.type`, `model`, and `slug`. With OTEL enabled Codex emits the following event types (in addition to the metadata above): @@ -594,13 +611,14 @@ flag; the official prebuilt binaries ship with the feature enabled. When the feature is disabled the telemetry hooks become no-ops so the CLI continues to function without the extra dependencies. -## notify +### notify Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: ```json { "type": "agent-turn-complete", + "thread-id": "b5f6c1c2-1111-2222-3333-444455556666", "turn-id": "12345", "input-messages": ["Rename `foo` to `bar` and update the callsites."], "last-assistant-message": "Rename complete and verified `cargo build` succeeds." @@ -609,6 +627,8 @@ Specify a program that will be executed to get notified about events generated b The `"type"` property will always be set. Currently, `"agent-turn-complete"` is the only notification type that is supported. +`"thread-id"` contains a string that identifies the Codex session that produced the notification; you can use it to correlate multiple turns that belong to the same task. + As an example, here is a Python script that parses the JSON and decides whether to show a desktop push notification using [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS: ```python @@ -636,13 +656,15 @@ def main() -> int: title = f"Codex: {assistant_message}" else: title = "Codex: Turn Complete!" - input_messages = notification.get("input_messages", []) + input_messages = notification.get("input-messages", []) message = " ".join(input_messages) title += message case _: print(f"not sending a push notification for: {notification_type}") return 0 + thread_id = notification.get("thread-id", "") + subprocess.check_output( [ "terminal-notifier", @@ -651,7 +673,7 @@ def main() -> int: "-message", message, "-group", - "codex", + "codex-" + thread_id, "-ignoreDnD", "-activate", "com.googlecode.iterm2", @@ -674,7 +696,79 @@ notify = ["python3", "/Users/mbolin/.codex/notify.py"] > [!NOTE] > Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. -## history +### hide_agent_reasoning + +Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output. + +Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command: + +```toml +hide_agent_reasoning = true # defaults to false +``` + +### show_raw_agent_reasoning + +Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. + +Notes: + +- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. +- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. + +Example: + +```toml +show_raw_agent_reasoning = true # defaults to false +``` + +## Profiles and overrides + +### profiles + +A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you +want to use at runtime via the `--profile` flag. + +Here is an example of a `config.toml` that defines multiple profiles: + +```toml +model = "o3" +approval_policy = "untrusted" + +# Setting `profile` is equivalent to specifying `--profile o3` on the command +# line, though the `--profile` flag can still be used to override this value. +profile = "o3" + +[model_providers.openai-chat-completions] +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + +[profiles.o3] +model = "o3" +model_provider = "openai" +approval_policy = "never" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" + +[profiles.gpt3] +model = "gpt-3.5-turbo" +model_provider = "openai-chat-completions" + +[profiles.zdr] +model = "o3" +model_provider = "openai" +approval_policy = "on-failure" +``` + +Users can specify config values at multiple levels. Order of precedence is as follows: + +1. custom command-line argument, e.g., `--model o3` +2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself) +3. as an entry in `config.toml`, e.g., `model = "o3"` +4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`) + +### history By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. @@ -685,7 +779,7 @@ To disable this behavior, configure `[history]` as follows: persistence = "none" # "save-all" is the default value ``` -## file_opener +### file_opener Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them. @@ -701,46 +795,11 @@ Note this is **not** a general editor setting (like `$EDITOR`), as it only accep Currently, `"vscode"` is the default, though Codex does not verify VS Code is installed. As such, `file_opener` may default to `"none"` or something else in the future. -## hide_agent_reasoning - -Codex intermittently emits "reasoning" events that show the model's internal "thinking" before it produces a final answer. Some users may find these events distracting, especially in CI logs or minimal terminal output. - -Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the TUI as well as the headless `exec` sub-command: - -```toml -hide_agent_reasoning = true # defaults to false -``` - -## show_raw_agent_reasoning - -Surfaces the model’s raw chain-of-thought ("raw reasoning content") when available. - -Notes: - -- Only takes effect if the selected model/provider actually emits raw reasoning content. Many models do not. When unsupported, this option has no visible effect. -- Raw reasoning may include intermediate thoughts or sensitive context. Enable only if acceptable for your workflow. - -Example: - -```toml -show_raw_agent_reasoning = true # defaults to false -``` - -## model_context_window - -The size of the context window for the model, in tokens. - -In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. - -## model_max_output_tokens - -This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. - -## project_doc_max_bytes +### project_doc_max_bytes Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB. -## project_doc_fallback_filenames +### project_doc_fallback_filenames Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time. @@ -750,7 +809,9 @@ project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"] We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance. -## tui +> See also [AGENTS.md discovery](./agents_md.md) for how Codex locates these files during a session. + +### tui Options that are specific to the TUI. @@ -770,6 +831,22 @@ notifications = [ "agent-turn-complete", "approval-requested" ] > [!NOTE] > `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. +## Forcing a login method + +To force users on a given machine to use a specific login method or workspace, use a combination of [managed configurations](https://developers.openai.com/codex/security#managed-configuration) as well as either or both of the following fields: + +```toml +# Force the user to log in with ChatGPT or via an api key. +forced_login_method = "chatgpt" or "api" +# When logging in with ChatGPT, only the specified workspace ID will be presented during the login +# flow and the id will be validated during the oauth callback as well as every time Codex starts. +forced_chatgpt_workspace_id = "00000000-0000-0000-0000-000000000000" +``` + +If the active credentials don't match the config, the user will be logged out and Codex will exit. + +If `forced_chatgpt_workspace_id` is set but `forced_login_method` is not set, API key login will still work. + ## Config reference | Key | Type / Values | Notes | @@ -784,12 +861,14 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `sandbox_workspace_write.network_access` | boolean | Allow network in workspace‑write (default: false). | | `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). | | `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). | -| `disable_response_storage` | boolean | Required for ZDR orgs. | | `notify` | array | External program for notifications. | | `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | -| `mcp_servers..command` | string | MCP server launcher command. | -| `mcp_servers..args` | array | MCP server args. | -| `mcp_servers..env` | map | MCP server env vars. | +| `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | +| `mcp_servers..args` | array | MCP server args (stdio servers only). | +| `mcp_servers..env` | map | MCP server env vars (stdio servers only). | +| `mcp_servers..url` | string | MCP server url (streamable http servers only). | +| `mcp_servers..bearer_token_env_var` | string | environment variable containing a bearer token to use for auth (streamable http servers only). | +| `mcp_servers..enabled` | boolean | When false, Codex skips starting the server (default: true). | | `mcp_servers..startup_timeout_sec` | number | Startup timeout in seconds (default: 10). Timeout is applied both for initializing MCP server and initially listing tools. | | `mcp_servers..tool_timeout_sec` | number | Per-tool timeout in seconds (default: 60). Accepts fractional values; omit to use the default. | | `model_providers..name` | string | Display name. | @@ -818,9 +897,10 @@ notifications = [ "agent-turn-complete", "approval-requested" ] | `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | | `model_reasoning_summary_format` | `none` \| `experimental` | Force reasoning summary format. | | `chatgpt_base_url` | string | Base URL for ChatGPT auth flow. | -| `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). | | `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | -| `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | +| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | +| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | +| `tools.view_image` | boolean | Enable the `view_image` tool so Codex can attach local image files from the workspace (default: false). | diff --git a/docs/contributing.md b/docs/contributing.md index eb67f1ce59..155f9f1ebe 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -87,7 +87,7 @@ This will push the commit using the tag `rust-v${VERSION}`, which in turn kicks If everything looks good in the generated GitHub Release, uncheck the **pre-release** box so it is the latest release. -Create a PR to update [`Formula/c/codex.rb`](https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb) on Homebrew. +Create a PR to update [`Cask/c/codex.rb`](https://github.com/Homebrew/homebrew-cask/blob/main/Formula/c/codex.rb) on Homebrew. ### Security & responsible AI diff --git a/docs/faq.md b/docs/faq.md index 33cc1fb932..55a6e5e33b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,23 +1,44 @@ ## FAQ +This FAQ highlights the most common questions and points you to the right deep-dive guides in `docs/`. + ### OpenAI released a model called Codex in 2021 - is this related? In 2021, OpenAI released Codex, an AI system designed to generate code from natural language prompts. That original Codex model was deprecated as of March 2023 and is separate from the CLI tool. ### Which models are supported? -We recommend using Codex with GPT-5, our best coding model. The default reasoning level is medium, and you can upgrade to high for complex tasks with the `/model` command. +We recommend using Codex with GPT-5 Codex, our best coding model. The default reasoning level is medium, and you can upgrade to high for complex tasks with the `/model` command. You can also use older models by using API-based auth and launching codex with the `--model` flag. -### Why does `o3` or `o4-mini` not work for me? +### How do approvals and sandbox modes work together? -It's possible that your [API account needs to be verified](https://help.openai.com/en/articles/10910291-api-organization-verification) in order to start streaming responses and seeing chain of thought summaries from the API. If you're still running into issues, please let us know! +Approvals are the mechanism Codex uses to ask before running a tool call with elevated permissions - typically to leave the sandbox or re-run a failed command without isolation. Sandbox mode provides the baseline isolation (`Read Only`, `Workspace Write`, or `Danger Full Access`; see [Sandbox & approvals](./sandbox.md)). + +### Can I automate tasks without the TUI? + +Yes. [`codex exec`](./exec.md) runs Codex in non-interactive mode with streaming logs, JSONL output, and structured schema support. The command respects the same sandbox and approval settings you configure in the [Config guide](./config.md). ### How do I stop Codex from editing my files? By default, Codex can modify files in your current working directory (Auto mode). To prevent edits, run `codex` in read-only mode with the CLI flag `--sandbox read-only`. Alternatively, you can change the approval level mid-conversation with `/approvals`. +### How do I connect Codex to MCP servers? + +Configure MCP servers through your `config.toml` using the examples in [Config -> Connecting to MCP servers](./config.md#connecting-to-mcp-servers). + +### I'm having trouble logging in. What should I check? + +Confirm your setup in three steps: + +1. Walk through the auth flows in [Authentication](./authentication.md) to ensure the correct credentials are present in `~/.codex/auth.json`. +2. If you're on a headless or remote machine, make sure port-forwarding is configured as described in [Authentication -> Connecting on a "Headless" Machine](./authentication.md#connecting-on-a-headless-machine). + ### Does it work on Windows? Running Codex directly on Windows may work, but is not officially supported. We recommend using [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install). + +### Where should I start after installation? + +Follow the quick setup in [Install & build](./install.md) and then jump into [Getting started](./getting-started.md) for interactive usage tips, prompt examples, and AGENTS.md guidance. diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048..3473cf5319 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,5 +1,11 @@ ## Getting started +Looking for something specific? Jump ahead: + +- [Tips & shortcuts](#tips--shortcuts) – hotkeys, resume flow, prompts +- [Non-interactive runs](./exec.md) – automate with `codex exec` +- Ready for deeper customization? Head to [`advanced.md`](./advanced.md) + ### CLI usage | Command | Purpose | Example | @@ -59,6 +65,8 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote | 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. | | 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. | +Looking to reuse your own instructions? Create slash commands with [custom prompts](./prompts.md). + ### Memory with AGENTS.md You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down: @@ -75,21 +83,26 @@ For more information on how to use AGENTS.md, see the [official AGENTS.md docume 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. -#### Image input - -Paste images directly into the composer (Ctrl+V / Cmd+V) to attach them to your prompt. You can also attach files via the CLI using `-i/--image` (comma‑separated): - -```bash -codex -i screenshot.png "Explain this error" -codex --image img1.png,img2.jpg "Summarize these diagrams" -``` - #### 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. + +#### `--add-dir` flag + +Need to work across multiple projects in one run? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example: + +```shell +codex --cd apps/frontend --add-dir ../backend --add-dir ../shared +``` + +Codex can then inspect and edit files in each listed directory without leaving the primary workspace. + #### Shell completions Generate shell completion scripts via: @@ -100,6 +113,11 @@ codex completion zsh codex completion fish ``` -#### `--cd`/`-C` flag +#### Image input -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. +Paste images directly into the composer (Ctrl+V / Cmd+V) to attach them to your prompt. You can also attach files via the CLI using `-i/--image` (comma‑separated): + +```bash +codex -i screenshot.png "Explain this error" +codex --image img1.png,img2.jpg "Summarize these diagrams" +``` diff --git a/docs/platform-sandboxing.md b/docs/platform-sandboxing.md index 32619a48ef..39ffce67c3 100644 --- a/docs/platform-sandboxing.md +++ b/docs/platform-sandboxing.md @@ -1,8 +1,3 @@ -### Platform sandboxing details +## Platform sandboxing -The mechanism Codex uses to implement the sandbox policy depends on your OS: - -- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. -- **Linux** uses a combination of Landlock/seccomp APIs to enforce the `sandbox` configuration. - -Note that when running Linux in a containerized environment such as Docker, sandboxing may not work if the host/container configuration does not support the necessary Landlock/seccomp APIs. In such cases, we recommend configuring your Docker container so that it provides the sandbox guarantees you are looking for and then running `codex` with `--sandbox danger-full-access` (or, more simply, the `--dangerously-bypass-approvals-and-sandbox` flag) within your container. +This content now lives alongside the rest of the sandbox guidance. See [Sandbox mechanics by platform](./sandbox.md#platform-sandboxing-details) for up-to-date details. diff --git a/docs/prompts.md b/docs/prompts.md index 5157c4ecea..2e6c9fc1e6 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -1,20 +1,57 @@ ## Custom Prompts -Save frequently used prompts as Markdown files and reuse them quickly from the slash menu. +Custom prompts turn your repeatable instructions into reusable slash commands, so you can trigger them without retyping or copy/pasting. Each prompt is a Markdown file that Codex expands into the conversation the moment you run it. -- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). -- File type: Only Markdown files with the `.md` extension are recognized. -- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. -- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. -- Arguments: Local prompts support placeholders in their content: - - `$1..$9` expand to the first nine positional arguments typed after the slash name - - `$ARGUMENTS` expands to all arguments joined by a single space - - `$$` is preserved literally - - Quoted args: Wrap a single argument in double quotes to include spaces, e.g. `/review "docs/My File.md"`. -- How to use: - - Start a new session (Codex loads custom prompts on session start). - - In the composer, type `/` to open the slash popup and begin typing your prompt name. - - Use Up/Down to select it. Press Enter to submit its contents, or Tab to autocomplete the name. -- Notes: - - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. - - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. +### Where prompts live + +- Location: store prompts in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). Set `CODEX_HOME` if you want to use a different folder. +- File type: Codex only loads `.md` files. Non-Markdown files are ignored. +- Naming: The filename (without `.md`) becomes the prompt name. A file called `review.md` registers the prompt `review`. +- Refresh: Prompts are loaded when a session starts. Restart Codex (or start a new session) after adding or editing files. +- Conflicts: Files whose names collide with built-in commands (like `init`) are skipped. + +### File format + +- Body: The file contents are sent verbatim when you run the prompt (after placeholder expansion). +- Frontmatter (optional): Add YAML-style metadata at the top of the file to improve the slash popup. + + ```markdown + --- + description: Request a concise git diff review + argument-hint: FILE= [FOCUS=

] + --- + ``` + + - `description` shows under the entry in the popup. + - `argument-hint` (or `argument_hint`) displays a short hint about expected inputs. + +### Placeholders and arguments + +- Numeric placeholders: `$1`–`$9` insert the first nine positional arguments you type after the command. `$ARGUMENTS` inserts all positional arguments joined by a single space. Use `$$` to emit a literal dollar sign (Codex leaves `$$` untouched). +- Named placeholders: Tokens such as `$FILE` or `$TICKET_ID` expand from `KEY=value` pairs you supply. Keys are case-sensitive—use the same uppercase name in the command (for example, `FILE=...`). +- Quoted arguments: Double-quote any value that contains spaces, e.g. `TICKET_TITLE="Fix logging"`. +- Invocation syntax: Run prompts via `/prompts: ...`. When the slash popup is open, typing either `prompts:` or the bare prompt name will surface `/prompts:` suggestions. +- Error handling: If a prompt contains named placeholders, Codex requires them all. You will see a validation message if any are missing or malformed. + +### Running a prompt + +1. Start a new Codex session (ensures the prompt list is fresh). +2. In the composer, type `/` to open the slash popup. +3. Type `prompts:` (or start typing the prompt name) and select it with ↑/↓. +4. Provide any required arguments, press Enter, and Codex sends the expanded content. + +### Examples + +**Draft PR helper** + +`~/.codex/prompts/draftpr.md` + +```markdown +--- +description: Create feature branch, commit and open draft PR. +--- + +Create a branch named `tibo/`, commit the changes, and open a draft PR. +``` + +Usage: type `/prompts:draftpr` to have codex perform the work. diff --git a/docs/release_management.md b/docs/release_management.md index d2b3264554..05148b1c93 100644 --- a/docs/release_management.md +++ b/docs/release_management.md @@ -4,7 +4,7 @@ Currently, we made Codex binaries available in three places: - GitHub Releases https://github.com/openai/codex/releases/ - `@openai/codex` on npm: https://www.npmjs.com/package/@openai/codex -- `codex` on Homebrew: https://formulae.brew.sh/formula/codex +- `codex` on Homebrew: https://formulae.brew.sh/cask/codex # Cutting a Release @@ -34,14 +34,12 @@ The GitHub Action is responsible for publishing to npm. ## Publishing to Homebrew -For Homebrew, we are properly set up with their automation system, so every few hours or so it will check our GitHub repo to see if there is a new release. When it finds one, it will put up a PR to create the equivalent Homebrew release, which entails building Codex CLI from source on various versions of macOS. +For Homebrew, we ship Codex as a cask. Homebrew's automation system checks our GitHub repo every few hours for a new release and will open a PR to update the cask with the latest binary. Inevitably, you just have to refresh this page periodically to see if the release has been picked up by their automation system: -https://github.com/Homebrew/homebrew-core/pulls?q=%3Apr+codex +https://github.com/Homebrew/homebrew-cask/pulls?q=%3Apr+codex -Once everything builds, a Homebrew admin has to approve the PR. Again, the whole process takes several hours and we don't have total control over it, but it seems to work pretty well. +For reference, our Homebrew cask lives at: -For reference, our Homebrew formula lives at: - -https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb +https://github.com/Homebrew/homebrew-cask/blob/main/Casks/c/codex.rb diff --git a/docs/sandbox.md b/docs/sandbox.md index 711ecc8f25..d08f4d85d2 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,23 +1,23 @@ ## Sandbox & approvals -### Approval modes +What Codex is allowed to do is governed by a combination of **sandbox modes** (what Codex is allowed to do without supervision) and **approval policies** (when you must confirm an action). This page explains the options, how they interact, and how the sandbox behaves on each platform. -We've chosen a powerful default for how Codex works on your computer: `Auto`. In this approval mode, Codex can read files, make edits, and run commands in the working directory automatically. However, Codex will need your approval to work outside the working directory or access network. +### Approval policies -When you just want to chat, or if you want to plan before diving in, you can switch to `Read Only` mode with the `/approvals` command. +Codex starts conservatively. Until you explicitly tell it a workspace is trusted, the CLI defaults to **read-only sandboxing** with the `read-only` approval preset. Codex can inspect files and answer questions, but every edit or command requires approval. -If you need Codex to read files, make edits, and run commands with network access, without approval, you can use `Full Access`. Exercise caution before doing so. +When you mark a workspace as trusted (for example via the onboarding prompt or `/approvals` → “Trust this directory”), Codex upgrades the default preset to **Auto**: sandboxed writes inside the workspace with `AskForApproval::OnRequest`. Codex only interrupts you when it needs to leave the workspace or rerun something outside the sandbox. + +If you want maximum guardrails for a trusted repo, switch back to Read Only from the `/approvals` picker. If you truly need hands-off automation, use `Full Access`—but be deliberate, because that skips both the sandbox and approvals. #### Defaults and recommendations -- Codex runs in a sandbox by default with strong guardrails: it prevents editing files outside the workspace and blocks network access unless enabled. -- On launch, Codex detects whether the folder is version-controlled and recommends: - - Version-controlled folders: `Auto` (workspace write + on-request approvals) - - Non-version-controlled folders: `Read Only` -- The workspace includes the current directory and temporary directories like `/tmp`. Use the `/status` command to see which directories are in the workspace. -- You can set these explicitly: - - `codex --sandbox workspace-write --ask-for-approval on-request` +- Every session starts in a sandbox. Until a repo is trusted, Codex enforces read-only access and will prompt before any write or command. +- Marking a repo as trusted switches the default preset to Auto (`workspace-write` + `ask-for-approval on-request`) so Codex can keep iterating locally without nagging you. +- The workspace always includes the current directory plus temporary directories like `/tmp`. Use `/status` to confirm the exact writable roots. +- You can override the defaults from the command line at any time: - `codex --sandbox read-only --ask-for-approval on-request` + - `codex --sandbox workspace-write --ask-for-approval on-request` ### Can I run without ANY approvals? @@ -30,7 +30,7 @@ Yes, you can disable all approval prompts with `--ask-for-approval never`. This | Safe read-only browsing | `--sandbox read-only --ask-for-approval on-request` | Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network. | | Read-only non-interactive (CI) | `--sandbox read-only --ask-for-approval never` | Reads only; never escalates | | Let it edit the repo, ask if risky | `--sandbox workspace-write --ask-for-approval on-request` | Codex can read files, make edits, and run commands in the workspace. Codex requires approval for actions outside the workspace or for network access. | -| Auto (preset) | `--full-auto` (equivalent to `--sandbox workspace-write` + `--ask-for-approval on-failure`) | Codex can read files, make edits, and run commands in the workspace. Codex requires approval when a sandboxed command fails or needs escalation. | +| Auto (preset; trusted repos) | `--full-auto` (equivalent to `--sandbox workspace-write` + `--ask-for-approval on-request`) | Codex runs sandboxed commands that can write inside the workspace without prompting. Escalates only when it must leave the sandbox. | | YOLO (not recommended) | `--dangerously-bypass-approvals-and-sandbox` (alias: `--yolo`) | No sandbox; no prompts | > Note: In `workspace-write`, network is disabled by default unless enabled in config (`[sandbox_workspace_write].network_access = true`). @@ -63,9 +63,18 @@ approval_policy = "never" sandbox_mode = "read-only" ``` +### Sandbox mechanics by platform {#platform-sandboxing-details} + +The mechanism Codex uses to enforce the sandbox policy depends on your OS: + +- **macOS 12+** uses **Apple Seatbelt**. Codex invokes `sandbox-exec` with a profile that corresponds to the selected `--sandbox` mode, constraining filesystem and network access at the OS level. +- **Linux** combines **Landlock** and **seccomp** APIs to approximate the same guarantees. Kernel support is required; older kernels may not expose the necessary features. + +In containerized Linux environments (for example Docker), sandboxing may not work when the host or container configuration does not expose Landlock/seccomp. In those cases, configure the container to provide the isolation you need and run Codex with `--sandbox danger-full-access` (or the shorthand `--dangerously-bypass-approvals-and-sandbox`) inside that container. + ### 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: +To test how commands behave under Codex's sandbox, use the CLI helpers: ``` # macOS @@ -78,12 +87,3 @@ codex sandbox linux [--full-auto] [COMMAND]... codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` - -### Platform sandboxing details - -The mechanism Codex uses to implement the sandbox policy depends on your OS: - -- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. -- **Linux** uses a combination of Landlock/seccomp APIs to enforce the `sandbox` configuration. - -Note that when running Linux in a containerized environment such as Docker, sandboxing may not work if the host/container configuration does not support the necessary Landlock/seccomp APIs. In such cases, we recommend configuring your Docker container so that it provides the sandbox guarantees you are looking for and then running `codex` with `--sandbox danger-full-access` (or, more simply, the `--dangerously-bypass-approvals-and-sandbox` flag) within your container. diff --git a/flake.lock b/flake.lock index 6e4f3acce7..21550893f4 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1744463964, - "narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", + "lastModified": 1758427187, + "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", + "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", "type": "github" }, "original": { @@ -36,44 +18,7 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1746844454, - "narHash": "sha256-GcUWDQUDRYrD34ol90KGUpjbVcOfUNbv0s955jPecko=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "be092436d4c0c303b654e4007453b69c0e33009e", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 7247333c5a..b331c443bb 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,28 @@ { description = "Development Nix flake for OpenAI Codex CLI"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { nixpkgs, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems f; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + codex-rs = pkgs.callPackage ./codex-rs { }; + in + { + codex-rs = codex-rs; + default = codex-rs; + } + ); }; - }; - - outputs = { nixpkgs, flake-utils, rust-overlay, ... }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { - inherit system; - }; - pkgsWithRust = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; - monorepo-deps = with pkgs; [ - # for precommit hook - pnpm - husky - ]; - codex-cli = import ./codex-cli { - inherit pkgs monorepo-deps; - }; - codex-rs = import ./codex-rs { - pkgs = pkgsWithRust; - inherit monorepo-deps; - }; - in - rec { - packages = { - codex-cli = codex-cli.package; - codex-rs = codex-rs.package; - }; - - devShells = { - codex-cli = codex-cli.devShell; - codex-rs = codex-rs.devShell; - }; - - apps = { - codex-cli = codex-cli.app; - codex-rs = codex-rs.app; - }; - - defaultPackage = packages.codex-cli; - defaultApp = apps.codex-cli; - defaultDevShell = devShells.codex-cli; - } - ); } diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1aeffce673..df36892337 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -83,6 +83,18 @@ const turn = await thread.run("Summarize repository status", { console.log(turn.finalResponse); ``` +### Attaching images + +Provide structured input entries when you need to include images alongside text. Text entries are concatenated into the final prompt while image entries are passed to the Codex CLI via `--image`. + +```typescript +const turn = await thread.run([ + { type: "text", text: "Describe these screenshots" }, + { type: "local_image", path: "./ui.png" }, + { type: "local_image", path: "./diagram.jpg" }, +]); +``` + ### Resuming an existing thread Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. @@ -95,7 +107,7 @@ await thread.run("Implement the fix"); ### Working directory controls -Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. +Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. ```typescript const thread = codex.startThread({ diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index b1cf4c414a..45ae5fc646 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -11,6 +11,7 @@ export type CodexExecArgs = { baseUrl?: string; apiKey?: string; threadId?: string | null; + images?: string[]; // --model model?: string; // --sandbox @@ -55,6 +56,12 @@ export class CodexExec { commandArgs.push("--output-schema", args.outputSchemaFile); } + if (args.images?.length) { + for (const image of args.images) { + commandArgs.push("--image", image); + } + } + if (args.threadId) { commandArgs.push("resume", args.threadId); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6e1ace9d14..8e6a71df8c 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -24,7 +24,7 @@ export type { } from "./items"; export { Thread } from "./thread"; -export type { RunResult, RunStreamedResult, Input } from "./thread"; +export type { RunResult, RunStreamedResult, Input, UserInput } from "./thread"; export { Codex } from "./codex"; diff --git a/sdk/typescript/src/items.ts b/sdk/typescript/src/items.ts index 37563f9af9..fa2e0326bf 100644 --- a/sdk/typescript/src/items.ts +++ b/sdk/typescript/src/items.ts @@ -102,11 +102,6 @@ export type TodoListItem = { items: TodoItem[]; }; -export type SessionItem = { - id: string; - type: "session"; - session_id: string; -}; /** Canonical union of thread items and their type-specific payloads. */ export type ThreadItem = diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 0caf52b486..2306df25b6 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -25,7 +25,17 @@ export type StreamedTurn = { export type RunStreamedResult = StreamedTurn; /** An input to send to the agent. */ -export type Input = string; +export type UserInput = + | { + type: "text"; + text: string; + } + | { + type: "local_image"; + path: string; + }; + +export type Input = string | UserInput[]; /** Respesent a thread of conversation with the agent. One thread can have multiple consecutive turns. */ export class Thread { @@ -53,21 +63,23 @@ export class Thread { } /** Provides the input to the agent and streams events as they are produced during the turn. */ - async runStreamed(input: string, turnOptions: TurnOptions = {}): Promise { + async runStreamed(input: Input, turnOptions: TurnOptions = {}): Promise { return { events: this.runStreamedInternal(input, turnOptions) }; } private async *runStreamedInternal( - input: string, + input: Input, turnOptions: TurnOptions = {}, ): AsyncGenerator { const { schemaPath, cleanup } = await createOutputSchemaFile(turnOptions.outputSchema); const options = this._threadOptions; + const { prompt, images } = normalizeInput(input); const generator = this._exec.run({ - input, + input: prompt, baseUrl: this._options.baseUrl, apiKey: this._options.apiKey, threadId: this._id, + images, model: options?.model, sandboxMode: options?.sandboxMode, workingDirectory: options?.workingDirectory, @@ -93,7 +105,7 @@ export class Thread { } /** Provides the input to the agent and returns the completed turn. */ - async run(input: string, turnOptions: TurnOptions = {}): Promise { + async run(input: Input, turnOptions: TurnOptions = {}): Promise { const generator = this.runStreamedInternal(input, turnOptions); const items: ThreadItem[] = []; let finalResponse: string = ""; @@ -118,3 +130,19 @@ export class Thread { return { items, finalResponse, usage }; } } + +function normalizeInput(input: Input): { prompt: string; images: string[] } { + if (typeof input === "string") { + return { prompt: input, images: [] }; + } + const promptParts: string[] = []; + const images: string[] = []; + for (const item of input) { + if (item.type === "text") { + promptParts.push(item.text); + } else if (item.type === "local_image") { + images.push(item.path); + } + } + return { prompt: promptParts.join("\n\n"), images }; +} diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index de7ef5555d..f495b1e6ec 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -279,6 +279,82 @@ describe("Codex", () => { await close(); } }); + it("combines structured text input segments", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Combined input applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "Describe file changes" }, + { type: "text", text: "Focus on impacted tests" }, + ]); + + const payload = requests[0]; + expect(payload).toBeDefined(); + const lastUser = payload!.json.input.at(-1); + expect(lastUser?.content?.[0]?.text).toBe("Describe file changes\n\nFocus on impacted tests"); + } finally { + await close(); + } + }); + it("forwards images to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Images applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-images-")); + const imagesDirectoryEntries: [string, string] = [ + path.join(tempDir, "first.png"), + path.join(tempDir, "second.jpg"), + ]; + imagesDirectoryEntries.forEach((image, index) => { + fs.writeFileSync(image, `image-${index}`); + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run([ + { type: "text", text: "describe the images" }, + { type: "local_image", path: imagesDirectoryEntries[0] }, + { type: "local_image", path: imagesDirectoryEntries[1] }, + ]); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + const forwardedImages: string[] = []; + for (let i = 0; i < commandArgs!.length; i += 1) { + if (commandArgs![i] === "--image") { + forwardedImages.push(commandArgs![i + 1] ?? ""); + } + } + expect(forwardedImages).toEqual(imagesDirectoryEntries); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + restore(); + await close(); + } + }); it("runs in provided working directory", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200,