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-release.yml b/.github/workflows/rust-release.yml index be04615699..94143acb49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -206,6 +206,69 @@ jobs: 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: | 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/AGENTS.md b/AGENTS.md index 832f1d6543..81b8d10ac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ In the codex-rs folder where the rust code lives: - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls - 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/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/Cargo.lock b/codex-rs/Cargo.lock index 0a910fb392..f348adb2dd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1153,6 +1153,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" @@ -1384,6 +1395,7 @@ dependencies = [ "axum", "codex-protocol", "dirs", + "escargot", "futures", "keyring", "mcp-types", @@ -1393,6 +1405,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tiny_http", @@ -1418,6 +1431,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-git-tooling", "codex-login", @@ -1452,6 +1466,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -1607,10 +1622,12 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "notify", "regex-lite", "serde_json", "tempfile", "tokio", + "walkdir", "wiremock", ] @@ -1851,6 +1868,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" @@ -2329,6 +2356,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" @@ -2401,6 +2440,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" @@ -2688,6 +2736,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" @@ -3087,6 +3146,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" @@ -3287,6 +3366,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" @@ -3686,6 +3785,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" @@ -4860,6 +4983,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" @@ -5174,6 +5306,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" @@ -6382,6 +6628,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" @@ -6441,6 +6696,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 4b324c4b54..3e621c3500 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "core", "exec", "execpolicy", + "feedback", "file-search", "git-tooling", "linux-sandbox", @@ -57,6 +58,7 @@ 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" } @@ -84,8 +86,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" @@ -123,6 +125,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" @@ -147,6 +150,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" diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 845a2431f4..916560c60b 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -9,6 +9,7 @@ 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; @@ -697,6 +698,7 @@ 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)] @@ -904,6 +906,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 +925,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..f455ad32e1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1284,6 +1284,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }) => { let params = ExecCommandApprovalParams { conversation_id, @@ -1291,6 +1292,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) 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/cli/src/main.rs b/codex-rs/cli/src/main.rs index 79ddb58888..4d3160999f 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; @@ -28,6 +29,8 @@ mod mcp_cmd; use crate::infty::InftyCli; use crate::mcp_cmd::McpCli; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; /// Codex CLI /// @@ -47,6 +50,9 @@ struct MultitoolCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, + #[clap(flatten)] + pub feature_toggles: FeatureToggles, + #[clap(flatten)] interactive: TuiCli, @@ -100,6 +106,9 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + /// Inspect feature flags. + Features(FeaturesCli), + /// [experimental] Manage Codex Infty long-running task runs. #[clap(name = "infty")] Infty(InftyCli), @@ -206,6 +215,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec 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 @@ -254,11 +332,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( @@ -266,7 +350,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( @@ -299,7 +383,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( @@ -394,6 +478,30 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() 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(()) @@ -497,6 +605,7 @@ mod tests { interactive, config_overrides: root_overrides, subcommand, + feature_toggles: _, } = cli; let Subcommand::Resume(ResumeCommand { @@ -522,6 +631,7 @@ mod tests { conversation_id: conversation .map(ConversationId::from_string) .map(Result::unwrap), + update_action: None, } } @@ -530,6 +640,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 0a5be0dc23..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,10 +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. /// @@ -189,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, @@ -222,20 +228,28 @@ 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, @@ -248,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(()) } @@ -285,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." ); @@ -297,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(()) } @@ -357,20 +403,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .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, }) } }; @@ -399,30 +457,29 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 6]> = Vec::new(); + let mut stdio_rows: Vec<[String; 7]> = Vec::new(); let mut http_rows: Vec<[String; 5]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let env_display = format_env_display(env.as_ref(), env_vars); + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); let status = if cfg.enabled { "enabled".to_string() } else { @@ -438,6 +495,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> command.clone(), args_display, env_display, + cwd_display, status, auth_status, ]); @@ -445,6 +503,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + .. } => { let status = if cfg.enabled { "enabled".to_string() @@ -473,6 +532,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "Command".len(), "Args".len(), "Env".len(), + "Cwd".len(), "Status".len(), "Auth".len(), ]; @@ -483,36 +543,40 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{name: 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!({ @@ -614,7 +690,13 @@ 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() { @@ -623,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 @@ -635,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 705509abf5..7a6c2daaff 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -28,10 +28,18 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -112,9 +120,13 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -150,9 +162,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/issues"); assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 8f33a8e470..ea0d6fc1d9 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,9 @@ use std::path::Path; use anyhow::Result; +use codex_core::config::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; @@ -27,8 +30,8 @@ fn list_shows_empty_state() -> Result<()> { Ok(()) } -#[test] -fn list_and_get_render_expected_output() -> Result<()> { +#[tokio::test] +async fn list_and_get_render_expected_output() -> Result<()> { let codex_home = TempDir::new()?; let mut add = codex_command(codex_home.path())?; @@ -46,6 +49,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()); @@ -54,6 +69,8 @@ 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")); @@ -79,7 +96,12 @@ fn list_and_get_render_expected_output() -> Result<()> { ], "env": { "TOKEN": "secret" - } + }, + "env_vars": [ + "APP_TOKEN", + "WORKSPACE_ID" + ], + "cwd": null }, "startup_timeout_sec": null, "tool_timeout_sec": null, @@ -98,6 +120,8 @@ 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")); 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/core/src/auth.rs b/codex-rs/core/src/auth.rs index 4eea313e66..21cb4bf6d6 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -135,6 +135,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) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index d6f394fb86..de0b9d4151 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -5,6 +5,8 @@ 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; @@ -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..3a259fce67 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; @@ -47,6 +49,7 @@ 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::util::backoff; use codex_otel::otel_event_manager::OtelEventManager; @@ -123,8 +126,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 +176,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 +259,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 +287,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 +310,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 +353,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, @@ -416,7 +438,9 @@ impl ModelClient { request_id, }) } - Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), + Err(e) => Err(StreamAttemptError::RetryableTransportError( + CodexErr::ConnectionFailed(ConnectionFailedError { source: e }), + )), } } @@ -1015,6 +1039,7 @@ mod tests { "test", "test", None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 47dfc0e09c..e33a23c176 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -17,6 +17,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,6 +28,12 @@ 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; @@ -99,6 +106,7 @@ use crate::rollout::RolloutRecorderParams; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; +use crate::state::TaskKind; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::ReviewTask; @@ -364,15 +372,32 @@ 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:#}"); @@ -399,11 +424,24 @@ 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 log_message = + format!("MCP client for `{server_name}` failed to start: {err:#}"); + error!("{log_message}"); + let display_message = if matches!( + auth_statuses.get(&server_name), + Some(McpAuthStatus::NotLoggedIn) + ) { + format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}` to log in." + ) + } else { + log_message + }; post_session_configured_error_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { message }), + msg: EventMsg::Error(ErrorEvent { + message: display_message, + }), }); } } @@ -413,6 +451,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(), @@ -446,12 +485,7 @@ impl Session { 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, + features: &config.features, }), user_instructions, base_instructions, @@ -593,6 +627,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 { @@ -600,6 +635,7 @@ impl Session { command, cwd, reason, + parsed_cmd, }), }; self.send_event(event).await; @@ -855,10 +891,7 @@ impl Session { call_id, command: command_for_display.clone(), cwd, - parsed_cmd: parse_command(&command_for_display) - .into_iter() - .map(Into::into) - .collect(), + parsed_cmd: parse_command(&command_for_display), }), }; let event = Event { @@ -883,6 +916,7 @@ impl Session { duration, exit_code, timed_out: _, + .. } = output; // Send full stdout/stderr to clients; do not truncate. let stdout = stdout.text.clone(); @@ -947,15 +981,28 @@ impl Session { 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 begin_turn_diff = turn_diff_tracker.clone(); + let begin_context = context.clone(); + let session = self; let result = self .services .executor - .run(request, self, approval_policy, &context) + .run(request, self, approval_policy, &context, move || { + let turn_diff = begin_turn_diff.clone(); + let ctx = begin_context.clone(); + async move { + session.on_exec_command_begin(turn_diff, ctx).await; + } + }) .await; + if matches!( + &result, + Err(ExecError::Function(FunctionCallError::Denied(_))) + ) { + return result; + } + let normalized = normalize_exec_result(&result); let borrowed = normalized.event_output(); @@ -1035,6 +1082,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, @@ -1200,12 +1280,7 @@ async fn submission_loop( 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, + features: &config.features, }); let new_turn_context = TurnContext { @@ -1302,14 +1377,7 @@ async fn submission_loop( 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, + features: &config.features, }), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -1409,16 +1477,23 @@ 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 = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) - .await; + 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, + resources, + resource_templates, auth_statuses, }, ), @@ -1541,14 +1616,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(); @@ -1639,6 +1715,7 @@ pub(crate) async fn run_task( turn_context: Arc, sub_id: String, input: Vec, + task_kind: TaskKind, ) -> Option { if input.is_empty() { return None; @@ -1722,6 +1799,7 @@ pub(crate) async fn run_task( Arc::clone(&turn_diff_tracker), sub_id.clone(), turn_input, + task_kind, ) .await { @@ -1874,6 +1952,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(), @@ -1947,6 +2026,7 @@ async fn run_turn( turn_diff_tracker: SharedTurnDiffTracker, sub_id: String, input: Vec, + task_kind: TaskKind, ) -> CodexResult { let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); let router = Arc::new(ToolRouter::from_config( @@ -1976,6 +2056,7 @@ async fn run_turn( Arc::clone(&turn_diff_tracker), &sub_id, &prompt, + task_kind, ) .await { @@ -2049,6 +2130,7 @@ async fn try_run_turn( turn_diff_tracker: SharedTurnDiffTracker, sub_id: &str, prompt: &Prompt, + task_kind: TaskKind, ) -> CodexResult { // call_ids that are part of this response. let completed_call_ids = prompt @@ -2114,7 +2196,11 @@ 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) + .await?; let tool_runtime = ToolCallRuntime::new( Arc::clone(&router), @@ -2195,7 +2281,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 { @@ -2724,6 +2811,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(), @@ -2753,12 +2841,7 @@ mod tests { ); 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, + features: &config.features, }); let turn_context = TurnContext { client, @@ -2826,12 +2909,7 @@ mod tests { ); 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, + features: &config.features, }); let turn_context = Arc::new(TurnContext { client, diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index d43e3abcbb..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; @@ -258,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/config.rs b/codex-rs/core/src/config.rs index c715651851..6f5defcedc 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,6 +29,8 @@ 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; @@ -36,6 +43,7 @@ use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use serde::Deserialize; +use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -94,6 +102,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 @@ -218,12 +230,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 +388,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,15 +417,50 @@ 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); + } } } @@ -539,6 +602,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, @@ -794,19 +905,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. @@ -817,6 +924,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 { @@ -847,6 +965,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")] @@ -868,9 +995,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(), @@ -892,30 +1033,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( @@ -974,15 +1111,15 @@ 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, } = overrides; @@ -1005,7 +1142,56 @@ 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 active_project = cfg + .get_active_project(&resolved_cwd) + .unwrap_or(ProjectConfig { trust_level: None }); + + let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + 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. @@ -1029,34 +1215,15 @@ 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_view_image_tool = include_view_image_tool - .or(cfg.tools.as_ref().and_then(|t| t.view_image)) - .unwrap_or(true); + 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 model = model .or(config_profile.model) @@ -1104,6 +1271,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, @@ -1114,11 +1285,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, @@ -1164,21 +1333,18 @@ 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), + 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 @@ -1203,20 +1369,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( @@ -1309,6 +1473,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; @@ -1374,7 +1539,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#" @@ -1389,7 +1555,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#" @@ -1413,8 +1580,57 @@ 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 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] @@ -1436,6 +1652,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()?; @@ -1518,6 +1821,8 @@ 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)), @@ -1531,10 +1836,18 @@ 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:?}"), } @@ -1644,6 +1957,8 @@ 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, @@ -1670,7 +1985,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 @@ -1678,6 +1999,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:?}"), } @@ -1686,15 +2009,101 @@ 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)), @@ -1721,20 +2130,127 @@ 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, @@ -1757,9 +2273,112 @@ 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:?}"), } @@ -1778,6 +2397,8 @@ url = "https://example.com/mcp" command: "docs-server".to_string(), args: Vec::new(), env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: false, startup_timeout_sec: None, @@ -2093,6 +2714,7 @@ model_verbosity = "high" 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, @@ -2120,8 +2742,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(), @@ -2156,6 +2781,7 @@ model_verbosity = "high" 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, @@ -2183,8 +2809,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(), @@ -2234,6 +2863,7 @@ model_verbosity = "high" 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, @@ -2261,8 +2891,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(), @@ -2298,6 +2931,7 @@ model_verbosity = "high" 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, @@ -2325,8 +2959,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(), @@ -2337,6 +2974,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 0ef9248511..3da6108631 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -44,16 +44,26 @@ 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)] @@ -92,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())?; @@ -102,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 { @@ -115,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")), @@ -152,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 { @@ -161,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>, }, } @@ -322,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)] @@ -468,7 +521,9 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec![], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); @@ -489,7 +544,9 @@ 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); @@ -511,12 +568,58 @@ 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( @@ -543,7 +646,9 @@ 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); @@ -563,12 +668,39 @@ 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() + )])), + } + ); + } + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( @@ -591,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/error.rs b/codex-rs/core/src/error.rs index 786e20523a..da2a868d70 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -91,6 +91,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." )] @@ -126,9 +132,6 @@ pub enum CodexErr { #[error(transparent)] Io(#[from] io::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] Json(#[from] serde_json::Error), @@ -147,6 +150,37 @@ pub enum CodexErr { EnvVar(EnvVarError), } +#[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, diff --git a/codex-rs/core/src/executor/backends.rs b/codex-rs/core/src/executor/backends.rs index 95cdb3cacb..9c65745c4f 100644 --- a/codex-rs/core/src/executor/backends.rs +++ b/codex-rs/core/src/executor/backends.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use crate::CODEX_APPLY_PATCH_ARG1; use crate::apply_patch::ApplyPatchExec; use crate::exec::ExecParams; +use crate::executor::ExecutorConfig; use crate::function_tool::FunctionCallError; pub(crate) enum ExecutionMode { @@ -22,6 +23,7 @@ pub(crate) trait ExecutionBackend: Send + Sync { params: ExecParams, // Required for downcasting the apply_patch. mode: &ExecutionMode, + config: &ExecutorConfig, ) -> Result; fn stream_stdout(&self, _mode: &ExecutionMode) -> bool { @@ -47,6 +49,7 @@ impl ExecutionBackend for ShellBackend { &self, params: ExecParams, mode: &ExecutionMode, + _config: &ExecutorConfig, ) -> Result { match mode { ExecutionMode::Shell => Ok(params), @@ -65,17 +68,22 @@ impl ExecutionBackend for ApplyPatchBackend { &self, params: ExecParams, mode: &ExecutionMode, + config: &ExecutorConfig, ) -> 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 path_to_codex = if let Some(exe_path) = &config.codex_exe { + exe_path.to_string_lossy().to_string() + } else { + 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 { diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs index 97d7b29294..3f1e02dff2 100644 --- a/codex-rs/core/src/executor/mod.rs +++ b/codex-rs/core/src/executor/mod.rs @@ -60,5 +60,9 @@ pub mod errors { pub(crate) fn rejection(msg: impl Into) -> Self { FunctionCallError::RespondToModel(msg.into()).into() } + + pub(crate) fn denied(msg: impl Into) -> Self { + FunctionCallError::Denied(msg.into()).into() + } } } diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs index 9a1956f66f..072debb5d3 100644 --- a/codex-rs/core/src/executor/runner.rs +++ b/codex-rs/core/src/executor/runner.rs @@ -1,3 +1,4 @@ +use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; @@ -30,19 +31,19 @@ use codex_otel::otel_event_manager::ToolDecisionSource; pub(crate) struct ExecutorConfig { pub(crate) sandbox_policy: SandboxPolicy, pub(crate) sandbox_cwd: PathBuf, - codex_linux_sandbox_exe: Option, + pub(crate) codex_exe: Option, } impl ExecutorConfig { pub(crate) fn new( sandbox_policy: SandboxPolicy, sandbox_cwd: PathBuf, - codex_linux_sandbox_exe: Option, + codex_exe: Option, ) -> Self { Self { sandbox_policy, sandbox_cwd, - codex_linux_sandbox_exe, + codex_exe, } } } @@ -74,19 +75,31 @@ impl Executor { /// 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( + pub(crate) async fn run( &self, mut request: ExecutionRequest, session: &Session, approval_policy: AskForApproval, context: &ExecCommandContext, - ) -> Result { + on_exec_begin: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future, + { 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. + // Step 1: Snapshot sandbox configuration so it stays stable for this run. + let config = self + .config + .read() + .map_err(|_| ExecError::rejection("executor config poisoned"))? + .clone(); + + // Step 2: 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() @@ -94,16 +107,9 @@ impl Executor { None }; request.params = backend - .prepare(request.params, &request.mode) + .prepare(request.params, &request.mode, &config) .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, @@ -119,7 +125,7 @@ impl Executor { if sandbox_decision.record_session_approval { self.approval_cache.insert(request.approval_command.clone()); } - + on_exec_begin().await; // Step 4: Launch the command within the chosen sandbox. let first_attempt = self .spawn( @@ -210,7 +216,7 @@ impl Executor { Ok(retry_output) } ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) + Err(ExecError::denied("exec command rejected by user")) } } } @@ -227,7 +233,7 @@ impl Executor { sandbox, &config.sandbox_policy, &config.sandbox_cwd, - &config.codex_linux_sandbox_exe, + &config.codex_exe, stdout_stream, ) .await @@ -301,7 +307,8 @@ pub(crate) fn normalize_exec_result( } Err(err) => { let message = match err { - ExecError::Function(FunctionCallError::RespondToModel(msg)) => msg.clone(), + ExecError::Function(FunctionCallError::RespondToModel(msg)) + | ExecError::Function(FunctionCallError::Denied(msg)) => msg.clone(), ExecError::Codex(e) => get_error_message_ui(e), err => err.to_string(), }; diff --git a/codex-rs/core/src/executor/sandbox.rs b/codex-rs/core/src/executor/sandbox.rs index 5c01ff69b4..f0a421a29a 100644 --- a/codex-rs/core/src/executor/sandbox.rs +++ b/codex-rs/core/src/executor/sandbox.rs @@ -149,7 +149,7 @@ async fn select_shell_sandbox( 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")) + Err(ExecError::denied("exec command rejected by user")) } } } 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..a25fff6113 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -4,6 +4,8 @@ use thiserror::Error; pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), + #[error("{0}")] + Denied(String), #[error("LocalShellCall without call_id or id")] MissingLocalShellCallId, #[error("Fatal error: {0}")] diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 816beeb336..d3c1deb536 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -30,6 +30,7 @@ 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; diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/core/src/mcp/auth.rs index dbb9db804f..22d1f5f51f 100644 --- a/codex-rs/core/src/mcp/auth.rs +++ b/codex-rs/core/src/mcp/auth.rs @@ -45,11 +45,15 @@ async fn compute_auth_status( 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_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 768c6b01a6..99c2e626e5 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; @@ -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, @@ -246,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( @@ -254,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, @@ -318,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 @@ -327,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, @@ -338,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 @@ -347,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) @@ -382,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(); 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/state/turn.rs b/codex-rs/core/src/state/turn.rs index f715d5481e..89af13a1a5 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -34,6 +34,16 @@ 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, @@ -113,3 +123,15 @@ impl ActiveTurn { } } } + +#[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/regular.rs b/codex-rs/core/src/tasks/regular.rs index 9d24099746..b3758d5fc6 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -27,6 +27,6 @@ impl SessionTask for RegularTask { input: Vec, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input).await + run_task(sess, ctx, sub_id, input, TaskKind::Regular).await } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 047a2f40e2..cec9243234 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -28,7 +28,7 @@ impl SessionTask for ReviewTask { input: Vec, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input).await + run_task(sess, ctx, sub_id, input, TaskKind::Review).await } async fn abort(&self, session: Arc, sub_id: &str) { 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..355e5d607a 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -3,6 +3,7 @@ mod exec_stream; mod grep_files; mod list_dir; mod mcp; +mod mcp_resource; mod plan; mod read_file; mod shell; @@ -17,6 +18,7 @@ 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/mod.rs b/codex-rs/core/src/tools/mod.rs index 691c6dc0c2..607697e083 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -238,6 +238,7 @@ fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { 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, } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c10f8e22f9..5352051917 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(), } } @@ -512,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 { @@ -724,6 +824,7 @@ pub(crate) fn build_specs( 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; @@ -741,6 +842,7 @@ pub(crate) fn build_specs( 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 { builder.push_spec(create_unified_exec_tool()); @@ -771,6 +873,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); @@ -906,40 +1015,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", + ], ); } @@ -948,14 +1071,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(); @@ -969,14 +1090,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(); @@ -1001,14 +1119,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, @@ -1053,15 +1169,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([ @@ -1106,14 +1226,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. @@ -1171,6 +1288,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "view_image", "test_server/cool", "test_server/do", @@ -1183,14 +1303,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( @@ -1221,6 +1339,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1229,7 +1350,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { @@ -1252,14 +1373,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( @@ -1288,6 +1407,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1295,7 +1417,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { @@ -1316,14 +1438,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( @@ -1352,6 +1473,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1359,7 +1483,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { @@ -1383,14 +1507,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( @@ -1419,6 +1541,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1426,7 +1551,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { @@ -1462,14 +1587,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, @@ -1523,6 +1646,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1531,7 +1657,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/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/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/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..beaa87a853 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(), 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/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9ca0cc9369..fcfc960224 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 diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index e111cebcb7..1a2815b0ee 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(())); @@ -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,6 +88,8 @@ 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)), @@ -105,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!("{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, @@ -227,13 +368,15 @@ 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)), @@ -254,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, @@ -408,13 +551,15 @@ 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)), @@ -435,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 fd9c26d882..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(())); @@ -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 ba87cb333d..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?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index cfa96dd75c..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, @@ -176,6 +183,7 @@ async fn unified_exec_streams_after_lagged_output() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex, @@ -300,7 +308,7 @@ async fn unified_exec_timeout_and_followup_poll() -> 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, 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/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 967da52b8a..144e7cabb0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -17,6 +17,7 @@ use codex_core::ConversationManager; use codex_core::NewConversation; 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; @@ -168,8 +169,7 @@ 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)), @@ -192,6 +192,7 @@ 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); let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); @@ -360,6 +361,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/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_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..a26892e5d4 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, })?), }) } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 2d6f278a79..c02ea19fdf 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, @@ -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, @@ -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/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index d1fbe83508..b06e290436 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::path::PathBuf; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] @@ -8,6 +9,11 @@ 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/protocol.rs b/codex-rs/protocol/src/protocol.rs index b73909473d..d888194776 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,6 +21,8 @@ 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 serde::Deserialize; use serde::Serialize; @@ -1178,6 +1180,7 @@ 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)] @@ -1248,6 +1251,10 @@ pub struct GetHistoryEntryResponseEvent { 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, } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 99a609b339..c515cdbfa3 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -57,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 index 0281c0ffe8..77c33f6959 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::time::Duration; use anyhow::Error; @@ -6,11 +7,14 @@ 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"; @@ -21,6 +25,8 @@ 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() { @@ -31,7 +37,9 @@ pub async fn determine_streamable_http_auth_status( return Ok(McpAuthStatus::OAuth); } - match supports_oauth_login(url).await { + 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) => { @@ -44,9 +52,14 @@ pub async fn determine_streamable_http_auth_status( } /// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. -async fn supports_oauth_login(url: &str) -> Result { +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 client = Client::builder().timeout(DISCOVERY_TIMEOUT).build()?; + 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()) { 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 05412da184..ca99a7bb90 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -7,6 +7,7 @@ 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; 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/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/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/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3cd38e8de9..56a6e44de3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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,6 +316,11 @@ 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() @@ -849,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); @@ -962,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 @@ -975,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) => { @@ -1759,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; @@ -2877,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/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/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 1e8d1983b0..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, @@ -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..8d9e84f2aa 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 { @@ -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() 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__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 6687793979..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); @@ -1198,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"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 787e098093..9f308cd00f 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; @@ -216,6 +224,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 { @@ -264,6 +273,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 { @@ -909,6 +920,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(); @@ -955,6 +967,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -972,6 +985,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(); @@ -1020,6 +1034,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -1123,10 +1138,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()); } @@ -1718,7 +1760,6 @@ impl ChatWidget { } else { default_choice }; - let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; @@ -1741,6 +1782,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| { @@ -1770,6 +1819,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, @@ -1777,9 +1827,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() @@ -1795,22 +1849,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, @@ -1829,6 +1885,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; @@ -1839,6 +1978,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; @@ -1928,6 +2071,8 @@ impl ChatWidget { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, ev.tools, + ev.resources, + ev.resource_templates, &ev.auth_statuses, )); } 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__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__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 3bfdf243bc..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. @@ -289,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) } @@ -392,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(), @@ -433,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(), @@ -480,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(), @@ -505,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 { @@ -769,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() { @@ -1054,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(); @@ -1205,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, }), } } @@ -1323,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(), @@ -1366,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(), @@ -1571,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(), @@ -2241,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/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/history_cell.rs b/codex-rs/tui/src/history_cell.rs index aa0ae35d92..85acaf9b6a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -20,6 +20,7 @@ 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; @@ -34,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; @@ -114,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(); @@ -125,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)); @@ -970,6 +978,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { pub(crate) fn new_mcp_tools_output( config: &Config, tools: HashMap, + resources: HashMap>, + resource_templates: HashMap>, auth_statuses: &HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ @@ -1008,7 +1018,13 @@ pub(crate) fn new_mcp_tools_output( 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 { @@ -1017,33 +1033,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 { - lines.push(vec![" • Tools: ".into(), "(disabled)".red()].into()); - } else if names.is_empty() { + 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 { @@ -1148,7 +1244,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(), @@ -1160,7 +1256,8 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { include_angle_pipe: true, include_prefix: true, }, - )); + ); + lines.extend(output.lines); } PlainHistoryCell { lines } @@ -1585,10 +1682,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, @@ -1645,6 +1744,7 @@ mod tests { vec![ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }], ) .unwrap(); @@ -1666,6 +1766,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(); @@ -1694,14 +1795,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..aa40b05c55 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,12 +13,8 @@ use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; 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,6 +23,7 @@ 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 app; @@ -59,6 +56,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 +68,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)] @@ -160,52 +189,9 @@ pub async fn run_main( } }; - let mut config = { - // Load configuration and support CLI overrides. - - #[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); - } - } - }; - - // 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, - )?; + let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + 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 +220,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 +259,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 +305,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 +354,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 +376,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 +413,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() { @@ -441,6 +464,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 +483,7 @@ async fn run_ratatui_app( prompt, images, resume_selection, + feedback, ) .await; @@ -469,6 +494,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 +571,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/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 3324c3d866..8a7541c022 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -7,8 +7,6 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::style::Styled as _; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -22,11 +20,9 @@ use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; -use crate::render::renderable::RowRenderable; +use crate::selection_list::selection_option_row; use super::onboarding_screen::StepState; -use unicode_width::UnicodeWidthStr; - pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, pub cwd: PathBuf, @@ -88,7 +84,7 @@ impl WidgetRef for &TrustDirectoryWidget { } for (idx, (text, selection)) in options.iter().enumerate() { - column.push(new_option_row( + column.push(selection_option_row( idx, text.to_string(), self.highlighted == *selection, @@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget { } } -fn new_option_row(index: usize, label: String, is_selected: bool) -> Box { - let prefix = if is_selected { - format!("› {}. ", index + 1) - } else { - format!(" {}. ", index + 1) - }; - - let mut style = Style::default(); - if is_selected { - style = style.cyan(); - } - - let mut row = RowRenderable::new(); - row.push(prefix.width() as u16, prefix.set_style(style)); - row.push( - u16::MAX, - Paragraph::new(label) - .style(style) - .wrap(Wrap { trim: false }), - ); - - row.into() -} - impl KeyboardHandler for TrustDirectoryWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { 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/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__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/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/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/docs/config.md b/docs/config.md index 2ac7aba6b7..e460cb19bb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -359,6 +359,11 @@ 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 @@ -372,6 +377,10 @@ experimental_use_rmcp_client = true 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" } ``` For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` @@ -509,7 +518,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): @@ -602,6 +611,7 @@ Specify a program that will be executed to get notified about events generated b ```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." @@ -610,6 +620,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 @@ -637,13 +649,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", @@ -652,7 +666,7 @@ def main() -> int: "-message", message, "-group", - "codex", + "codex-" + thread_id, "-ignoreDnD", "-activate", "com.googlecode.iterm2",