From 889ee018e7dd5d8bf9861ddae56082d86cebe5c7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 13 May 2026 09:08:05 -0700 Subject: [PATCH 01/52] config: add strict config parsing (#20559) ## Why Codex intentionally ignores unknown `config.toml` fields by default so older and newer config files keep working across versions. That leniency also makes typo detection hard because misspelled or misplaced keys disappear silently. This change adds an opt-in strict config mode so users and tooling can fail fast on unrecognized config fields without changing the default permissive behavior. This feature is possible because `serde_ignored` exposes the exact signal Codex needs: it lets Codex run ordinary Serde deserialization while recording fields Serde would otherwise ignore. That avoids requiring `#[serde(deny_unknown_fields)]` across every config type and keeps strict validation opt-in around the existing config model. ## What Changed ### Added strict config validation - Added `serde_ignored`-based validation for `ConfigToml` in `codex-rs/config/src/strict_config.rs`. - Combined `serde_ignored` with `serde_path_to_error` so strict mode preserves typed config error paths while also collecting fields Serde would otherwise ignore. - Added strict-mode validation for unknown `[features]` keys, including keys that would otherwise be accepted by `FeaturesToml`'s flattened boolean map. - Kept typed config errors ahead of ignored-field reporting, so malformed known fields are reported before unknown-field diagnostics. - Added source-range diagnostics for top-level and nested unknown config fields, including non-file managed preference source names. ### Kept parsing single-pass per source - Reworked file and managed-config loading so strict validation reuses the already parsed `TomlValue` for that source. - For actual config files and managed config strings, the loader now reads once, parses once, and validates that same parsed value instead of deserializing multiple times. - Validated `-c` / `--config` override layers with the same base-directory context used for normal relative-path resolution, so unknown override keys are still reported when another override contains a relative path. ### Scoped `--strict-config` to config-heavy entry points - Added support for `--strict-config` on the main config-loading entry points where it is most useful: - `codex` - `codex resume` - `codex fork` - `codex exec` - `codex review` - `codex mcp-server` - `codex app-server` when running the server itself - the standalone `codex-app-server` binary - the standalone `codex-exec` binary - Commands outside that set now reject `--strict-config` early with targeted errors instead of accepting it everywhere through shared CLI plumbing. - `codex app-server` subcommands such as `proxy`, `daemon`, and `generate-*` are intentionally excluded from the first rollout. - When app-server strict mode sees invalid config, app-server exits with the config error instead of logging a warning and continuing with defaults. - Introduced a dedicated `ReviewCommand` wrapper in `codex-rs/cli` instead of extending shared `ReviewArgs`, so `--strict-config` stays on the outer config-loading command surface and does not become part of the reusable review payload used by `codex exec review`. ### Coverage - Added tests for top-level and nested unknown config fields, unknown `[features]` keys, typed-error precedence, source-location reporting, and non-file managed preference source names. - Added CLI coverage showing invalid `--enable`, invalid `--disable`, and unknown `-c` overrides still error when `--strict-config` is present, including compound-looking feature names such as `multi_agent_v2.subagent_usage_hint_text`. - Added integration coverage showing both `codex app-server --strict-config` and standalone `codex-app-server --strict-config` exit with an error for unknown config fields instead of starting with fallback defaults. - Added coverage showing unsupported command surfaces reject `--strict-config` with explicit errors. ## Example Usage Run Codex with strict config validation enabled: ```shell codex --strict-config ``` Strict config mode is also available on the supported config-heavy subcommands: ```shell codex --strict-config exec "explain this repository" codex review --strict-config --uncommitted codex mcp-server --strict-config codex app-server --strict-config --listen off codex-app-server --strict-config --listen off ``` For example, if `~/.codex/config.toml` contains a typo in a key name: ```toml model = "gpt-5" approval_polic = "on-request" ``` then `codex --strict-config` reports the misspelled key instead of silently ignoring it. The path is shortened to `~` here for readability: ```text $ codex --strict-config Error loading config.toml: ~/.codex/config.toml:2:1: unknown configuration field `approval_polic` | 2 | approval_polic = "on-request" | ^^^^^^^^^^^^^^ ``` Without `--strict-config`, Codex keeps the existing permissive behavior and ignores the unknown key. Strict config mode also validates ad-hoc `-c` / `--config` overrides: ```text $ codex --strict-config -c foo=bar Error: unknown configuration field `foo` in -c/--config override $ codex --strict-config -c features.foo=true Error: unknown configuration field `features.foo` in -c/--config override ``` Invalid feature toggles are rejected too, including values that look like nested config paths: ```text $ codex --strict-config --enable does_not_exist Error: Unknown feature flag: does_not_exist $ codex --strict-config --disable does_not_exist Error: Unknown feature flag: does_not_exist $ codex --strict-config --enable multi_agent_v2.subagent_usage_hint_text Error: Unknown feature flag: multi_agent_v2.subagent_usage_hint_text ``` Unsupported commands reject the flag explicitly: ```text $ codex --strict-config cloud list Error: `--strict-config` is not supported for `codex cloud` ``` ## Verification The `codex-cli` `strict_config` tests cover invalid `--enable`, invalid `--disable`, the compound `multi_agent_v2.subagent_usage_hint_text` case, unknown `-c` overrides, app-server strict startup failure through `codex app-server`, and rejection for unsupported commands such as `codex cloud`, `codex mcp`, `codex remote-control`, and `codex app-server proxy`. The config and config-loader tests cover unknown top-level fields, unknown nested fields, unknown `[features]` keys, source-location reporting, non-file managed config sources, and `-c` validation for keys such as `features.foo`. The app-server test suite covers standalone `codex-app-server --strict-config` startup failure for an unknown config field. ## Documentation The Codex CLI docs on developers.openai.com/codex should mention `--strict-config` as an opt-in validation mode for supported config-heavy entry points once this ships. --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 13 +- codex-rs/Cargo.toml | 1 + codex-rs/app-server-client/src/lib.rs | 6 + codex-rs/app-server/src/config_manager.rs | 10 +- codex-rs/app-server/src/in_process.rs | 4 + codex-rs/app-server/src/lib.rs | 35 +-- codex-rs/app-server/src/main.rs | 24 +- codex-rs/app-server/src/mcp_refresh.rs | 1 + .../src/message_processor_tracing_tests.rs | 1 + .../thread_processor_tests.rs | 1 + .../tests/suite/conversation_summary.rs | 1 + codex-rs/app-server/tests/suite/mod.rs | 1 + .../app-server/tests/suite/strict_config.rs | 33 ++ .../app-server/tests/suite/v2/mcp_resource.rs | 1 + .../tests/suite/v2/remote_thread_store.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 3 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/cli/src/debug_sandbox.rs | 27 +- codex-rs/cli/src/main.rs | 289 ++++++++++++++++-- codex-rs/cli/tests/app_server.rs | 30 ++ codex-rs/cli/tests/features.rs | 28 ++ codex-rs/config/Cargo.toml | 1 + codex-rs/config/src/diagnostics.rs | 92 +++++- codex-rs/config/src/lib.rs | 3 + codex-rs/config/src/loader/README.md | 3 +- codex-rs/config/src/loader/layer_io.rs | 68 ++++- codex-rs/config/src/loader/macos.rs | 86 +++++- codex-rs/config/src/loader/mod.rs | 104 ++++++- codex-rs/config/src/state.rs | 16 + codex-rs/config/src/strict_config.rs | 201 ++++++++++++ codex-rs/config/src/strict_config_tests.rs | 112 +++++++ .../core/src/config/config_loader_tests.rs | 238 +++++++++++++-- codex-rs/core/src/config/mod.rs | 71 ++--- codex-rs/exec/src/cli.rs | 6 +- codex-rs/exec/src/lib.rs | 14 +- codex-rs/exec/src/main.rs | 3 +- codex-rs/exec/src/main_tests.rs | 12 +- codex-rs/mcp-server/src/codex_tool_config.rs | 8 +- codex-rs/mcp-server/src/lib.rs | 8 +- codex-rs/mcp-server/src/main.rs | 7 +- codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/lib.rs | 41 ++- codex-rs/tui/src/onboarding/auth.rs | 1 + codex-rs/utils/cli/src/config_override.rs | 25 ++ 45 files changed, 1458 insertions(+), 178 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/strict_config.rs create mode 100644 codex-rs/cli/tests/app_server.rs create mode 100644 codex-rs/config/src/strict_config.rs create mode 100644 codex-rs/config/src/strict_config_tests.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ce362f0978..87434e1c68 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1482,6 +1482,7 @@ "serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}", "serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}", + "serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}", "serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}", "serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}", "serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4a23797cc3..25827331b2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2432,6 +2432,7 @@ dependencies = [ "prost 0.14.3", "schemars 0.8.22", "serde", + "serde_ignored", "serde_json", "serde_path_to_error", "sha2", @@ -5454,7 +5455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11641,6 +11642,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bd0bcec66a..f5cfda7296 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -352,6 +352,7 @@ seccompiler = "0.5.0" semver = "1.0" sentry = "0.46.0" serde = "1" +serde_ignored = "0.1.14" serde_json = "1" serde_path_to_error = "0.1.20" serde_with = "3.17" diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index cd5dcf6649..49a7a3b800 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -336,6 +336,8 @@ pub struct InProcessClientStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Feedback sink used by app-server/core telemetry and logs. @@ -402,6 +404,7 @@ impl InProcessClientStartArgs { config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, + strict_config: self.strict_config, cloud_requirements: self.cloud_requirements, thread_config_loader, feedback: self.feedback, @@ -1030,6 +1033,7 @@ mod tests { config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -2188,6 +2192,7 @@ mod tests { config: config.clone(), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -2228,6 +2233,7 @@ mod tests { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 030829fa4b..628a4cc8ea 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -30,6 +30,7 @@ pub(crate) struct ConfigManager { cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, @@ -40,6 +41,7 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, @@ -49,6 +51,7 @@ impl ConfigManager { cli_overrides: Arc::new(RwLock::new(cli_overrides)), runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, + strict_config, cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), @@ -217,6 +220,7 @@ impl ConfigManager { .codex_home(self.codex_home.clone()) .cli_overrides(merged_cli_overrides) .loader_overrides(self.loader_overrides.clone()) + .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) @@ -245,7 +249,10 @@ impl ConfigManager { &self.codex_home, cwd, &self.current_cli_overrides(), - self.loader_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: self.loader_overrides.clone(), + strict_config: self.strict_config, + }, self.current_cloud_requirements(), thread_config_loader.as_ref(), ) @@ -280,6 +287,7 @@ impl ConfigManager { codex_home, cli_overrides, loader_overrides, + /*strict_config*/ false, cloud_requirements, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 29c0dae45a..d4fc50fea7 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -119,6 +119,8 @@ pub struct InProcessStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Loader used to fetch typed thread config sources before a thread starts. @@ -409,6 +411,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult Arc IoResult<()> { - run_main_with_transport( + run_main_with_transport_options( arg0_paths, cli_config_overrides, loader_overrides, + strict_config, default_analytics_enabled, AppServerTransport::Stdio, SessionSource::VSCode, AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions::default(), ) .await } @@ -409,33 +412,12 @@ impl Default for AppServerRuntimeOptions { } } -pub async fn run_main_with_transport( - arg0_paths: Arg0DispatchPaths, - cli_config_overrides: CliConfigOverrides, - loader_overrides: LoaderOverrides, - default_analytics_enabled: bool, - transport: AppServerTransport, - session_source: SessionSource, - auth: AppServerWebsocketAuthSettings, -) -> IoResult<()> { - run_main_with_transport_options( - arg0_paths, - cli_config_overrides, - loader_overrides, - default_analytics_enabled, - transport, - session_source, - auth, - AppServerRuntimeOptions::default(), - ) - .await -} - #[allow(clippy::too_many_arguments)] pub async fn run_main_with_transport_options( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, + strict_config: bool, default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, @@ -472,6 +454,7 @@ pub async fn run_main_with_transport_options( codex_home.to_path_buf(), cli_kv_overrides.clone(), loader_overrides, + strict_config, Default::default(), arg0_paths.clone(), Arc::new(NoopThreadConfigLoader), @@ -500,6 +483,10 @@ pub async fn run_main_with_transport_options( { Ok(config) => (config, true), Err(err) => { + if strict_config { + return Err(err); + } + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); ( diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 31a017d17c..6f5ecabf90 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -39,6 +39,10 @@ struct AppServerArgs { #[command(flatten)] auth: AppServerWebsocketAuthArgs, + /// Fail if config.toml contains unknown configuration fields. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Hidden debug-only test hook used by integration tests that spawn the /// production app-server binary. #[cfg(debug_assertions)] @@ -52,7 +56,15 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - let args = AppServerArgs::parse(); + let AppServerArgs { + listen, + session_source, + auth, + strict_config, + #[cfg(debug_assertions)] + disable_plugin_startup_tasks_for_tests, + remote_control, + } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() } else { @@ -60,20 +72,20 @@ fn main() -> anyhow::Result<()> { .map(LoaderOverrides::with_managed_config_path_for_tests) .unwrap_or_default() }; - let transport = args.listen; - let session_source = args.session_source; - let auth = args.auth.try_into_settings()?; + let transport = listen; + let auth = auth.try_into_settings()?; let mut runtime_options = AppServerRuntimeOptions::default(); #[cfg(debug_assertions)] - if args.disable_plugin_startup_tasks_for_tests { + if disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } - runtime_options.remote_control_enabled = args.remote_control; + runtime_options.remote_control_enabled = remote_control; run_main_with_transport_options( arg0_paths, CliConfigOverrides::default(), loader_overrides, + strict_config, /*default_analytics_enabled*/ false, transport, session_source, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 31a6f4afd8..f7d32b2ea8 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -207,6 +207,7 @@ mod tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), loader.clone(), diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 452991270a..3daeeeb5c9 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -239,6 +239,7 @@ async fn build_test_processor( config.codex_home.to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 61a31f9c4d..8bda80d6cf 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -582,6 +582,7 @@ mod thread_processor_behavior_tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..5253850107 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index c60f4a32a1..bac309a930 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -1,4 +1,5 @@ mod auth; mod conversation_summary; mod fuzzy_file_search; +mod strict_config; mod v2; diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs new file mode 100644 index 0000000000..93784c9752 --- /dev/null +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -0,0 +1,33 @@ +use std::process::Command; + +use anyhow::Result; +use tempfile::TempDir; + +#[test] +fn strict_config_rejects_unknown_config_fields_for_standalone_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) + .env("CODEX_HOME", codex_home.path()) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .args(["--strict-config", "--listen", "off"]) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("unknown configuration field `foo`"), + "expected strict config error in stderr, got: {stderr}" + ); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index a51f4bbd4e..bddf57f666 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index e5c0b2c53f..9787868f5c 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -75,6 +75,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index d6267b8fc0..fa143254a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -375,6 +375,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -440,6 +441,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -525,6 +527,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index dfc79e9d48..d2f8f268a2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -245,6 +245,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2722f69849..30c60d7b95 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -190,6 +190,7 @@ async fn run_command_under_sandbox( .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, config_options, + /*strict_config*/ false, ) .await?; @@ -636,12 +637,14 @@ async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, + strict_config: bool, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, options, /*codex_home*/ None, + strict_config, ) .await } @@ -651,6 +654,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, + strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { permissions_profile, @@ -680,6 +684,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + strict_config, ) .await?; @@ -697,6 +702,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + strict_config, ) .await .map_err(Into::into) @@ -707,14 +713,16 @@ async fn build_debug_sandbox_config( harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() .cli_overrides(cli_overrides) - .harness_overrides(harness_overrides); - if let ManagedRequirementsMode::Ignore = managed_requirements_mode { + .harness_overrides(harness_overrides) + .strict_config(strict_config); + if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { builder = builder.loader_overrides(LoaderOverrides { ignore_managed_requirements: true, - ..Default::default() + ..LoaderOverrides::default() }); } if let Some(codex_home) = codex_home { @@ -783,6 +791,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let legacy_config = build_debug_sandbox_config( @@ -793,6 +802,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -805,6 +815,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -840,6 +851,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let read_only_config = build_debug_sandbox_config( @@ -850,6 +862,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -862,6 +875,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -905,6 +919,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -917,6 +932,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -942,6 +958,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -975,6 +992,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1004,6 +1022,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1015,6 +1034,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home.path().to_path_buf()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -1040,6 +1060,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 36ce876b88..df3a9ab44e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -55,7 +55,7 @@ use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; use codex_core::build_models_manager; -use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; @@ -109,7 +109,7 @@ enum Subcommand { Exec(ExecCli), /// Run a code review non-interactively. - Review(ReviewArgs), + Review(ReviewCommand), /// Manage login. Login(LoginCommand), @@ -124,7 +124,7 @@ enum Subcommand { Plugin(PluginCli), /// Start Codex as an MCP server (stdio). - McpServer, + McpServer(McpServerCommand), /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), @@ -266,6 +266,23 @@ struct DebugModelsCommand { bundled: bool, } +#[derive(Debug, Parser)] +struct ReviewCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + + #[clap(flatten)] + args: ReviewArgs, +} + +#[derive(Debug, Parser)] +struct McpServerCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, +} + #[derive(Debug, Parser)] struct DebugTraceReduceCommand { /// Trace bundle directory containing manifest.json and trace.jsonl. @@ -419,6 +436,10 @@ struct AppServerCommand { #[command(subcommand)] subcommand: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( @@ -810,6 +831,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; let root_remote_auth_token_env = remote.remote_auth_token_env; + let root_strict_config = interactive.strict_config; + reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?; match subcommand { None => { @@ -835,13 +858,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { exec_cli .shared .inherit_exec_root_options(&interactive.shared); + exec_cli.strict_config |= root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::Review(review_args)) => { + Some(Subcommand::Review(ReviewCommand { + strict_config, + args: review_args, + })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), @@ -849,19 +876,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); + exec_cli.strict_config = strict_config || root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::McpServer) => { + Some(Subcommand::McpServer(McpServerCommand { strict_config })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), "mcp-server", )?; - codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; + codex_mcp_server::run_main( + arg0_paths.clone(), + root_config_overrides, + strict_config || root_strict_config, + ) + .await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( @@ -894,11 +927,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { Some(Subcommand::AppServer(app_server_cli)) => { let AppServerCommand { subcommand, + strict_config: app_server_strict_config, listen, remote_control, analytics_default_enabled, auth, } = app_server_cli; + let strict_config = app_server_strict_config || root_strict_config; + reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?; reject_remote_mode_for_app_server_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), @@ -916,6 +952,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { arg0_paths.clone(), root_config_overrides, codex_config::LoaderOverrides::default(), + strict_config, analytics_default_enabled, transport, codex_protocol::protocol::SessionSource::VSCode, @@ -1320,11 +1357,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ..Default::default() }; - let config = Config::load_with_cli_overrides_and_harness_overrides( - cli_kv_overrides, - overrides, - ) - .await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let mut rows = Vec::with_capacity(FEATURES.len()); let mut name_width = 0; let mut stage_width = 0; @@ -1500,8 +1537,11 @@ async fn run_debug_prompt_input_command( additional_writable_roots: shared.add_dir, ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let mut input = shared .images @@ -1532,7 +1572,10 @@ async fn run_debug_models_command( let cli_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; - let config = Config::load_with_cli_overrides(cli_overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .build() + .await?; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; let models_manager = build_models_manager(&config, auth_manager); @@ -1557,8 +1600,11 @@ async fn run_debug_clear_memories_command( config_profile: interactive.config_profile.clone(), ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let state_path = state_db_path(config.sqlite_home.as_path()); let mut cleared_state_db = false; @@ -1593,9 +1639,7 @@ fn prepend_config_flags( subcommand_config_overrides: &mut CliConfigOverrides, cli_config_overrides: CliConfigOverrides, ) { - subcommand_config_overrides - .raw_overrides - .splice(0..0, cli_config_overrides.raw_overrides); + subcommand_config_overrides.prepend_root_overrides(cli_config_overrides); } fn reject_remote_mode_for_subcommand( @@ -1616,12 +1660,103 @@ fn reject_remote_mode_for_subcommand( Ok(()) } +fn reject_root_strict_config_for_subcommand( + strict_config: bool, + subcommand: &Option, +) -> anyhow::Result<()> { + if !strict_config { + return Ok(()); + } + + match unsupported_subcommand_name_for_strict_config(subcommand) { + Some(subcommand_name) => { + reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name) + } + None => Ok(()), + } +} + +/// Return the selected subcommand name when a root-level `--strict-config` +/// flag should be rejected after parsing. +/// +/// `--strict-config` is parsed on the root interactive CLI so commands like +/// `codex --strict-config` continue to work for the TUI and for wrappers that +/// forward root options into another command shape. Clap will still accept that +/// root flag before the dispatcher knows which subcommand the user selected, so +/// unsupported subcommands need an explicit post-parse reject path. +/// +/// `Some(...)` returns the user-facing command name fragment to embed in the +/// rejection error, such as `cloud` or `app-server proxy`. `None` means the +/// selected command is allowed to inherit root `--strict-config`. +fn unsupported_subcommand_name_for_strict_config( + subcommand: &Option, +) -> Option<&'static str> { + match subcommand { + None + | Some(Subcommand::Exec(_)) + | Some(Subcommand::Review(_)) + | Some(Subcommand::McpServer(_)) + | Some(Subcommand::Resume(_)) + | Some(Subcommand::Fork(_)) => None, + Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, + Some(Subcommand::AppServer(app_server)) => { + Some(app_server_subcommand_name(app_server.subcommand.as_ref())) + } + Some(Subcommand::RemoteControl) => Some("remote-control"), + Some(Subcommand::Mcp(_)) => Some("mcp"), + Some(Subcommand::Plugin(_)) => Some("plugin"), + #[cfg(any(target_os = "macos", target_os = "windows"))] + Some(Subcommand::App(_)) => Some("app"), + Some(Subcommand::Login(_)) => Some("login"), + Some(Subcommand::Logout(_)) => Some("logout"), + Some(Subcommand::Completion(_)) => Some("completion"), + Some(Subcommand::Update) => Some("update"), + Some(Subcommand::Cloud(_)) => Some("cloud"), + Some(Subcommand::Sandbox(_)) => Some("sandbox"), + Some(Subcommand::Debug(_)) => Some("debug"), + Some(Subcommand::Execpolicy(_)) => Some("execpolicy"), + Some(Subcommand::Apply(_)) => Some("apply"), + Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), + Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), + Some(Subcommand::ExecServer(_)) => Some("exec-server"), + Some(Subcommand::Features(_)) => Some("features"), + } +} + +fn reject_strict_config_for_app_server_subcommand( + strict_config: bool, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + if subcommand.is_none() { + return Ok(()); + } + reject_strict_config_for_unsupported_subcommand( + strict_config, + app_server_subcommand_name(subcommand), + ) +} + +fn reject_strict_config_for_unsupported_subcommand( + strict_config: bool, + subcommand: &str, +) -> anyhow::Result<()> { + if strict_config { + anyhow::bail!("`--strict-config` is not supported for `codex {subcommand}`"); + } + Ok(()) +} + fn reject_remote_mode_for_app_server_subcommand( remote: Option<&str>, remote_auth_token_env: Option<&str>, subcommand: Option<&AppServerSubcommand>, ) -> anyhow::Result<()> { - let subcommand_name = match subcommand { + let subcommand_name = app_server_subcommand_name(subcommand); + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { + match subcommand { None => "app-server", Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand { AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", @@ -1643,8 +1778,7 @@ fn reject_remote_mode_for_app_server_subcommand( Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { "app-server generate-internal-json-schema" } - }; - reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) + } } async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> { @@ -1816,6 +1950,7 @@ fn finalize_fork_interactive( fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { let TuiCli { shared, + strict_config, approval_policy, web_search, prompt, @@ -1831,6 +1966,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) if web_search { interactive.web_search = true; } + if strict_config { + interactive.strict_config = true; + } if let Some(prompt) = prompt { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); @@ -2318,6 +2456,7 @@ mod tests { "my-profile", "-C", "/tmp", + "--strict-config", "-i", "/tmp/a.png,/tmp/b.png", ] @@ -2340,6 +2479,7 @@ mod tests { Some(std::path::Path::new("/tmp")) ); assert!(interactive.web_search); + assert!(interactive.strict_config); let has_a = interactive .images .iter() @@ -2434,6 +2574,77 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn strict_config_parses_for_supported_commands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse"); + assert!(cli.interactive.strict_config); + + let cli = MultitoolCli::try_parse_from(["codex", "mcp-server", "--strict-config"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::McpServer(McpServerCommand { + strict_config: true, + })) + ); + + let cli = + MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::Review(ReviewCommand { + strict_config: true, + .. + })) + ); + } + + #[test] + fn root_strict_config_is_rejected_for_unsupported_subcommands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("mcp should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex mcp`" + ); + + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("remote-control should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex remote-control`" + ); + } + + #[test] + fn app_server_subcommands_reject_strict_config() { + let app_server = + app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref()); + let err = reject_strict_config_for_app_server_subcommand( + app_server.strict_config, + app_server.subcommand.as_ref(), + ) + .expect_err("app-server proxy should not support --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex app-server proxy`" + ); + } + #[test] fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) @@ -2880,4 +3091,38 @@ mod tests { .expect_err("feature should be rejected"); assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); } + + #[test] + fn strict_config_with_unknown_enable_errors() { + let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_unknown_disable_errors() { + let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_compound_enable_errors() { + let err = strict_config_feature_toggle_error( + ["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(), + ); + assert_eq!( + err.to_string(), + "Unknown feature flag: multi_agent_v2.subagent_usage_hint_text" + ); + } + + fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error { + let cli_args = std::iter::once("codex") + .chain(std::iter::once("--strict-config")) + .chain(args.iter().copied()); + let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed"); + assert!(cli.interactive.strict_config); + cli.feature_toggles + .to_overrides() + .expect_err("feature should be rejected") + } } diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs new file mode 100644 index 0000000000..4a2642379d --- /dev/null +++ b/codex-rs/cli/tests/app_server.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn strict_config_rejects_unknown_config_fields_for_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["app-server", "--strict-config", "--listen", "off"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 17a7eff679..596cfaa8f8 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -11,6 +11,34 @@ fn codex_command(codex_home: &Path) -> Result { Ok(cmd) } +#[test] +fn strict_config_rejects_unknown_config_override() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "mcp-server"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} + +#[test] +fn strict_config_is_not_supported_for_cloud_command() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"]) + .assert() + .failure() + .stderr(contains( + "`--strict-config` is not supported for `codex cloud`", + )); + + Ok(()) +} + #[tokio::test] async fn features_enable_writes_feature_flag_to_config() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 9583a57c62..c345f31c6e 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -32,6 +32,7 @@ multimap = { workspace = true } prost = "0.14.3" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_ignored = { workspace = true } serde_json = { workspace = true } serde_path_to_error = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 899114d6d7..69549dc207 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError { } } +#[derive(Clone, Copy)] +pub(crate) enum ConfigDiagnosticSource<'a> { + Path(&'a Path), + #[cfg(any(target_os = "macos", test))] + DisplayName(&'a str), +} + +impl ConfigDiagnosticSource<'_> { + pub(crate) fn to_path_buf(self) -> PathBuf { + match self { + ConfigDiagnosticSource::Path(path) => path.to_path_buf(), + #[cfg(any(target_os = "macos", test))] + ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), + } + } +} + pub fn io_error_from_config_error( kind: io::ErrorKind, error: ConfigError, @@ -98,21 +115,39 @@ pub fn config_error_from_toml( path: impl AsRef, contents: &str, err: toml::de::Error, +) -> ConfigError { + config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err) +} + +pub(crate) fn config_error_from_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + err: toml::de::Error, ) -> ConfigError { let range = err .span() .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); - ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) + ConfigError::new(source.to_path_buf(), range, err.message()) } pub fn config_error_from_typed_toml( path: impl AsRef, contents: &str, +) -> Option { + config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + ) +} + +fn config_error_from_typed_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, ) -> Option { let deserializer = match toml::de::Deserializer::parse(contents) { Ok(deserializer) => deserializer, - Err(err) => return Some(config_error_from_toml(path, contents, err)), + Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)), }; let result: Result = serde_path_to_error::deserialize(deserializer); @@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml( .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); Some(ConfigError::new( - path.as_ref().to_path_buf(), + source.to_path_buf(), range, toml_err.message(), )) @@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op } } -fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { +pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { let start = position_for_offset(contents, span.start); let end_index = if span.end > span.start { span.end - 1 @@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition { } } -fn default_range() -> TextRange { +pub(crate) fn default_range() -> TextRange { let position = TextPosition { line: 1, column: 1 }; TextRange { start: position, @@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option Option> { +pub(crate) fn span_for_config_path( + contents: &str, + path: &SerdePath, +) -> Option> { if is_features_table_path(path) && let Some(span) = span_for_features_value(contents) { @@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option Option> { + let doc = contents.parse::>().ok()?; + let mut node = TomlNode::Item(doc.as_item()); + for (index, segment) in path.iter().enumerate() { + if index + 1 == path.len() { + let key_span = match &node { + TomlNode::Item(item) => item + .as_table_like() + .and_then(|table| table.get_key_value(segment)) + .and_then(|(key, _)| key.span()), + TomlNode::Table(table) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + TomlNode::Value(Value::InlineTable(table)) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + _ => None, + }; + if key_span.is_some() { + return key_span; + } + } + + if let Some(next) = map_child(&node, segment) { + node = next; + continue; + } + + let index = segment.parse::().ok()?; + node = seq_child(&node, index)?; + } + + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + fn is_features_table_path(path: &SerdePath) -> bool { let mut segments = path.iter(); matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 2d4c62013e..e48dcedac3 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -21,6 +21,7 @@ mod requirements_exec_policy; pub mod schema; mod skills_config; mod state; +mod strict_config; mod thread_config; mod tui_keymap; pub mod types; @@ -116,7 +117,9 @@ pub use skills_config::SkillsConfig; pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; +pub use state::ConfigLoadOptions; pub use state::LoaderOverrides; +pub use strict_config::config_error_from_ignored_toml_fields; pub use thread_config::NoopThreadConfigLoader; pub use thread_config::RemoteThreadConfigLoader; pub use thread_config::SessionThreadConfig; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index 28750c4929..e004cea87c 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` - `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) +- `ConfigLoadOptions` (user-facing load behavior such as strict config validation) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) diff --git a/codex-rs/config/src/loader/layer_io.rs b/codex-rs/config/src/loader/layer_io.rs index 9c15df7271..415d82e405 100644 --- a/codex-rs/config/src/loader/layer_io.rs +++ b/codex-rs/config/src/loader/layer_io.rs @@ -2,11 +2,14 @@ use super::macos::ManagedAdminConfigLayer; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; +use crate::config_toml::ConfigToml; use crate::diagnostics::config_error_from_toml; use crate::diagnostics::io_error_from_config_error; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; use codex_file_system::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; use std::io; use std::path::Path; use std::path::PathBuf; @@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal( fs: &dyn ExecutorFileSystem, codex_home: &Path, overrides: LoaderOverrides, + strict_config: bool, ) -> io::Result { #[cfg(target_os = "macos")] let LoaderOverrides { @@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal( managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)), )?; - let managed_config = - read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false) - .await? - .map(|managed_config| MangedConfigFromFile { - managed_config, - file: managed_config_path.clone(), - }); + let managed_config = read_config_from_path( + fs, + &managed_config_path, + /*log_missing_as_info*/ false, + strict_config, + ) + .await? + .map(|loaded| MangedConfigFromFile { + managed_config: loaded, + file: managed_config_path.clone(), + }); #[cfg(target_os = "macos")] - let managed_preferences = - load_managed_admin_config_layer(managed_preferences_base64.as_deref()) - .await? - .map(map_managed_admin_layer); + let managed_preferences = load_managed_admin_config_layer( + managed_preferences_base64.as_deref(), + strict_config, + codex_home, + ) + .await? + .map(map_managed_admin_layer); #[cfg(not(target_os = "macos"))] let managed_preferences = None; @@ -93,10 +104,16 @@ pub(super) async fn read_config_from_path( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, log_missing_as_info: bool, + strict_config: bool, ) -> io::Result> { match fs.read_file_text(path, /*sandbox*/ None).await { Ok(contents) => match toml::from_str::(&contents) { - Ok(value) => Ok(Some(value)), + Ok(value) => { + if strict_config { + validate_config_toml_strictly(path, &contents, &value)?; + } + Ok(Some(value)) + } Err(err) => { tracing::error!("Failed to parse {}: {err}", path.as_path().display()); let config_error = config_error_from_toml(path.as_path(), &contents, err.clone()); @@ -122,6 +139,33 @@ pub(super) async fn read_config_from_path( } } +fn validate_config_toml_strictly( + path: &AbsolutePathBuf, + contents: &str, + value: &TomlValue, +) -> io::Result<()> { + let Some(base_dir) = path.as_path().parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Config file {} has no parent directory", path.display()), + )); + }; + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + path.as_path(), + contents, + value.clone(), + ) { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )); + } + + Ok(()) +} + /// Return the default managed config path. pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf { #[cfg(unix)] diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 3a9fc3a0ea..199e7f6daa 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; use crate::config_requirements::RequirementSource; +use crate::config_toml::ConfigToml; +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::io_error_from_config_error; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use base64::Engine; use base64::prelude::BASE64_STANDARD; +use codex_utils_absolute_path::AbsolutePathBufGuard; use core_foundation::base::TCFType; use core_foundation::string::CFString; use core_foundation::string::CFStringRef; use std::ffi::c_void; use std::io; +use std::path::Path; use tokio::task; use toml::Value as TomlValue; @@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource { pub(crate) async fn load_managed_admin_config_layer( override_base64: Option<&str>, + strict_config: bool, + base_dir: &Path, ) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); return if trimmed.is_empty() { Ok(None) } else { - parse_managed_config_base64(trimmed).map(Some) + parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some) }; } - match task::spawn_blocking(load_managed_admin_config).await { + let base_dir = base_dir.to_path_buf(); + match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await { Ok(result) => result, Err(join_err) => { if join_err.is_cancelled() { @@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer( } } -fn load_managed_admin_config() -> io::Result> { +fn load_managed_admin_config( + strict_config: bool, + base_dir: &Path, +) -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? .as_deref() .map(str::trim) - .map(parse_managed_config_base64) + .map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir)) .transpose() } @@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result> { Ok(Some(value)) } -fn parse_managed_config_base64(encoded: &str) -> io::Result { +fn parse_managed_config_base64( + encoded: &str, + strict_config: bool, + base_dir: &Path, +) -> io::Result { let raw_toml = decode_managed_preferences_base64(encoded)?; - match toml::from_str::(&raw_toml) { - Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer { + let source_name = + format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}"); + let parsed = toml::from_str::(&raw_toml).map_err(|err| { + tracing::error!("Failed to parse managed config TOML: {err}"); + if strict_config { + let config_error = config_error_from_toml_for_source( + ConfigDiagnosticSource::DisplayName(&source_name), + &raw_toml, + err.clone(), + ); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + } else { + io::Error::new(io::ErrorKind::InvalidData, err) + } + })?; + + validate_managed_config_toml_strictly_if_requested( + strict_config, + &source_name, + &raw_toml, + &parsed, + base_dir, + )?; + match parsed { + TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer { config: TomlValue::Table(parsed), raw_toml, }), - Ok(other) => { + other => { tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); Err(io::Error::new( io::ErrorKind::InvalidData, "managed config root must be a table", )) } - Err(err) => { - tracing::error!("Failed to parse managed config TOML: {err}"); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) - } + } +} + +fn validate_managed_config_toml_strictly_if_requested( + strict_config: bool, + source_name: &str, + raw_toml: &str, + parsed: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + if !strict_config { + return Ok(()); + } + + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(source_name, raw_toml, parsed.clone()) + { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) } } diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 6e98691465..1bf70c4d05 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -21,7 +21,11 @@ use crate::project_root_markers::default_project_root_markers; use crate::project_root_markers::project_root_markers_from_config; use crate::state::ConfigLayerEntry; use crate::state::ConfigLayerStack; +use crate::state::ConfigLoadOptions; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; +use crate::strict_config::ignored_toml_value_field; +use crate::strict_config::unknown_feature_toml_value_field; use crate::thread_config::ThreadConfigContext; use crate::thread_config::ThreadConfigLoader; use codex_app_server_protocol::ConfigLayerSource; @@ -104,10 +108,14 @@ pub async fn load_config_layers_state( codex_home: &Path, cwd: Option, cli_overrides: &[(String, TomlValue)], - overrides: LoaderOverrides, + options: impl Into, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { + let ConfigLoadOptions { + loader_overrides: overrides, + strict_config, + } = options.into(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -140,7 +148,8 @@ pub async fn load_config_layers_state( // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. let loaded_config_layers = - layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?; + layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) + .await?; if !ignore_managed_requirements { load_requirements_from_legacy_scheme( &mut config_requirements_toml, @@ -168,6 +177,9 @@ pub async fn load_config_layers_state( .as_ref() .map(AbsolutePathBuf::as_path) .unwrap_or(codex_home); + if strict_config { + validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?; + } Some(resolve_relative_paths_in_config_toml( cli_overrides_layer, base_dir, @@ -177,16 +189,20 @@ pub async fn load_config_layers_state( // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; - let system_layer = - load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { + let system_layer = load_config_toml_for_required_layer( + fs, + &system_config_toml_file, + strict_config, + |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_toml_file.clone(), }, config_toml, ) - }) - .await?; + }, + ) + .await?; layers.push(system_layer); // Add a layer for $CODEX_HOME/config.toml so folder-derived resources such @@ -201,7 +217,7 @@ pub async fn load_config_layers_state( TomlValue::Table(toml::map::Map::new()), ) } else { - load_config_toml_for_required_layer(fs, &user_file, |config_toml| { + load_config_toml_for_required_layer(fs, &user_file, strict_config, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), @@ -268,6 +284,7 @@ pub async fn load_config_layers_state( &project_trust_context.project_root, &project_trust_context, codex_home, + strict_config, ) .await?; layers.extend(project_layers.layers); @@ -359,15 +376,11 @@ fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigL async fn load_config_toml_for_required_layer( fs: &dyn ExecutorFileSystem, toml_file: &AbsolutePathBuf, + strict_config: bool, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|err| { - let config_error = - config_error_from_toml(toml_file.as_path(), &contents, err.clone()); - io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) - })?; let config_parent = toml_file.as_path().parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, @@ -377,6 +390,19 @@ async fn load_config_toml_for_required_layer( ), ) })?; + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = + config_error_from_toml(toml_file.as_path(), &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + })?; + if strict_config { + validate_config_toml_strictly( + toml_file.as_path(), + &contents, + &config, + config_parent, + )?; + } resolve_relative_paths_in_config_toml(config, config_parent) } Err(e) => { @@ -397,6 +423,51 @@ async fn load_config_toml_for_required_layer( Ok(create_entry(toml_value)) } +fn validate_config_toml_strictly( + toml_file: &Path, + contents: &str, + value: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + toml_file, + contents, + value.clone(), + ) { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) + } +} + +fn validate_cli_overrides_strictly( + cli_overrides_layer: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(ignored_path) = ignored_toml_value_field::(cli_overrides_layer.clone()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + Ok(()) +} + /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. @@ -998,6 +1069,7 @@ async fn load_project_layers( project_root: &AbsolutePathBuf, trust_context: &ProjectTrustContext, codex_home: &Path, + strict_config: bool, ) -> io::Result { let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?; let codex_home_normalized = @@ -1063,6 +1135,14 @@ async fn load_project_layers( } }; let mut config = config; + if disabled_reason.is_none() && strict_config { + validate_config_toml_strictly( + config_file.as_path(), + &contents, + &config, + dot_codex_abs.as_path(), + )?; + } let ignored_project_config_keys = sanitize_project_config(&mut config); let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e718ecae67..015cd5d239 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -14,6 +14,22 @@ use std::collections::HashMap; use std::path::PathBuf; use toml::Value as TomlValue; +/// User-facing config loading behavior that is not part of the config document. +#[derive(Debug, Default, Clone)] +pub struct ConfigLoadOptions { + pub loader_overrides: LoaderOverrides, + pub strict_config: bool, +} + +impl From for ConfigLoadOptions { + fn from(loader_overrides: LoaderOverrides) -> Self { + Self { + loader_overrides, + strict_config: false, + } + } +} + /// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs new file mode 100644 index 0000000000..fb64458e07 --- /dev/null +++ b/codex-rs/config/src/strict_config.rs @@ -0,0 +1,201 @@ +//! Strict config validation built on top of serde's ignored-field tracking. + +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::ConfigError; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::default_range; +use crate::diagnostics::span_for_config_path; +use crate::diagnostics::span_for_toml_key_path; +use crate::diagnostics::text_range_from_span; +use codex_features::is_known_feature_key; +use serde::de::DeserializeOwned; +use std::path::Path; +use toml::Value as TomlValue; + +pub fn config_error_from_ignored_toml_fields( + path: impl AsRef, + contents: &str, +) -> Option { + let source = ConfigDiagnosticSource::Path(path.as_ref()); + match toml::from_str::(contents) { + Ok(value) => { + config_error_from_ignored_toml_value_fields_for_source::(source, contents, value) + } + Err(err) => Some(config_error_from_toml_for_source(source, contents, err)), + } +} + +pub(crate) fn config_error_from_ignored_toml_value_fields( + path: impl AsRef, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + value, + ) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( + source_name: &str, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::DisplayName(source_name), + contents, + value, + ) +} + +fn config_error_from_ignored_toml_value_fields_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + value: TomlValue, +) -> Option { + let unknown_feature_paths = unknown_feature_toml_value_path(&value); + let mut ignored_paths = Vec::new(); + let mut ignored_callback = |ignored_path: serde_ignored::Path<'_>| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }; + let deserializer = serde_ignored::Deserializer::new(value, &mut ignored_callback); + let result: Result = serde_path_to_error::deserialize(deserializer); + + match result { + Ok(_) => unknown_field_error_from_paths(source, contents, ignored_paths) + .or_else(|| unknown_field_error_from_paths(source, contents, unknown_feature_paths)), + Err(err) => { + let path_hint = err.path().clone(); + let toml_err = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub(crate) fn ignored_toml_value_field(value: TomlValue) -> Option { + let mut ignored_paths = Vec::new(); + let result: Result = serde_ignored::deserialize(value, |ignored_path| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }); + if result.is_err() { + return None; + } + + ignored_paths + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +pub(crate) fn unknown_feature_toml_value_field(value: &TomlValue) -> Option { + unknown_feature_toml_value_path(value) + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +fn unknown_field_error_from_paths( + source: ConfigDiagnosticSource<'_>, + contents: &str, + ignored_paths: Vec>, +) -> Option { + let path_segments = ignored_paths.into_iter().next()?; + let ignored_path = path_segments.join("."); + let range = span_for_toml_key_path(contents, &path_segments) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + format!("unknown configuration field `{ignored_path}`"), + )) +} + +fn unknown_feature_toml_value_path(value: &TomlValue) -> Vec> { + let Some(root) = value.as_table() else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + push_unknown_feature_paths(&mut paths, &["features"], root.get("features")); + + if let Some(profiles) = root.get("profiles").and_then(TomlValue::as_table) { + for (profile_name, profile) in profiles { + let prefix = ["profiles", profile_name.as_str(), "features"]; + let features = profile + .as_table() + .and_then(|profile| profile.get("features")); + push_unknown_feature_paths(&mut paths, &prefix, features); + } + } + + paths +} + +fn push_unknown_feature_paths( + paths: &mut Vec>, + prefix: &[&str], + features: Option<&TomlValue>, +) { + let Some(features) = features.and_then(TomlValue::as_table) else { + return; + }; + + for feature_key in features + .keys() + .map(String::as_str) + .filter(|key| !is_known_feature_key(key)) + { + let mut path = prefix + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); + path.push(feature_key.to_string()); + paths.push(path); + } +} + +fn ignored_path_segments(path: &serde_ignored::Path<'_>) -> Vec { + let mut segments = Vec::new(); + push_ignored_path_segments(path, &mut segments); + segments +} + +fn push_ignored_path_segments(path: &serde_ignored::Path<'_>, segments: &mut Vec) { + match path { + serde_ignored::Path::Root => {} + serde_ignored::Path::Seq { parent, index } => { + push_ignored_path_segments(parent, segments); + segments.push(index.to_string()); + } + serde_ignored::Path::Map { parent, key } => { + push_ignored_path_segments(parent, segments); + segments.push(key.clone()); + } + serde_ignored::Path::Some { parent } + | serde_ignored::Path::NewtypeStruct { parent } + | serde_ignored::Path::NewtypeVariant { parent } => { + push_ignored_path_segments(parent, segments); + } + } +} + +#[cfg(test)] +#[path = "strict_config_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs new file mode 100644 index 0000000000..4621b6b2e5 --- /dev/null +++ b/codex-rs/config/src/strict_config_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::config_toml::ConfigToml; +use crate::diagnostics::TextPosition; +use crate::diagnostics::TextRange; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn ignored_toml_field_errors_accept_non_file_source_names() { + let source_name = "com.openai.codex:config_toml_base64"; + let contents = r#" +model = "gpt-5" +unknown_key = true"#; + + let value = toml::from_str::(contents).expect("valid TOML"); + let error = config_error_from_ignored_toml_value_fields_for_source_name::( + source_name, + contents, + value, + ) + .expect("unknown field error"); + + assert_eq!( + error, + ConfigError::new( + PathBuf::from(source_name), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { + line: 3, + column: 11, + }, + }, + "unknown configuration field `unknown_key`", + ) + ); +} + +#[test] +fn type_errors_take_precedence_over_ignored_fields() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +model_context_window = "wide" +unknown_key = true"#; + + let error = + config_error_from_ignored_toml_fields::(path, contents).expect("type error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { + line: 2, + column: 24, + }, + end: TextPosition { + line: 2, + column: 29, + }, + }, + "invalid type: string \"wide\", expected i64", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `features.foo`", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_profile_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[profiles.work.features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `profiles.work.features.foo`", + ) + ); +} diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 9eec97773d..136a422849 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -18,6 +18,7 @@ use codex_config::RequirementSource; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; @@ -133,7 +134,8 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<( #[tokio::test] async fn returns_config_error_for_invalid_user_config_toml() { let tmp = tempdir().expect("tempdir"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -161,7 +163,8 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { let tmp = tempdir().expect("tempdir"); std::fs::write( tmp.path().join(CONFIG_TOML_FILE), - "model = \"from-user-config\"\ninvalid = [", + r#"model = "from-user-config" +invalid = ["#, ) .expect("write config"); @@ -219,7 +222,8 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: async fn returns_config_error_for_invalid_managed_config_toml() { let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; std::fs::write(&managed_path, contents).expect("write managed config"); let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); @@ -336,10 +340,151 @@ command = "python3 /tmp/user-hook.py" Ok(()) } +#[tokio::test] +async fn strict_config_rejects_unknown_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"model = "gpt-5" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_config_error = + config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![( + "foo".to_string(), + TomlValue::String("bar".to_string()), + )]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key_with_relative_path_override() { + let tmp = tempdir().expect("tempdir"); + let instructions_path = tmp.path().join("instructions.md"); + std::fs::write(&instructions_path, "instructions").expect("write instructions"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![ + ( + "model_instructions_file".to_string(), + TomlValue::String("instructions.md".to_string()), + ), + ("foo".to_string(), TomlValue::String("bar".to_string())), + ]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![("features.foo".to_string(), TomlValue::Boolean(true))]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `features.foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[features] +foo = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + assert_eq!( + config_error.message, + "unknown configuration field `features.foo`" + ); + assert_eq!(config_error.range.start.line, 2); + assert_eq!(config_error.range.start.column, 1); +} + +#[test] +fn strict_config_points_to_unknown_nested_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[mcp_servers.local] +command = "echo" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let error = config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + + assert_eq!( + error.message, + "unknown configuration field `mcp_servers.local.unknown_key`" + ); + assert_eq!(error.range.start.line, 3); + assert_eq!(error.range.start.column, 1); +} #[test] fn schema_error_points_to_feature_value() { let tmp = tempdir().expect("tempdir"); - let contents = "[features]\ncollaboration_modes = \"true\""; + let contents = r#"[features] +collaboration_modes = "true""#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -716,7 +861,12 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<() let tmp = tempdir()?; let managed_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?; + tokio::fs::write( + &managed_path, + r#"approval_policy = "on-request" +"#, + ) + .await?; let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); loader_overrides.macos_managed_config_requirements_base64 = Some( @@ -1201,11 +1351,17 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; let managed_config_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?; + tokio::fs::write( + &managed_config_path, + r#"approval_policy = "never" +"#, + ) + .await?; let system_requirements_path = tmp.path().join("requirements.toml"); tokio::fs::write( &system_requirements_path, - "allowed_sandbox_modes = [\"read-only\"]\n", + r#"allowed_sandbox_modes = ["read-only"] +"#, ) .await?; @@ -1391,12 +1547,14 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; @@ -1867,7 +2025,12 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R let home_dir = tmp.path().join("home"); let codex_home = home_dir.join(".codex"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "foo = \"user\"\n").await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"foo = "user" +"#, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?; let layers = load_config_layers_state( @@ -1909,7 +2072,12 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul tokio::fs::create_dir_all(&nested_dot_codex).await?; tokio::fs::create_dir_all(project_root.join(".git")).await?; - tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?; + tokio::fs::write( + nested_dot_codex.join(CONFIG_TOML_FILE), + r#"foo = "child" +"#, + ) + .await?; tokio::fs::create_dir_all(&project_dot_codex).await?; make_config_for_test( @@ -1923,7 +2091,10 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?; tokio::fs::write( &user_config_path, - format!("foo = \"user\"\n{user_config_contents}"), + format!( + r#"foo = "user" +{user_config_contents}"# + ), ) .await?; @@ -1948,7 +2119,11 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); - let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config"); + let child_config: TomlValue = toml::from_str( + r#"foo = "child" +"#, + ) + .expect("parse child config"); let expected_project_layer = ConfigLayerEntry::new( ConfigLayerSource::Project { dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, @@ -1972,7 +2147,9 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(nested.join(".codex")).await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\nprofile = \"ignored\"\n", + r#"foo = "child" +profile = "ignored" +"#, ) .await?; @@ -1991,7 +2168,10 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?; tokio::fs::write( &untrusted_config_path, - format!("foo = \"user\"\n{untrusted_config_contents}"), + format!( + r#"foo = "user" +{untrusted_config_contents}"# + ), ) .await?; @@ -2037,7 +2217,8 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(&codex_home_unknown).await?; tokio::fs::write( codex_home_unknown.join(CONFIG_TOML_FILE), - "foo = \"user\"\n", + r#"foo = "user" +"#, ) .await?; @@ -2207,7 +2388,8 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"project\"\n", + r#"foo = "project" +"#, ) .await?; std::os::unix::fs::symlink(&project_root, &alias_root)?; @@ -2378,9 +2560,21 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: ) .await?; let config_contents = tokio::fs::read_to_string(&config_path).await?; - tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; + tokio::fs::write( + &config_path, + format!( + r#"foo = "user" +{config_contents}"# + ), + ) + .await?; } else { - tokio::fs::write(&config_path, "foo = \"user\"\n").await?; + tokio::fs::write( + &config_path, + r#"foo = "user" +"#, + ) + .await?; } let layers = load_config_layers_state( @@ -2537,12 +2731,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() tokio::fs::write(project_root.join(".hg"), "hg").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d74f83102c..3b42a660e8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -15,7 +15,6 @@ use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConstrainedWithSource; use codex_config::FeatureRequirementsToml; -use codex_config::LoaderOverrides; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::PluginRequirementsToml; @@ -136,9 +135,11 @@ mod otel; mod permissions; #[cfg(test)] mod schema; +pub use codex_config::ConfigLoadOptions; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; +pub use codex_config::LoaderOverrides; pub use codex_network_proxy::NetworkProxyAuditMetadata; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; pub use codex_sandboxing::system_bwrap_warning; @@ -902,6 +903,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, thread_config_loader: Option>, fallback_cwd: Option, @@ -928,6 +930,11 @@ impl ConfigBuilder { self } + pub fn strict_config(mut self, strict_config: bool) -> Self { + self.strict_config = strict_config; + self + } + pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { self.cloud_requirements = cloud_requirements; self @@ -957,6 +964,7 @@ impl ConfigBuilder { cli_overrides, harness_overrides, loader_overrides, + strict_config, cloud_requirements, thread_config_loader, fallback_cwd, @@ -979,7 +987,10 @@ impl ConfigBuilder { &codex_home, Some(cwd), &cli_overrides, - loader_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + }, cloud_requirements, thread_config_loader .as_deref() @@ -1260,56 +1271,38 @@ impl Config { ) .await } - - /// This is a secondary way of creating [Config], which is appropriate when - /// the harness is meant to be used with a specific configuration that - /// ignores user settings. For example, the `codex exec` subcommand is - /// designed to use [AskForApproval::Never] exclusively. - /// - /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and - /// `main_execve_wrapper_exe`. - pub async fn load_with_cli_overrides_and_harness_overrides( - cli_overrides: Vec<(String, TomlValue)>, - harness_overrides: ConfigOverrides, - ) -> std::io::Result { - ConfigBuilder::default() - .cli_overrides(cli_overrides) - .harness_overrides(harness_overrides) - .build() - .await - } -} - -/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working -/// with [ConfigToml] directly means that [ConfigRequirements] have not been -/// applied yet, which risks failing to enforce required constraints. -pub async fn load_config_as_toml_with_cli_overrides( - codex_home: &Path, - cwd: Option<&AbsolutePathBuf>, - cli_overrides: Vec<(String, TomlValue)>, -) -> std::io::Result { - load_config_as_toml_with_cli_and_loader_overrides( - codex_home, - cwd, - cli_overrides, - LoaderOverrides::default(), - ) - .await } +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. pub async fn load_config_as_toml_with_cli_and_loader_overrides( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, +) -> std::io::Result { + load_config_as_toml_with_cli_and_load_options(codex_home, cwd, cli_overrides, loader_overrides) + .await +} + +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. +pub async fn load_config_as_toml_with_cli_and_load_options( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + options: impl Into, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home, cwd.cloned(), &cli_overrides, - loader_overrides, + options, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 5a94e815ae..3a5ebfd1ba 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -16,6 +16,10 @@ pub struct Cli { #[command(subcommand)] pub command: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", global = true, default_value_t = false)] + pub strict_config: bool, + #[clap(flatten)] pub shared: ExecSharedCliOptions, @@ -258,7 +262,7 @@ impl FromArgMatches for ResumeArgs { } } -#[derive(Parser, Debug)] +#[derive(Args, Debug)] pub struct ReviewArgs { /// Review staged, unstaged, and untracked changes. #[arg( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e7a960c02e..7825d163c9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -54,6 +54,7 @@ use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; use codex_core::StateDbHandle; @@ -62,7 +63,7 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_and_loader_overrides; +use codex_core::config::load_config_as_toml_with_cli_and_load_options; use codex_core::config::resolve_oss_provider; use codex_core::find_thread_meta_by_name_str; use codex_core::format_exec_policy_error_with_source; @@ -242,6 +243,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let Cli { command, + strict_config, shared, skip_git_repo_check, ephemeral, @@ -318,18 +320,20 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - #[allow(clippy::print_stderr)] let loader_overrides = LoaderOverrides { ignore_user_config, ignore_user_and_project_exec_policy_rules: ignore_rules, ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( + let config_toml = match load_config_as_toml_with_cli_and_load_options( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - loader_overrides.clone(), + ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + strict_config, + }, ) .await { @@ -431,6 +435,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) .loader_overrides(loader_overrides) + .strict_config(strict_config) .cloud_requirements(cloud_requirements) .build() .await?; @@ -522,6 +527,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result config: std::sync::Arc::new(config.clone()), cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, + strict_config, cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 79a681b146..61eaecdd0a 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -32,8 +32,7 @@ fn main() -> anyhow::Result<()> { let mut inner = top_cli.inner; inner .config_overrides - .raw_overrides - .splice(0..0, top_cli.config_overrides.raw_overrides); + .prepend_root_overrides(top_cli.config_overrides); run_main(inner, arg0_paths).await?; Ok(()) diff --git a/codex-rs/exec/src/main_tests.rs b/codex-rs/exec/src/main_tests.rs index a9cb0ec633..5c0a8a3bfc 100644 --- a/codex-rs/exec/src/main_tests.rs +++ b/codex-rs/exec/src/main_tests.rs @@ -7,6 +7,7 @@ fn top_cli_parses_resume_prompt_after_config_flag() { let cli = TopCli::parse_from([ "codex-exec", "resume", + "--strict-config", "--last", "--json", "--model", @@ -17,8 +18,12 @@ fn top_cli_parses_resume_prompt_after_config_flag() { "--skip-git-repo-check", PROMPT, ]); + let mut inner = cli.inner; + inner + .config_overrides + .prepend_root_overrides(cli.config_overrides); - let Some(codex_exec::Command::Resume(args)) = cli.inner.command else { + let Some(codex_exec::Command::Resume(args)) = inner.command.as_ref() else { panic!("expected resume command"); }; let effective_prompt = args.prompt.clone().or_else(|| { @@ -29,9 +34,10 @@ fn top_cli_parses_resume_prompt_after_config_flag() { } }); assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); - assert_eq!(cli.config_overrides.raw_overrides.len(), 1); + assert_eq!(inner.config_overrides.raw_overrides.len(), 1); assert_eq!( - cli.config_overrides.raw_overrides[0], + inner.config_overrides.raw_overrides[0], "reasoning_level=xhigh" ); + assert!(inner.strict_config); } diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index f83fd4fd5a..2f9f354277 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -2,6 +2,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; @@ -191,8 +192,11 @@ impl CodexToolCallParam { .map(|(k, v)| (k, json_to_toml(v))) .collect(); - let cfg = - Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await?; + let cfg = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(overrides) + .build() + .await?; Ok((prompt, cfg)) } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 45daf99a01..d82de48f9e 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -6,7 +6,7 @@ use std::io::Result as IoResult; use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; -use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; @@ -59,6 +59,7 @@ type IncomingMessage = JsonRpcMessage; pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, + strict_config: bool, ) -> IoResult<()> { // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. @@ -68,7 +69,10 @@ pub async fn run_main( format!("error parsing -c overrides: {e}"), ) })?; - let config = Config::load_with_cli_overrides(cli_kv_overrides) + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .strict_config(strict_config) + .build() .await .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index ce61fd04d1..220507446a 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -5,7 +5,12 @@ use codex_utils_cli::CliConfigOverrides; fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - run_main(arg0_paths, CliConfigOverrides::default()).await?; + run_main( + arg0_paths, + CliConfigOverrides::default(), + /*strict_config*/ false, + ) + .await?; Ok(()) }) } diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index d6001c60d8..4ec69bf26f 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -12,6 +12,10 @@ pub struct Cli { #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] pub prompt: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + pub strict_config: bool, + // Internal controls set by the top-level `codex resume` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 97c7d023f5..8880935d06 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,7 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::find_codex_home; -use crate::legacy_core::config::load_config_as_toml_with_cli_and_loader_overrides; +use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::format_exec_policy_error_with_source; use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; @@ -282,6 +282,7 @@ async fn start_embedded_app_server( config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -293,6 +294,7 @@ async fn start_embedded_app_server( config, cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -453,6 +455,7 @@ async fn start_app_server( config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -465,6 +468,7 @@ async fn start_app_server( config, cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -489,6 +493,7 @@ pub(crate) async fn start_app_server_for_picker( config.clone(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, @@ -519,6 +524,7 @@ async fn start_embedded_app_server_with( config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, @@ -545,6 +551,7 @@ where config: Arc::new(config), cli_overrides: cli_kv_overrides, loader_overrides, + strict_config, cloud_requirements, feedback, log_db, @@ -759,6 +766,7 @@ pub async fn run_main( loader_overrides: LoaderOverrides, explicit_remote_endpoint: Option, ) -> std::io::Result { + let strict_config = cli.strict_config; let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox { ( Some(SandboxMode::DangerFullAccess), @@ -836,11 +844,14 @@ pub async fn run_main( config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( + let config_toml = match load_config_as_toml_with_cli_and_load_options( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - loader_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + strict_config, + }, ) .await { @@ -936,6 +947,8 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await; @@ -991,6 +1004,8 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await; } @@ -1133,6 +1148,7 @@ pub async fn run_main( cli, arg0_paths, loader_overrides, + strict_config, app_server_target, remote_cwd_override, config, @@ -1154,6 +1170,7 @@ async fn run_ratatui_app( cli: Cli, arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, + strict_config: bool, app_server_target: AppServerTarget, remote_cwd_override: Option, initial_config: Config, @@ -1217,6 +1234,7 @@ async fn run_ratatui_app( initial_config.clone(), cli_kv_overrides.clone(), loader_overrides.clone(), + strict_config, cloud_requirements.clone(), feedback.clone(), log_db.clone(), @@ -1304,6 +1322,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await } else { @@ -1506,6 +1526,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, fallback_cwd, ) .await @@ -1515,6 +1537,8 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), cloud_requirements.clone(), + loader_overrides.clone(), + strict_config, ) .await } @@ -1556,6 +1580,7 @@ async fn run_ratatui_app( config.clone(), cli_kv_overrides.clone(), loader_overrides, + strict_config, cloud_requirements.clone(), feedback.clone(), log_db.clone(), @@ -1697,11 +1722,15 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, cloud_requirements: CloudRequirementsLoader, + loader_overrides: LoaderOverrides, + strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, cloud_requirements, + loader_overrides, + strict_config, /*fallback_cwd*/ None, ) .await @@ -1711,12 +1740,16 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, cloud_requirements: CloudRequirementsLoader, + loader_overrides: LoaderOverrides, + strict_config: bool, fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] match ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) + .strict_config(strict_config) .cloud_requirements(cloud_requirements) .fallback_cwd(fallback_cwd) .build() @@ -1788,6 +1821,7 @@ mod tests { config, Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, @@ -2353,6 +2387,7 @@ mod tests { config, Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 91362bf5a5..8486de32b4 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1056,6 +1056,7 @@ mod tests { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: Default::default(), + strict_config: false, cloud_requirements: cloud_requirements_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, diff --git a/codex-rs/utils/cli/src/config_override.rs b/codex-rs/utils/cli/src/config_override.rs index 41be3ca6b6..8368b10f89 100644 --- a/codex-rs/utils/cli/src/config_override.rs +++ b/codex-rs/utils/cli/src/config_override.rs @@ -37,6 +37,13 @@ pub struct CliConfigOverrides { } impl CliConfigOverrides { + /// Prepend root-level config flags so they have lower precedence than + /// command-specific flags parsed after a subcommand. + pub fn prepend_root_overrides(&mut self, root_overrides: Self) { + self.raw_overrides + .splice(0..0, root_overrides.raw_overrides); + } + /// Parse the raw strings captured from the CLI into a list of `(path, /// value)` tuples where `value` is a `serde_json::Value`. pub fn parse_overrides(&self) -> Result, String> { @@ -190,6 +197,24 @@ mod tests { assert_eq!(parsed[0].1.as_bool(), Some(true)); } + #[test] + fn prepends_root_overrides() { + let mut subcommand_overrides = CliConfigOverrides { + raw_overrides: vec![r#"model="gpt-5.2""#.to_string()], + }; + subcommand_overrides.prepend_root_overrides(CliConfigOverrides { + raw_overrides: vec![r#"model="gpt-5.1""#.to_string()], + }); + + assert_eq!( + subcommand_overrides.raw_overrides, + vec![ + r#"model="gpt-5.1""#.to_string(), + r#"model="gpt-5.2""#.to_string(), + ] + ); + } + #[test] fn parses_inline_table() { let v = parse_toml_value("{a = 1, b = 2}").expect("parse"); From fdda59c00b95a7656d29f1fcfdee88e7a7884f66 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 13 May 2026 18:16:51 +0200 Subject: [PATCH 02/52] Introduce tool exposure for deferred registration (#22489) ## Why Deferred tools were tracked with separate side-channel filtering after tool specs had already been assembled. That made the registry responsible for executing tools while the router/spec planner separately decided whether those same tools should be exposed to the model up front. This PR makes exposure part of the tool handler contract so direct versus deferred availability travels with the executable tool registration. Next step will be to simplify registration ## What Changed - Adds `ToolExposure` to `codex-tools` and exposes it through `ToolExecutor`, defaulting tools to `Direct`. - Teaches dynamic tools and MCP handlers to mark deferred tools as `Deferred` at construction time. - Renames the registry object-safe wrapper from `AnyToolHandler` to `RegisteredTool` and uses `ToolExposure` when deciding whether to include a handler's spec in the initial model-visible tool list. - Refactors tool spec planning to derive direct specs and deferred search entries from registered handlers, removing the router's special-case deferred dynamic tool filtering. ## Verification - Not run. --- codex-rs/core/src/tools/handlers/dynamic.rs | 11 ++++ codex-rs/core/src/tools/handlers/mcp.rs | 15 +++++- codex-rs/core/src/tools/registry.rs | 54 +++++++++++--------- codex-rs/core/src/tools/registry_tests.rs | 4 +- codex-rs/core/src/tools/router.rs | 52 ++----------------- codex-rs/core/src/tools/spec_plan.rs | 56 ++++++++++----------- codex-rs/tools/src/lib.rs | 1 + codex-rs/tools/src/tool_executor.rs | 12 +++++ 8 files changed, 100 insertions(+), 105 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 3f3e72e2f3..8000b2097c 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::turn_timing::now_unix_timestamp_ms; @@ -30,6 +31,7 @@ use tracing::warn; pub struct DynamicToolHandler { tool_name: ToolName, spec: Option, + exposure: ToolExposure, search_text: String, } @@ -48,6 +50,11 @@ impl DynamicToolHandler { Some(Self { tool_name, spec: Some(spec), + exposure: if tool.defer_loading { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }, search_text: build_dynamic_search_text(tool), }) } @@ -64,6 +71,10 @@ impl ToolExecutor for DynamicToolHandler { self.spec.clone() } + fn exposure(&self) -> ToolExposure { + self.exposure + } + async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 2cd02e8a0a..5f700e3a98 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -13,6 +13,7 @@ use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolTelemetryTags; use crate::tools::tool_search_entry::ToolSearchInfo; @@ -28,11 +29,19 @@ use serde_json::Value; pub struct McpHandler { tool_info: ToolInfo, + exposure: ToolExposure, } impl McpHandler { pub fn new(tool_info: ToolInfo) -> Self { - Self { tool_info } + Self::with_exposure(tool_info, ToolExposure::Direct) + } + + pub fn with_exposure(tool_info: ToolInfo, exposure: ToolExposure) -> Self { + Self { + tool_info, + exposure, + } } } @@ -71,6 +80,10 @@ impl ToolExecutor for McpHandler { })) } + fn exposure(&self) -> ToolExposure { + self.exposure + } + fn supports_parallel_tool_calls(&self) -> bool { self.tool_info.supports_parallel_tool_calls } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b8480600f7..ad54b5fb77 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -35,6 +35,7 @@ use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; pub use codex_tools::ToolExecutor; +pub use codex_tools::ToolExposure; pub trait ToolHandler: ToolExecutor { fn search_info(&self) -> Option { @@ -155,11 +156,17 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -pub(crate) trait AnyToolHandler: Send + Sync { +/// Object-safe registry entry for heterogeneous tool handlers. +/// +/// Concrete handlers keep their typed `ToolExecutor::Output`; the registry +/// boxes that output only after typed hooks have run. +pub(crate) trait RegisteredTool: Send + Sync { fn tool_name(&self) -> ToolName; fn spec(&self) -> Option; + fn exposure(&self) -> ToolExposure; + fn search_info(&self) -> Option; fn supports_parallel_tool_calls(&self) -> bool; @@ -186,7 +193,7 @@ pub(crate) trait AnyToolHandler: Send + Sync { ) -> BoxFuture<'a, Result>; } -impl AnyToolHandler for T +impl RegisteredTool for T where T: ToolHandler, { @@ -198,6 +205,10 @@ where ToolExecutor::spec(self) } + fn exposure(&self) -> ToolExposure { + ToolExecutor::exposure(self) + } + fn search_info(&self) -> Option { ToolHandler::search_info(self) } @@ -253,11 +264,11 @@ where } pub struct ToolRegistry { - handlers: HashMap>, + handlers: HashMap>, } impl ToolRegistry { - fn new(handlers: HashMap>) -> Self { + fn new(handlers: HashMap>) -> Self { Self { handlers } } @@ -272,10 +283,10 @@ impl ToolRegistry { T: ToolHandler + 'static, { let name = handler.tool_name(); - Self::new(HashMap::from([(name, handler as Arc)])) + Self::new(HashMap::from([(name, handler as Arc)])) } - fn handler(&self, name: &ToolName) -> Option> { + fn handler(&self, name: &ToolName) -> Option> { self.handlers.get(name).map(Arc::clone) } @@ -534,7 +545,7 @@ impl ToolRegistry { } pub struct ToolRegistryBuilder { - handlers: HashMap>, + handlers: HashMap>, specs: Vec, } @@ -554,29 +565,28 @@ impl ToolRegistryBuilder { where H: ToolHandler + 'static, { - self.register_any_handler(handler); + self.register_tool(handler); } - pub(crate) fn register_any_handler(&mut self, handler: Arc) { - self.register_any_handler_internal(handler, /*include_spec*/ true); + pub(crate) fn register_tool(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ true); } - pub(crate) fn register_any_handler_without_spec(&mut self, handler: Arc) { - self.register_any_handler_internal(handler, /*include_spec*/ false); + pub(crate) fn register_tool_without_spec(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ false); } - fn register_any_handler_internal( - &mut self, - handler: Arc, - include_spec: bool, - ) { + fn register_tool_internal(&mut self, handler: Arc, include_spec: bool) { let name = handler.tool_name(); if self.handlers.contains_key(&name) { error_or_panic(format!("handler for tool {name} already registered")); return; } - if include_spec && let Some(spec) = handler.spec() { + if include_spec + && handler.exposure() == ToolExposure::Direct + && let Some(spec) = handler.spec() + { self.push_spec(spec); } @@ -590,12 +600,8 @@ impl ToolRegistryBuilder { return; } - if let Some(spec) = executor.spec() { - self.push_spec(spec); - } - - let handler: Arc = Arc::new(ExtensionToolHandler::new(executor)); - self.handlers.insert(tool_name, handler); + let handler: Arc = Arc::new(ExtensionToolHandler::new(executor)); + self.register_tool_internal(handler, /*include_spec*/ true); } pub fn build(self) -> (Vec, ToolRegistry) { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 1cc68be44d..e27dd8fd31 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -33,10 +33,10 @@ fn handler_looks_up_namespaced_aliases_explicitly() { let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); let plain_handler = Arc::new(TestHandler { tool_name: plain_name.clone(), - }) as Arc; + }) as Arc; let namespaced_handler = Arc::new(TestHandler { tool_name: namespaced_name.clone(), - }) as Arc; + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index abb0e18976..d386d6dae3 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -17,11 +17,9 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_tools::DiscoverableTool; -use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; -use std::collections::HashSet; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::instrument; @@ -66,21 +64,11 @@ impl ToolRouter { dynamic_tools, ); let (specs, registry) = builder.build(); - let deferred_dynamic_tools = dynamic_tools - .iter() - .filter(|tool| tool.defer_loading) - .map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone())) - .collect::>(); let model_visible_specs = specs - .iter() - .filter_map(|spec| { - if config.code_mode_only_enabled - && codex_code_mode::is_code_mode_nested_tool(spec.name()) - { - return None; - } - - filter_deferred_dynamic_tool_spec(spec.clone(), &deferred_dynamic_tools) + .into_iter() + .filter(|spec| { + !config.code_mode_only_enabled + || !codex_code_mode::is_code_mode_nested_tool(spec.name()) }) .collect(); @@ -232,38 +220,6 @@ pub(crate) fn extension_tool_executors(session: &Session) -> Vec, -) -> Option { - if deferred_dynamic_tools.is_empty() { - return Some(spec); - } - - match spec { - ToolSpec::Function(tool) => { - if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) { - None - } else { - Some(ToolSpec::Function(tool)) - } - } - ToolSpec::Namespace(mut namespace) => { - let namespace_name = namespace.name.clone(); - namespace.tools.retain(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains( - &ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()), - ), - }); - if namespace.tools.is_empty() { - None - } else { - Some(ToolSpec::Namespace(namespace)) - } - } - spec => Some(spec), - } -} #[cfg(test)] #[path = "router_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 86ff84f8e8..0ac670921b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -44,7 +44,8 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; use crate::tools::hosted_spec::create_web_search_tool; -use crate::tools::registry::AnyToolHandler; +use crate::tools::registry::RegisteredTool; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistryBuilder; use crate::tools::spec_plan_types::ToolRegistryBuildParams; use crate::tools::spec_plan_types::agent_type_description; @@ -52,13 +53,11 @@ use codex_extension_api::ExtensionToolExecutor; use codex_protocol::openai_models::ConfigShellToolType; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolEnvironmentMode; -use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; use codex_tools::collect_code_mode_exec_prompt_tool_definitions; use codex_tools::default_namespace_description; use std::collections::BTreeMap; -use std::collections::HashSet; use std::sync::Arc; pub fn build_tool_registry_builder( @@ -66,40 +65,34 @@ pub fn build_tool_registry_builder( params: ToolRegistryBuildParams<'_>, ) -> ToolRegistryBuilder { let mut builder = ToolRegistryBuilder::new(); - let all_deferred_tools = params - .deferred_mcp_tools - .into_iter() - .flatten() - .map(codex_mcp::ToolInfo::canonical_tool_name) - .chain( - params - .dynamic_tools - .iter() - .filter(|tool| tool.defer_loading) - .map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone())), - ) - .collect::>(); let handlers = collect_handler_tools(config, params); + let deferred_tools_available = handlers + .iter() + .any(|handler| handler.exposure() == ToolExposure::Deferred); for handler in build_code_mode_handlers( config, &handlers, params.extension_tool_executors, - config.search_tool && !all_deferred_tools.is_empty(), + config.search_tool && deferred_tools_available, ) { - builder.register_any_handler(handler); + builder.register_tool(handler); } let mut non_deferred_specs = Vec::new(); let mut deferred_search_infos = Vec::new(); for handler in &handlers { - let tool_name = handler.tool_name(); - if all_deferred_tools.contains(&tool_name) { - if let Some(search_info) = handler.search_info() { - deferred_search_infos.push(search_info); + match handler.exposure() { + ToolExposure::Direct => { + if let Some(spec) = handler.spec() { + non_deferred_specs.push(spec); + } + } + ToolExposure::Deferred => { + if let Some(search_info) = handler.search_info() { + deferred_search_infos.push(search_info); + } } - } else if let Some(spec) = handler.spec() { - non_deferred_specs.push(spec); } } @@ -127,7 +120,7 @@ pub fn build_tool_registry_builder( } for handler in handlers { - builder.register_any_handler_without_spec(handler); + builder.register_tool_without_spec(handler); } if config.search_tool && config.namespace_tools && !deferred_search_infos.is_empty() { @@ -143,10 +136,10 @@ pub fn build_tool_registry_builder( fn build_code_mode_handlers( config: &ToolsConfig, - handlers: &[Arc], + handlers: &[Arc], extension_tool_executors: &[Arc], deferred_tools_available: bool, -) -> Vec> { +) -> Vec> { if !config.code_mode_enabled { return vec![]; } @@ -251,9 +244,9 @@ fn code_mode_namespace_descriptions( fn collect_handler_tools( config: &ToolsConfig, params: ToolRegistryBuildParams<'_>, -) -> Vec> { +) -> Vec> { let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; - let mut handlers = Vec::>::new(); + let mut handlers = Vec::>::new(); if config.environment_mode.has_environment() { let include_environment_id = @@ -430,7 +423,10 @@ fn collect_handler_tools( if let Some(deferred_mcp_tools) = params.deferred_mcp_tools { for tool in deferred_mcp_tools { - handlers.push(Arc::new(McpHandler::new(tool.clone()))); + handlers.push(Arc::new(McpHandler::with_exposure( + tool.clone(), + ToolExposure::Deferred, + ))); } } diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 86e4750b32..2a87ba35d7 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -79,6 +79,7 @@ pub use tool_discovery::ToolSearchSourceInfo; pub use tool_discovery::collect_request_plugin_install_entries; pub use tool_discovery::filter_request_plugin_install_discoverable_tools_for_client; pub use tool_executor::ToolExecutor; +pub use tool_executor::ToolExposure; pub use tool_output::JsonToolOutput; pub use tool_output::ToolOutput; pub use tool_payload::ToolPayload; diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs index 8c38e9fc8c..1d7169ca1d 100644 --- a/codex-rs/tools/src/tool_executor.rs +++ b/codex-rs/tools/src/tool_executor.rs @@ -5,6 +5,14 @@ use crate::ToolName; use crate::ToolOutput; use crate::ToolSpec; +/// Controls whether a tool is exposed in the initial model-visible tool list +/// or registered for later discovery. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ToolExposure { + Direct, + Deferred, +} + /// Shared runtime contract for model-visible tools. /// /// Implementations keep the model-visible spec tied to the executable runtime. @@ -20,6 +28,10 @@ pub trait ToolExecutor: Send + Sync { None } + fn exposure(&self) -> ToolExposure { + ToolExposure::Direct + } + fn supports_parallel_tool_calls(&self) -> bool { false } From 7c7b4861d88960f7e3bd5b7f30f8351be666dd84 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 13 May 2026 18:21:02 +0200 Subject: [PATCH 03/52] fix: drop underscored id headers (#22193) ## Why Stop sending duplicate `session_id`/`thread_id` headers. We only want the hyphenated forms as `_` is rejected by some proxies Related discussion here: https://openai.slack.com/archives/C095U48JNL9/p1778508316923179 ## What - Keep `session-id` and `thread-id` - Remove the underscore aliases --- codex-rs/codex-api/src/requests/headers.rs | 2 -- codex-rs/codex-api/tests/clients.rs | 8 ------- codex-rs/core/tests/suite/client.rs | 22 ++++--------------- .../core/tests/suite/client_websockets.rs | 4 ++-- codex-rs/core/tests/suite/compact_remote.rs | 8 +++---- 5 files changed, 10 insertions(+), 34 deletions(-) diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index ed5a5ad647..c5cede8ac6 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -5,11 +5,9 @@ use http::HeaderValue; pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); if let Some(id) = session_id { - insert_header(&mut headers, "session_id", &id); insert_header(&mut headers, "session-id", &id); } if let Some(id) = thread_id { - insert_header(&mut headers, "thread_id", &id); insert_header(&mut headers, "thread-id", &id); } headers diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index a78073b302..afaf82389f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -458,18 +458,10 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { assert_eq!(requests.len(), 1); let req = &requests[0]; - assert_eq!( - req.headers.get("session_id").and_then(|v| v.to_str().ok()), - Some("sess_123") - ); assert_eq!( req.headers.get("session-id").and_then(|v| v.to_str().ok()), Some("sess_123") ); - assert_eq!( - req.headers.get("thread_id").and_then(|v| v.to_str().ok()), - Some("thread_123") - ); assert_eq!( req.headers.get("thread-id").and_then(|v| v.to_str().ok()), Some("thread_123") diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2bb511312e..8a461edc63 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -764,10 +764,8 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); - let request_session_id = request.header("session_id").expect("session_id header"); - let request_session_id_hyphenated = request.header("session-id").expect("session-id header"); - let request_thread_id = request.header("thread_id").expect("thread_id header"); - let request_thread_id_hyphenated = request.header("thread-id").expect("thread-id header"); + let request_session_id = request.header("session-id").expect("session-id header"); + let request_thread_id = request.header("thread-id").expect("thread-id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -779,12 +777,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { let thread_id_string = expected_thread_id.to_string(); assert_eq!(request_session_id, expected_session_id.to_string()); - assert_eq!( - request_session_id_hyphenated, - expected_session_id.to_string() - ); assert_eq!(request_thread_id, thread_id_string.as_str()); - assert_eq!(request_thread_id_hyphenated, thread_id_string.as_str()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); assert_eq!( @@ -1046,20 +1039,13 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - let request_session_id = request.header("session_id").expect("session_id header"); - let request_session_id_hyphenated = request.header("session-id").expect("session-id header"); - let request_thread_id = request.header("thread_id").expect("thread_id header"); - let request_thread_id_hyphenated = request.header("thread-id").expect("thread-id header"); + let request_session_id = request.header("session-id").expect("session-id header"); + let request_thread_id = request.header("thread-id").expect("thread-id header"); let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); assert_eq!(request_session_id, expected_session_id.to_string()); - assert_eq!( - request_session_id_hyphenated, - expected_session_id.to_string() - ); assert_eq!(request_thread_id, expected_thread_id.to_string()); - assert_eq!(request_thread_id_hyphenated, expected_thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 489adb9a95..030aecd29e 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -132,11 +132,11 @@ async fn responses_websocket_streams_request() { Some(harness.thread_id.to_string()) ); assert_eq!( - handshake.header("session_id"), + handshake.header("session-id"), Some(harness.session_id.to_string()) ); assert_eq!( - handshake.header("thread_id"), + handshake.header("thread-id"), Some(harness.thread_id.to_string()) ); assert_eq!( diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index bec0ea436f..88aafd26ac 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -357,11 +357,11 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { Some("Bearer Access Token") ); assert_eq!( - compact_request.header("session_id").as_deref(), + compact_request.header("session-id").as_deref(), Some(session_id.as_str()) ); assert_eq!( - compact_request.header("thread_id").as_deref(), + compact_request.header("thread-id").as_deref(), Some(thread_id.as_str()) ); let compact_body = compact_request.body_json(); @@ -1046,12 +1046,12 @@ async fn remote_compact_runs_automatically() -> Result<()> { assert_eq!( compact_mock .single_request() - .header("session_id") + .header("session-id") .as_deref(), Some(session_id.as_str()) ); assert_eq!( - compact_mock.single_request().header("thread_id").as_deref(), + compact_mock.single_request().header("thread-id").as_deref(), Some(thread_id.as_str()) ); let follow_up_request = responses_mock.single_request(); From 83decfa3009cc575403bf935415eccb0a552d8f2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 13 May 2026 09:43:25 -0700 Subject: [PATCH 04/52] [codex] Remove unused legacy shell tools (#22246) ## Why Recent session history showed no active use of the raw `shell`, `local_shell`, or `container.exec` execution surfaces. Keeping those handlers/specs wired into core leaves duplicate shell execution paths alongside the supported `shell_command` and unified exec tools. ## What changed - Removed the raw `shell` handler/spec and its `ShellToolCallParams` protocol helper. - Removed the legacy `local_shell` and `container.exec` handler/spec plumbing while preserving persisted-history compatibility for old response items. - Normalized model/config `default` and `local` shell selections to `shell_command`. - Pruned tests that exercised removed raw-shell/local-shell/apply-patch variants and kept coverage on `shell_command`, unified exec, and freeform `apply_patch`. ## Verification - `git diff --check` - `cargo test -p codex-protocol` - `cargo test -p codex-tools` - `cargo test -p codex-core tools::handlers::shell` - `cargo test -p codex-core tools::spec` - `cargo test -p codex-core tools::router` - `cargo test -p codex-core active_call_preserves_triggering_command_context` - `cargo test -p codex-core guardian_tests` - `cargo test -p codex-core --test all shell_serialization` - `cargo test -p codex-core --test all apply_patch_cli` - `cargo test -p codex-core --test all shell_command_` - `cargo test -p codex-core --test all local_shell` - `cargo test -p codex-core --test all otel::` - `cargo test -p codex-core --test all hooks::` - `just fix -p codex-core` - `just fix -p codex-tools` --- codex-rs/config/src/types.rs | 3 +- codex-rs/core/config.schema.json | 2 +- codex-rs/core/src/client_common.rs | 2 +- codex-rs/core/src/compact_remote_v2.rs | 2 +- codex-rs/core/src/memory_usage.rs | 9 - codex-rs/core/src/session/tests.rs | 63 ++-- .../core/src/session/tests/guardian_tests.rs | 131 ++------ codex-rs/core/src/stream_events_utils.rs | 28 -- codex-rs/core/src/tools/handlers/mod.rs | 3 - codex-rs/core/src/tools/handlers/shell.rs | 91 +----- .../tools/handlers/shell/container_exec.rs | 97 ------ .../src/tools/handlers/shell/local_shell.rs | 131 -------- .../src/tools/handlers/shell/shell_handler.rs | 146 --------- .../core/src/tools/handlers/shell_spec.rs | 72 ----- .../src/tools/handlers/shell_spec_tests.rs | 156 ---------- .../core/src/tools/handlers/shell_tests.rs | 39 --- .../core/src/tools/network_approval_tests.rs | 4 +- codex-rs/core/src/tools/parallel.rs | 2 +- codex-rs/core/src/tools/router.rs | 32 -- codex-rs/core/src/tools/router_tests.rs | 3 +- codex-rs/core/src/tools/runtimes/shell.rs | 20 +- codex-rs/core/src/tools/spec_plan.rs | 43 +-- codex-rs/core/src/tools/spec_plan_tests.rs | 1 - codex-rs/core/src/tools/spec_tests.rs | 4 +- .../core/src/tools/tool_dispatch_trace.rs | 9 - codex-rs/core/src/tools/tool_search_entry.rs | 1 - codex-rs/core/tests/common/responses.rs | 19 -- codex-rs/core/tests/common/test_codex.rs | 8 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 49 --- codex-rs/core/tests/suite/compact.rs | 63 ++-- codex-rs/core/tests/suite/compact_remote.rs | 9 +- codex-rs/core/tests/suite/hooks.rs | 226 +------------- .../core/tests/suite/models_etag_responses.rs | 6 +- codex-rs/core/tests/suite/otel.rs | 177 +++-------- .../core/tests/suite/shell_serialization.rs | 288 +----------------- ...user_input_no_preempt_after_reasoning.snap | 2 +- codex-rs/core/tests/suite/tool_harness.rs | 15 +- codex-rs/core/tests/suite/tools.rs | 160 +++------- codex-rs/protocol/src/models.rs | 48 --- codex-rs/tools/src/code_mode.rs | 3 +- codex-rs/tools/src/function_call_error.rs | 2 - codex-rs/tools/src/tool_config.rs | 3 + codex-rs/tools/src/tool_payload.rs | 3 - codex-rs/tools/src/tool_spec.rs | 3 - codex-rs/tools/src/tool_spec_tests.rs | 1 - sdk/typescript/tests/abort.test.ts | 2 +- sdk/typescript/tests/responsesProxy.ts | 5 +- 47 files changed, 205 insertions(+), 1981 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/shell/container_exec.rs delete mode 100644 codex-rs/core/src/tools/handlers/shell/local_shell.rs delete mode 100644 codex-rs/core/src/tools/handlers/shell/shell_handler.rs diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 21d9b44752..74e3394f93 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -892,8 +892,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -/// Policy for building the `env` when spawning a process via either the -/// `shell` or `local_shell` tool. +/// Policy for building the `env` when spawning a process via shell-like tools. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ShellEnvironmentPolicyToml { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8a7cb01b5f..5c0100c981 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2344,7 +2344,7 @@ }, "ShellEnvironmentPolicyToml": { "additionalProperties": false, - "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", + "description": "Policy for building the `env` when spawning a process via shell-like tools.", "properties": { "exclude": { "description": "List of regular expressions.", diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index efe2670652..2061d53c80 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -126,7 +126,7 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) { } fn is_shell_tool_name(name: &str) -> bool { - matches!(name, "shell" | "container.exec") + name == "shell" } #[derive(Deserialize)] diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 7f6ea61a5b..4dafb95f8e 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -401,7 +401,7 @@ mod tests { message("assistant", "final", Some(MessagePhase::FinalAnswer)), ResponseItem::FunctionCall { id: None, - name: "shell".to_string(), + name: "shell_command".to_string(), namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index fd54044f21..bd856d5e6f 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -5,7 +5,6 @@ use crate::tools::handlers::unified_exec::ExecCommandArgs; use codex_memories_read::usage::MEMORIES_USAGE_METRIC; use codex_memories_read::usage::memories_usage_kinds_from_command; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use std::path::PathBuf; pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) { @@ -41,14 +40,6 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) - .ok() - .map(|params| { - ( - params.command, - invocation.turn.resolve_path(params.workdir).to_path_buf(), - ) - }), (None, "shell_command") => serde_json::from_str::(arguments) .ok() .map(|params| { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b4ba561873..9ef67bcb8e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4,7 +4,6 @@ use crate::config::ConfigBuilder; use crate::config::test_config; use crate::context::ContextualUserFragment; use crate::context::TurnAborted; -use crate::exec::ExecCapturePolicy; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::skills::SkillRenderSideEffects; @@ -69,7 +68,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; -use crate::tools::handlers::ShellHandler; +use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; @@ -8317,7 +8316,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8553,7 +8552,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: .await; sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8984,7 +8983,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { id: None, status: None, call_id: "call-1".to_string(), - name: "shell".to_string(), + name: "shell_command".to_string(), input: "{}".to_string(), }; @@ -9007,7 +9006,10 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { match err { FunctionCallError::Fatal(message) => { - assert_eq!(message, "tool shell invoked with incompatible payload"); + assert_eq!( + message, + "tool shell_command invoked with incompatible payload" + ); } other => panic!("expected FunctionCallError::Fatal, got {other:?}"), } @@ -9353,13 +9355,12 @@ async fn update_goal_tool_marks_goal_complete() { #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { - use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::protocol::AskForApproval; - use std::collections::HashMap; + use codex_tools::ShellCommandBackendConfig; let (session, mut turn_context_raw) = make_session_and_context().await; // Ensure policy is NOT OnRequest so the early rejection path triggers @@ -9370,43 +9371,16 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); + let command_script = "echo hi"; let timeout_ms = 1000; let sandbox_permissions = SandboxPermissions::RequireEscalated; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let tool_name = "shell"; + let tool_name = "shell_command"; let call_id = "test-call".to_string(); - let handler = ShellHandler::default(); + let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -9418,11 +9392,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), + "command": command_script, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, - "justification": params.justification.clone(), + "timeout_ms": timeout_ms, + "sandbox_permissions": sandbox_permissions, + "justification": Some("test"), }) .to_string(), }, @@ -9448,11 +9422,14 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); + let command = session + .user_shell() + .derive_exec_args(command_script, turn_context.tools_config.allow_login_shell); let exec_approval_requirement = session .services .exec_policy .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: ¶ms.command, + command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 730b9f5e87..ffb0d94d80 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1,8 +1,6 @@ use super::*; use crate::compact::InitialContextInjection; use crate::environment_selection::ResolvedTurnEnvironments; -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::sandboxing::SandboxPermissions; @@ -43,8 +41,6 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use serde::Deserialize; -use std::collections::HashMap; use std::fs; use std::sync::Arc; use std::time::Duration; @@ -238,7 +234,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { } #[tokio::test] -async fn guardian_allows_shell_additional_permissions_requests_past_policy_validation() { +async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() { let server = start_mock_server().await; let _request_log = mount_sse_once( &server, @@ -292,38 +288,9 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid let turn_context = Arc::new(turn_context_raw); let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 }; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: expiration_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; - - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -331,21 +298,22 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), + "command": "echo hi", + "login": false, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, + "timeout_ms": expiration_ms, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: None, }, - "justification": params.justification.clone(), + "justification": Some("test"), }) .to_string(), }, @@ -353,27 +321,11 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid .await; let output = expect_text_output(&resp.expect("expected Ok result")); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } #[tokio::test] -async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { +async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() { let server = start_mock_server().await; let guardian_request_log = mount_sse_once( &server, @@ -437,34 +389,22 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); - let command = if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }; + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), - call_id: "strict-shell-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + call_id: "strict-shell-command-call".to_string(), + tool_name: codex_tools::ToolName::plain("shell_command"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": command, + "command": "echo hi", + "login": false, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": 1_000_u64, }) @@ -593,7 +533,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message clippy::await_holding_invalid_type, reason = "test mutates active turn state directly to seed granted permissions" )] -async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { +async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { let (mut session, turn_context_raw) = make_session_and_context().await; session .features @@ -615,7 +555,9 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -623,15 +565,12 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "sticky-turn-grant".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": [ - "/bin/sh", - "-c", - "echo hi", - ], + "command": "echo hi", + "login": false, "timeout_ms": 1_000_u64, "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), }) @@ -643,23 +582,7 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per match resp { Ok(output) => { let output = expect_text_output(&output); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } Err(FunctionCallError::RespondToModel(output)) => { assert!( diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index b4f00ee864..25d7dada95 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -289,34 +289,6 @@ pub(crate) async fn handle_output_item_done( output.last_agent_message = last_agent_message; } - // Guardrail: the model issued a LocalShellCall without an id; surface the error back into history. - Err(FunctionCallError::MissingLocalShellCallId) => { - let msg = "LocalShellCall without call_id or id"; - ctx.turn_context - .session_telemetry - .log_tool_failed("local_shell", msg); - tracing::error!(msg); - - let response = ResponseInputItem::FunctionCallOutput { - call_id: String::new(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(msg.to_string()), - ..Default::default() - }, - }; - record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) - .await; - if let Some(response_item) = response_input_to_response_item(&response) { - ctx.sess - .record_conversation_items( - &ctx.turn_context, - std::slice::from_ref(&response_item), - ) - .await; - } - - output.needs_follow_up = true; - } // The tool request should be answered directly (or was denied); push that response into the transcript. Err(FunctionCallError::RespondToModel(message)) => { let response = ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 712ed0f1a6..69281acc7d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -62,11 +62,8 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; -pub use shell::ContainerExecHandler; -pub use shell::LocalShellHandler; pub use shell::ShellCommandHandler; pub(crate) use shell::ShellCommandHandlerOptions; -pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use tool_search::ToolSearchHandler; pub use unified_exec::ExecCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 6f8ba4d632..e6ce6908f3 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,6 +1,5 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; @@ -9,8 +8,6 @@ use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; @@ -19,12 +16,7 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; -use crate::tools::handlers::rewrite_function_arguments; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::runtimes::shell::ShellRuntimeBackend; @@ -33,36 +25,10 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; -mod container_exec; -mod local_shell; mod shell_command; -mod shell_handler; -pub use container_exec::ContainerExecHandler; -pub use local_shell::LocalShellHandler; pub use shell_command::ShellCommandHandler; pub(crate) use shell_command::ShellCommandHandlerOptions; -pub use shell_handler::ShellHandler; - -fn shell_function_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::Function { arguments } = payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) -} - -fn local_shell_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::LocalShell { params } = payload else { - return None; - }; - - Some(codex_shell_command::parse_command::shlex_join( - ¶ms.command, - )) -} fn shell_command_payload_command(payload: &ToolPayload) -> Option { let ToolPayload::Function { arguments } = payload else { @@ -88,53 +54,6 @@ struct RunExecLikeArgs { shell_runtime_backend: ShellRuntimeBackend, } -fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option { - shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) -} - -fn rewrite_shell_function_updated_hook_input( - mut invocation: ToolInvocation, - updated_input: JsonValue, - tool_name: &str, -) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel(format!( - "hook input rewrite received unsupported {tool_name} payload" - ))); - }; - let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| { - arguments.insert( - "command".to_string(), - JsonValue::Array(command.into_iter().map(JsonValue::String).collect()), - ); - })?, - }; - Ok(invocation) -} - -fn shell_function_post_tool_use_payload( - invocation: &ToolInvocation, - result: &FunctionToolOutput, -) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_function_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) -} - async fn run_exec_like(args: RunExecLikeArgs) -> Result { let RunExecLikeArgs { tool_name, @@ -289,15 +208,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result ShellRuntime::new(), - backend @ (ShellCommandClassic | ShellCommandZshFork) => { - ShellRuntime::for_shell_command(backend) - } - } - }; + let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend); let tool_ctx = ToolCtx { session: session.clone(), turn: turn.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell/container_exec.rs b/codex-rs/core/src/tools/handlers/shell/container_exec.rs deleted file mode 100644 index ee3dd8221d..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/container_exec.rs +++ /dev/null @@ -1,97 +0,0 @@ -use codex_protocol::models::ShellToolCallParams; -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; - -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; -use super::shell_handler::ShellHandler; - -pub struct ContainerExecHandler; - -impl ToolExecutor for ContainerExecHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("container.exec") - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for container.exec handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("container.exec"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for ContainerExecHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/local_shell.rs b/codex-rs/core/src/tools/handlers/shell/local_shell.rs deleted file mode 100644 index 608d748d85..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/local_shell.rs +++ /dev/null @@ -1,131 +0,0 @@ -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::create_local_shell_tool; -use super::RunExecLikeArgs; -use super::local_shell_payload_command; -use super::run_exec_like; -use super::shell_handler::ShellHandler; - -#[derive(Default)] -pub struct LocalShellHandler { - include_spec: bool, -} - -impl LocalShellHandler { - pub(crate) fn new() -> Self { - Self { include_spec: true } - } -} - -impl ToolExecutor for LocalShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("local_shell") - } - - fn spec(&self) -> Option { - self.include_spec.then(create_local_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.include_spec - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let ToolPayload::LocalShell { params } = payload else { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for local_shell handler".to_string(), - )); - }; - - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("local_shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: None, - prefix_rule: None, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for LocalShellHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::LocalShell { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let command = updated_hook_command(&updated_input)?; - invocation.payload = match invocation.payload { - ToolPayload::LocalShell { mut params } => { - params.command = shlex::split(command).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - ToolPayload::LocalShell { params } - } - payload => payload, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = local_shell_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs b/codex-rs/core/src/tools/handlers/shell/shell_handler.rs deleted file mode 100644 index 8875e96024..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs +++ /dev/null @@ -1,146 +0,0 @@ -use codex_protocol::ThreadId; -use codex_protocol::models::ShellToolCallParams; -use codex_tools::ToolName; - -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; -use crate::exec_env::create_env; -use crate::function_tool::FunctionCallError; -use crate::session::turn_context::TurnContext; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::ShellToolOptions; -use super::super::shell_spec::create_shell_tool; -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; - -#[derive(Default)] -pub struct ShellHandler { - options: Option, -} - -impl ShellHandler { - pub(crate) fn new(options: ShellToolOptions) -> Self { - Self { - options: Some(options), - } - } - - pub(super) fn to_exec_params( - params: &ShellToolCallParams, - turn_context: &TurnContext, - thread_id: ThreadId, - ) -> ExecParams { - ExecParams { - command: params.command.clone(), - cwd: turn_context.resolve_path(params.workdir.clone()), - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.network.clone(), - sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - } - } -} - -impl ToolExecutor for ShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("shell") - } - - fn spec(&self) -> Option { - self.options.map(create_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.options.is_some() - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for shell handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for ShellHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index dc46290bfa..43b78e3afe 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -11,20 +11,11 @@ pub struct CommandToolOptions { pub exec_permission_approvals_enabled: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ShellToolOptions { - pub exec_permission_approvals_enabled: bool, -} - #[cfg(test)] pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false) } -pub fn create_local_shell_tool() -> ToolSpec { - ToolSpec::LocalShell {} -} - pub(crate) fn create_exec_command_tool_with_environment_id( options: CommandToolOptions, include_environment_id: bool, @@ -153,69 +144,6 @@ pub fn create_write_stdin_tool() -> ToolSpec { }) } -pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec { - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - options.exec_permission_approvals_enabled, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { let mut properties = BTreeMap::from([ ( diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index a219263e1a..4774815fb2 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -6,91 +6,6 @@ fn windows_shell_guidance_description() -> String { format!("\n\n{}", windows_shell_guidance()) } -#[test] -fn shell_tool_matches_expected_spec() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: false, - }); - - let description = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# - .to_string() - + &windows_shell_guidance_description() - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - let properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some("The command to execute".to_string())), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some("The working directory to execute the command in".to_string())), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some("The timeout for the command in milliseconds".to_string())), - ), - ( - "sandbox_permissions".to_string(), - JsonSchema::string(Some( - "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - .to_string(), - )), - ), - ( - "justification".to_string(), - JsonSchema::string(Some( - r#"Only set if sandbox_permissions is \"require_escalated\". - Request approval from the user to run this command outside the sandbox. - Phrased as a simple question that summarizes the purpose of the - command as it relates to the task at hand - e.g. 'Do you want to - fetch and pull the latest version of this git branch?'"# - .to_string(), - )), - ), - ( - "prefix_rule".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some( - r#"Only specify when sandbox_permissions is `require_escalated`. - Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. - Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."# - .to_string(), - )), - ), - ]); - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn exec_command_tool_matches_expected_spec() { let tool = create_exec_command_tool(CommandToolOptions { @@ -224,77 +139,6 @@ fn write_stdin_tool_matches_expected_spec() { ); } -#[test] -fn shell_tool_with_request_permission_includes_additional_permissions() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: true, - }); - - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - /*exec_permission_approvals_enabled*/ true, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn request_permissions_tool_includes_full_permission_schema() { let tool = diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ce97b8317e..9db561d577 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -16,7 +16,6 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; @@ -203,44 +202,6 @@ fn shell_command_handler_rejects_login_when_disallowed() { ); } -#[tokio::test] -async fn local_shell_pre_tool_use_payload_uses_joined_command() { - let payload = ToolPayload::LocalShell { - params: codex_protocol::models::ShellToolCallParams { - command: vec![ - "bash".to_string(), - "-lc".to_string(), - "printf hi".to_string(), - ], - workdir: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: None, - }, - }; - let (session, turn) = make_session_and_context().await; - let handler = LocalShellHandler::default(); - - assert_eq!( - handler.pre_tool_use_payload(&ToolInvocation { - session: session.into(), - turn: turn.into(), - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), - call_id: "call-41".to_string(), - tool_name: codex_tools::ToolName::plain("local_shell"), - source: crate::tools::context::ToolCallSource::Direct, - payload, - }), - Some(crate::tools::registry::PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: json!({ "command": "bash -lc 'printf hi'" }), - }) - ); -} - #[tokio::test] async fn shell_command_pre_tool_use_payload_uses_raw_command() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 37a28b1255..a98d650688 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -229,7 +229,7 @@ async fn register_call_with_default_shell_trigger( "turn-1".to_string(), GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/tmp").abs(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -249,7 +249,7 @@ async fn active_call_preserves_triggering_command_context() { let service = NetworkApprovalService::default(); let expected = GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/repo").abs(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 2c62e6c15d..4c79e4b168 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -180,7 +180,7 @@ impl ToolCallRuntime { if call.tool_name.namespace.is_none() && matches!( call.tool_name.name.as_str(), - "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" + "shell_command" | "unified_exec" ) { format!("Wall time: {secs:.1} seconds\naborted by user") diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index d386d6dae3..d69e41ab19 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,5 +1,4 @@ use crate::function_tool::FunctionCallError; -use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; @@ -12,10 +11,8 @@ use crate::tools::spec::build_specs_with_discoverable_tools; use codex_extension_api::ExtensionToolExecutor; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; -use codex_protocol::models::ShellToolCallParams; use codex_tools::DiscoverableTool; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -141,35 +138,6 @@ impl ToolRouter { call_id, payload: ToolPayload::Custom { input }, })), - ResponseItem::LocalShellCall { - id, - call_id, - action, - .. - } => { - let call_id = call_id - .or(id) - .ok_or(FunctionCallError::MissingLocalShellCallId)?; - - match action { - LocalShellAction::Exec(exec) => { - let params = ShellToolCallParams { - command: exec.command, - workdir: exec.working_directory, - timeout_ms: exec.timeout_ms, - sandbox_permissions: Some(SandboxPermissions::UseDefault), - additional_permissions: None, - prefix_rule: None, - justification: None, - }; - Ok(Some(ToolCall { - tool_name: ToolName::plain("local_shell"), - call_id, - payload: ToolPayload::LocalShell { params }, - })) - } - } - } _ => Ok(None), } } diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index b4e935efb1..b154585419 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -110,7 +110,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow }, ); - let parallel_tool_name = ["shell", "local_shell", "exec_command", "shell_command"] + let parallel_tool_name = ["exec_command", "shell_command"] .into_iter() .find(|name| { router.tool_supports_parallel(&ToolCall { @@ -399,7 +399,6 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec None, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 4f6eb620aa..ad17c47596 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -62,19 +62,8 @@ pub struct ShellRequest { } /// Selects `ShellRuntime` behavior for different callers. -/// -/// Note: `Generic` is not the same as `ShellCommandClassic`. -/// `Generic` means "no `shell_command`-specific backend behavior" (used by the -/// generic `shell` tool path). The `ShellCommand*` variants are only for the -/// `shell_command` tool family. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ShellRuntimeBackend { - /// Tool-agnostic/default runtime path. - /// - /// Uses the normal `ShellRuntime` execution flow without enabling any - /// `shell_command`-specific backend selection. - #[default] - Generic, /// Legacy backend for the `shell_command` tool. /// /// Keeps `shell_command` on the standard shell runtime flow without the @@ -88,7 +77,6 @@ pub(crate) enum ShellRuntimeBackend { ShellCommandZshFork, } -#[derive(Default)] pub struct ShellRuntime { backend: ShellRuntimeBackend, } @@ -102,12 +90,6 @@ pub(crate) struct ApprovalKey { } impl ShellRuntime { - pub fn new() -> Self { - Self { - backend: ShellRuntimeBackend::Generic, - } - } - pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self { Self { backend } } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 0ac670921b..c4db408c55 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -2,7 +2,6 @@ use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; -use crate::tools::handlers::ContainerExecHandler; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; @@ -10,7 +9,6 @@ use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; -use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadMcpResourceHandler; @@ -19,7 +17,6 @@ use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; -use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UpdateGoalHandler; @@ -39,7 +36,6 @@ use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHand use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; -use crate::tools::handlers::shell_spec::ShellToolOptions; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; @@ -252,14 +248,6 @@ fn collect_handler_tools( let include_environment_id = matches!(config.environment_mode, ToolEnvironmentMode::Multiple); match &config.shell_type { - ConfigShellToolType::Default => { - handlers.push(Arc::new(ShellHandler::new(ShellToolOptions { - exec_permission_approvals_enabled, - }))); - } - ConfigShellToolType::Local => { - handlers.push(Arc::new(LocalShellHandler::new())); - } ConfigShellToolType::UnifiedExec => { handlers.push(Arc::new(ExecCommandHandler::new( ExecCommandHandlerOptions { @@ -271,7 +259,9 @@ fn collect_handler_tools( handlers.push(Arc::new(WriteStdinHandler)); } ConfigShellToolType::Disabled => {} - ConfigShellToolType::ShellCommand => { + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand => { handlers.push(Arc::new(ShellCommandHandler::new( ShellCommandHandlerOptions { backend_config: config.shell_command_backend, @@ -287,34 +277,15 @@ fn collect_handler_tools( && config.shell_type != ConfigShellToolType::Disabled { match &config.shell_type { - ConfigShellToolType::Default => { - handlers.push(Arc::new(ContainerExecHandler)); - handlers.push(Arc::new(LocalShellHandler::default())); - handlers.push(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } - ConfigShellToolType::Local => { - handlers.push(Arc::new(ShellHandler::default())); - handlers.push(Arc::new(ContainerExecHandler)); - handlers.push(Arc::new(ShellCommandHandler::from( - config.shell_command_backend, - ))); - } ConfigShellToolType::UnifiedExec => { - handlers.push(Arc::new(ShellHandler::default())); - handlers.push(Arc::new(ContainerExecHandler)); - handlers.push(Arc::new(LocalShellHandler::default())); handlers.push(Arc::new(ShellCommandHandler::from( config.shell_command_backend, ))); } - ConfigShellToolType::ShellCommand => { - handlers.push(Arc::new(ShellHandler::default())); - handlers.push(Arc::new(ContainerExecHandler)); - handlers.push(Arc::new(LocalShellHandler::default())); - } - ConfigShellToolType::Disabled => {} + ConfigShellToolType::Default + | ConfigShellToolType::Local + | ConfigShellToolType::ShellCommand + | ConfigShellToolType::Disabled => {} } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 207aa79234..044c445046 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2770,7 +2770,6 @@ fn strip_descriptions_tool(spec: &mut ToolSpec) { } } ToolSpec::Freeform(FreeformTool { .. }) - | ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => {} } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 560f87b2f0..38645e69bb 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -182,8 +182,8 @@ fn assert_contains_tool_names(tools: &[ToolSpec], expected_subset: &[&str]) { fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> { match config.shell_type { - ConfigShellToolType::Default => Some("shell"), - ConfigShellToolType::Local => Some("local_shell"), + ConfigShellToolType::Default => Some("shell_command"), + ConfigShellToolType::Local => Some("shell_command"), ConfigShellToolType::UnifiedExec => None, ConfigShellToolType::Disabled => None, ConfigShellToolType::ShellCommand => Some("shell_command"), diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index 6a0e635792..522e3cd1d8 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -111,15 +111,6 @@ fn tool_dispatch_payload(payload: &ToolPayload) -> ToolDispatchPayload { ToolPayload::Custom { input } => ToolDispatchPayload::Custom { input: input.clone(), }, - ToolPayload::LocalShell { params } => ToolDispatchPayload::LocalShell { - command: params.command.clone(), - workdir: params.workdir.clone(), - timeout_ms: params.timeout_ms, - sandbox_permissions: params.sandbox_permissions, - prefix_rule: params.prefix_rule.clone(), - additional_permissions: params.additional_permissions.clone(), - justification: params.justification.clone(), - }, } } diff --git a/codex-rs/core/src/tools/tool_search_entry.rs b/codex-rs/core/src/tools/tool_search_entry.rs index 7ecaf91a4d..8e37cdcfff 100644 --- a/codex-rs/core/src/tools/tool_search_entry.rs +++ b/codex-rs/core/src/tools/tool_search_entry.rs @@ -40,7 +40,6 @@ impl ToolSearchInfo { LoadableToolSpec::Namespace(namespace) } ToolSpec::ToolSearch { .. } - | ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } | ToolSpec::Freeform(_) => return None, diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index eab90c5aba..6c1f581f9f 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -890,10 +890,6 @@ pub fn ev_apply_patch_call( ) -> Value { match output_type { ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch), - ApplyPatchModelOutput::Shell => ev_apply_patch_shell_call(call_id, patch), - ApplyPatchModelOutput::ShellViaHeredoc => { - ev_apply_patch_shell_call_via_heredoc(call_id, patch) - } ApplyPatchModelOutput::ShellCommandViaHeredoc => { ev_apply_patch_shell_command_call_via_heredoc(call_id, patch) } @@ -925,21 +921,6 @@ pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) ev_function_call(call_id, "shell_command", &arguments) } -pub fn ev_apply_patch_shell_call(call_id: &str, patch: &str) -> Value { - let args = serde_json::json!({ "command": ["apply_patch", patch] }); - let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); - - ev_function_call(call_id, "shell", &arguments) -} - -pub fn ev_apply_patch_shell_call_via_heredoc(call_id: &str, patch: &str) -> Value { - let script = format!("apply_patch <<'EOF'\n{patch}\nEOF\n"); - let args = serde_json::json!({ "command": ["bash", "-lc", script] }); - let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); - - ev_function_call(call_id, "shell", &arguments) -} - pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value { let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") }); let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 24c25d6364..e7db3f4044 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -189,17 +189,13 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result { Box::pin(self.custom_tool_call_output(call_id)).await } - ApplyPatchModelOutput::Shell - | ApplyPatchModelOutput::ShellViaHeredoc - | ApplyPatchModelOutput::ShellCommandViaHeredoc => { + ApplyPatchModelOutput::ShellCommandViaHeredoc => { Box::pin(self.function_call_stdout(call_id)).await } } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 82113ee494..e42057215a 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -258,8 +258,6 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_cli_multiple_operations_integration( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -302,8 +300,6 @@ D delete.txt #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -329,8 +325,6 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_moves_file_to_new_directory( model_output: ApplyPatchModelOutput, @@ -357,8 +351,6 @@ async fn apply_patch_cli_moves_file_to_new_directory( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_updates_file_appends_trailing_newline( model_output: ApplyPatchModelOutput, @@ -385,8 +377,6 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_insert_only_hunk_modifies_file( model_output: ApplyPatchModelOutput, @@ -414,8 +404,6 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_move_overwrites_existing_destination( model_output: ApplyPatchModelOutput, @@ -445,8 +433,6 @@ async fn apply_patch_cli_move_overwrites_existing_destination( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( model_output: ApplyPatchModelOutput, @@ -484,8 +470,6 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_add_overwrites_existing_file( model_output: ApplyPatchModelOutput, @@ -511,8 +495,6 @@ async fn apply_patch_cli_add_overwrites_existing_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_invalid_hunk_header( model_output: ApplyPatchModelOutput, @@ -542,8 +524,6 @@ async fn apply_patch_cli_rejects_invalid_hunk_header( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_reports_missing_context( model_output: ApplyPatchModelOutput, @@ -577,8 +557,6 @@ async fn apply_patch_cli_reports_missing_context( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_reports_missing_target_file( model_output: ApplyPatchModelOutput, @@ -612,8 +590,6 @@ async fn apply_patch_cli_reports_missing_target_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_delete_missing_file_reports_error( model_output: ApplyPatchModelOutput, @@ -648,8 +624,6 @@ async fn apply_patch_cli_delete_missing_file_reports_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -672,8 +646,6 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_delete_directory_reports_verification_error( model_output: ApplyPatchModelOutput, @@ -698,8 +670,6 @@ async fn apply_patch_cli_delete_directory_reports_verification_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_path_traversal_outside_workspace( model_output: ApplyPatchModelOutput, @@ -744,8 +714,6 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn intercepted_apply_patch_verification_uses_local_sandbox( model_output: ApplyPatchModelOutput, @@ -797,8 +765,6 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace( model_output: ApplyPatchModelOutput, @@ -868,8 +834,6 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform ; "freeform")] -#[test_case(ApplyPatchModelOutput::Shell ; "shell")] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")] async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( model_output: ApplyPatchModelOutput, @@ -972,8 +936,6 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( model_output: ApplyPatchModelOutput, @@ -1021,8 +983,6 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_verification_failure_has_no_side_effects( model_output: ApplyPatchModelOutput, @@ -1535,7 +1495,6 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( model_output: ApplyPatchModelOutput, @@ -1558,8 +1517,6 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -1579,8 +1536,6 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_cli_missing_second_chunk_context_rejected( model_output: ApplyPatchModelOutput, @@ -1615,8 +1570,6 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_emits_turn_diff_event_with_unified_diff( model_output: ApplyPatchModelOutput, @@ -1847,8 +1800,6 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] async fn apply_patch_change_context_disambiguates_target( model_output: ApplyPatchModelOutput, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b1620ee36b..68ddf1691b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -26,7 +26,6 @@ use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; use core_test_support::hooks::trust_discovered_hooks; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; @@ -78,6 +77,14 @@ const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; +fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { + ev_function_call( + call_id, + "shell_command", + &json!({ "command": command }).to_string(), + ) +} + fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: String) -> Op { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -954,7 +961,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // first chunk of work let model_reasoning_response_1_sse = sse(vec![ reasoning_response_1.clone(), - ev_local_shell_call("r1-shell", "completed", vec!["echo", "make-react"]), + ev_shell_command_call("r1-shell", "echo make-react"), ev_completed_with_tokens("r1", token_count_used), ]); @@ -972,7 +979,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // second chunk of work let model_reasoning_response_2_sse = sse(vec![ reasoning_response_2.clone(), - ev_local_shell_call("r3-shell", "completed", vec!["echo", "make-node"]), + ev_shell_command_call("r3-shell", "echo make-node"), ev_completed_with_tokens("r3", token_count_used), ]); @@ -990,7 +997,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // third chunk of work let model_reasoning_response_3_sse = sse(vec![ ev_reasoning_item("m6", &["I will create a python app"], &[]), - ev_local_shell_call("r6-shell", "completed", vec!["echo", "make-python"]), + ev_shell_command_call("r6-shell", "echo make-python"), ev_completed_with_tokens("r6", token_count_used), ]); @@ -1186,20 +1193,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-react" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-react\"}", "call_id": "r1-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r1-shell", @@ -1296,20 +1293,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-node" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-node\"}", "call_id": "r3-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r3-shell", @@ -1406,20 +1393,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "action": { - "command": [ - "echo", - "make-python" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, + "arguments": "{\"command\":\"echo make-python\"}", "call_id": "r6-shell", - "status": "completed", - "type": "local_shell_call" + "name": "shell_command", + "type": "function_call" }, { "call_id": "r6-shell", diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 88aafd26ac..8849d80773 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -479,10 +479,9 @@ async fn assert_remote_manual_compact_request_parity( responses::ev_completed("turn-three-final-response"), ]), responses::sse(vec![ - responses::ev_local_shell_call( - "turn-four-local-shell", - "completed", - vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"], + responses::ev_shell_command_call( + "turn-four-shell-command", + "echo TURN_FOUR_LOCAL_SHELL", ), responses::ev_completed("turn-four-local-shell-response"), ]), @@ -589,7 +588,7 @@ async fn assert_remote_manual_compact_request_parity( assert_eq!( response_requests.len(), 7, - "expected five turns with one unsupported tool continuation and one local shell continuation" + "expected five turns with one unsupported tool continuation and one shell command continuation" ); assert_eq!( compact_mock.requests().len(), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 563719cec2..635aeaf488 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2135,48 +2135,25 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> #[derive(Clone, Copy)] enum BashRewriteSurface { - ContainerExec, ExecCommand, - LocalShell, - Shell, ShellCommand, } impl BashRewriteSurface { fn slug(self) -> &'static str { match self { - BashRewriteSurface::ContainerExec => "container-exec", BashRewriteSurface::ExecCommand => "exec-command", - BashRewriteSurface::LocalShell => "local-shell", - BashRewriteSurface::Shell => "shell", BashRewriteSurface::ShellCommand => "shell-command", } } - fn tool_call(self, call_id: &str, command: &[String], command_text: &str) -> Result { + fn tool_call(self, call_id: &str, command_text: &str) -> Result { match self { - BashRewriteSurface::ContainerExec => Ok(ev_function_call( - call_id, - "container.exec", - &serde_json::to_string(&serde_json::json!({ "command": command }))?, - )), BashRewriteSurface::ExecCommand => Ok(ev_function_call( call_id, "exec_command", &serde_json::to_string(&serde_json::json!({ "cmd": command_text }))?, )), - BashRewriteSurface::LocalShell => { - Ok(core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - )) - } - BashRewriteSurface::Shell => Ok(ev_function_call( - call_id, - "shell", - &serde_json::to_string(&serde_json::json!({ "command": command }))?, - )), BashRewriteSurface::ShellCommand => Ok(ev_function_call( call_id, "shell_command", @@ -2185,33 +2162,19 @@ impl BashRewriteSurface { } } - fn original_command(self, marker: &Path) -> (Vec, String) { - let command_text = format!("printf original > {}", marker.display()); + fn original_command(self, marker: &Path) -> String { match self { - BashRewriteSurface::ContainerExec - | BashRewriteSurface::LocalShell - | BashRewriteSurface::Shell => { - let command = vec!["/bin/sh".to_string(), "-c".to_string(), command_text]; - let command_text = codex_shell_command::parse_command::shlex_join(&command); - (command, command_text) - } BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { - (Vec::new(), command_text) + format!("printf original > {}", marker.display()) } } } fn rewritten_command(self, marker: &Path) -> String { - let command_text = format!("printf rewritten > {}", marker.display()); match self { - BashRewriteSurface::ContainerExec - | BashRewriteSurface::LocalShell - | BashRewriteSurface::Shell => codex_shell_command::parse_command::shlex_join(&[ - "/bin/sh".to_string(), - "-c".to_string(), - command_text, - ]), - BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => command_text, + BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { + format!("printf rewritten > {}", marker.display()) + } } } @@ -2234,14 +2197,14 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) let call_id = format!("pretooluse-{slug}-rewrite"); let original_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-original-marker")); let rewritten_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-rewritten-marker")); - let (tool_command, original_command) = surface.original_command(&original_marker); + let original_command = surface.original_command(&original_marker); let rewritten_command = surface.rewritten_command(&rewritten_marker); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - surface.tool_call(&call_id, &tool_command, &original_command)?, + surface.tool_call(&call_id, &original_command)?, ev_completed("resp-1"), ]), sse(vec![ @@ -2295,21 +2258,6 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) Ok(()) } -#[tokio::test] -async fn pre_tool_use_rewrites_shell_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::Shell).await -} - -#[tokio::test] -async fn pre_tool_use_rewrites_container_exec_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ContainerExec).await -} - -#[tokio::test] -async fn pre_tool_use_rewrites_local_shell_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::LocalShell).await -} - #[tokio::test] async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> { assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ShellCommand).await @@ -2745,95 +2693,6 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> { Ok(()) } -#[tokio::test] -async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "pretooluse-local-shell"; - let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - format!("printf blocked > {}", marker.display()), - ]; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "local shell blocked"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let mut builder = test_codex() - .with_pre_build_hook(|home| { - if let Err(error) = - write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") - { - panic!("failed to write pre tool use hook test fixture: {error}"); - } - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - - if marker.exists() { - fs::remove_file(&marker).context("remove leftover local shell marker")?; - } - - test.submit_turn("run the blocked local shell command") - .await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - let output_item = requests[1].function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("local shell output string"); - assert!( - output.contains("Command blocked by PreToolUse hook: blocked local shell"), - "blocked local shell output should surface the hook reason", - ); - assert!( - output.contains(&format!( - "Command: {}", - codex_shell_command::parse_command::shlex_join(&command) - )), - "blocked local shell output should surface the blocked command", - ); - assert!( - !marker.exists(), - "blocked local shell command should not execute" - ); - - let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; - assert_eq!(hook_inputs.len(), 1); - assert_eq!( - hook_inputs[0]["tool_input"]["command"], - codex_shell_command::parse_command::shlex_join(&command), - ); - assert!( - hook_inputs[0]["turn_id"] - .as_str() - .is_some_and(|turn_id| !turn_id.is_empty()) - ); - - Ok(()) -} - #[tokio::test] async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3424,75 +3283,6 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re Ok(()) } -#[tokio::test] -async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let call_id = "posttooluse-local-shell"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "printf local-post-tool-output".to_string(), - ]; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - core_test_support::responses::ev_local_shell_call( - call_id, - "completed", - command.iter().map(String::as_str).collect(), - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "local shell post hook context observed"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let post_context = "Remember the local shell post-tool note."; - let mut builder = test_codex() - .with_pre_build_hook(|home| { - if let Err(error) = - write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context) - { - panic!("failed to write post tool use hook test fixture: {error}"); - } - }) - .with_config(trust_discovered_hooks); - let test = builder.build(&server).await?; - - test.submit_turn("run the local shell command with post hook") - .await?; - - let requests = responses.requests(); - assert_eq!(requests.len(), 2); - assert!( - requests[1] - .message_input_texts("developer") - .contains(&post_context.to_string()), - "follow-up request should include local shell post tool use additional context", - ); - let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; - assert_eq!(hook_inputs.len(), 1); - assert_eq!( - hook_inputs[0]["tool_input"]["command"], - codex_shell_command::parse_command::shlex_join(&command), - ); - assert_eq!( - hook_inputs[0]["tool_response"], - Value::String("local-post-tool-output".to_string()), - ); - - Ok(()) -} - #[tokio::test] async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()> { diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index a9aa843ad1..346c503c25 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -15,8 +15,8 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::skip_if_no_network; @@ -32,7 +32,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch const ETAG_1: &str = "\"models-etag-1\""; const ETAG_2: &str = "\"models-etag-2\""; - const CALL_ID: &str = "local-shell-call-1"; + const CALL_ID: &str = "shell-command-call-1"; let server = MockServer::start().await; @@ -81,7 +81,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch // It also includes a mismatched X-Models-Etag, which should trigger a /models refresh. let first_response_body = sse(vec![ ev_response_created("resp-1"), - ev_local_shell_call(CALL_ID, "completed", vec!["/bin/echo", "etag ok"]), + ev_shell_command_call(CALL_ID, "/bin/echo 'etag ok'"), ev_completed("resp-1"), ]); responses::mount_response_once( diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index eb05b8a645..03692ac547 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -11,7 +11,6 @@ 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_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_reasoning_item; @@ -72,6 +71,19 @@ fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> { Ok(()) } +fn shell_command_call(call_id: &str, command: &str) -> serde_json::Value { + let args = serde_json::json!({ "command": command }).to_string(); + ev_function_call(call_id, "shell_command", &args) +} + +fn touch_command(path: &str) -> String { + if cfg!(windows) { + format!("New-Item -ItemType File -Path {path} -Force | Out-Null") + } else { + format!("/usr/bin/touch {path}") + } +} + #[test] fn extract_log_field_handles_empty_bare_values() { let line = "event.name=\"codex.tool_result\" mcp_server= mcp_server_origin="; @@ -996,23 +1008,13 @@ async fn handle_response_item_records_tool_result_for_function_call() { #[tokio::test] #[traced_test] -async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() { +async fn handle_response_item_records_tool_result_for_shell_command_call() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "local_shell_call", - "status": "completed", - "action": { - "type": "exec", - "command": vec!["/bin/echo", "hello"], - } - } - }), + shell_command_call("shell-call", "echo shell"), ev_completed("done"), ]), ) @@ -1021,76 +1023,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), - ev_completed("done"), - ]), - ) - .await; - - let TestCodex { codex, .. } = test_codex() - .with_config(move |config| { - config - .features - .disable(Feature::GhostCommit) - .expect("test config should allow feature update"); - }) - .build(&server) - .await - .unwrap(); - - codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - }) - .await - .unwrap(); - - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; - - logs_assert(|lines: &[&str]| { - let line = lines - .iter() - .find(|line| { - line.contains("codex.tool_result") - && line.contains(&"tool_name=local_shell".to_string()) - && line.contains("output=LocalShellCall without call_id or id") - }) - .ok_or_else(|| "missing codex.tool_result event".to_string())?; - - if !line.contains("success=false") { - return Err("missing success field".to_string()); - } - assert_empty_mcp_tool_fields(line)?; - - Ok(()) - }); -} - -#[cfg(target_os = "macos")] -#[tokio::test] -#[traced_test] -async fn handle_response_item_records_tool_result_for_local_shell_call() { - let server = start_mock_server().await; - - mount_sse_once( - &server, - sse(vec![ - ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]), - ev_completed("done"), - ]), - ) - .await; - - mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1129,10 +1062,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .find(|line| line.contains("codex.tool_result") && line.contains("call_id=shell-call")) .ok_or_else(|| "missing codex.tool_result event".to_string())?; - if !line.contains("tool_name=local_shell") { + if !line.contains("tool_name=shell_command") { return Err("missing tool_name field".to_string()); } - if !line.contains("arguments=/bin/echo shell") { + if !line.contains("arguments={\"command\":\"echo shell\"}") { return Err("missing arguments field".to_string()); } let output_idx = line @@ -1168,8 +1101,8 @@ fn tool_decision_assertion<'a>( .ok_or_else(|| format!("missing codex.tool_decision event for {call_id}"))?; let lower = line.to_lowercase(); - if !lower.contains("tool_name=local_shell") { - return Err("missing tool_name for local_shell".to_string()); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); } if !lower.contains(&format!("decision={expected_decision}")) { return Err(format!("unexpected decision for {call_id}")); @@ -1184,16 +1117,12 @@ fn tool_decision_assertion<'a>( #[tokio::test] #[traced_test] -async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { +async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "auto_config_call", - "completed", - vec!["/bin/echo", "local shell"], - ), + shell_command_call("auto_config_call", "echo local shell"), ev_completed("done"), ]), ) @@ -1202,7 +1131,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1244,16 +1173,13 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_approved_records_tool_decision() { +async fn handle_shell_command_user_approved_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_approved_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_approved_call", &command), ev_completed("done"), ]), ) @@ -1262,7 +1188,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1316,17 +1242,14 @@ async fn handle_container_exec_user_approved_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_approved_for_session_records_tool_decision() { +async fn handle_shell_command_user_approved_for_session_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_approved_session_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_approved_session_call", &command), ev_completed("done"), ]), ) @@ -1334,7 +1257,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1390,15 +1313,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() #[traced_test] async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_retry_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_retry_call", &command), ev_completed("done"), ]), ) @@ -1406,7 +1326,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1460,17 +1380,14 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_container_exec_user_denies_records_tool_decision() { +async fn handle_shell_command_user_denies_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "user_denied_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("user_denied_call", &command), ev_completed("done"), ]), ) @@ -1479,7 +1396,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1534,15 +1451,12 @@ async fn handle_container_exec_user_denies_records_tool_decision() { #[traced_test] async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_session_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_session_call", &command), ev_completed("done"), ]), ) @@ -1550,7 +1464,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) @@ -1606,15 +1520,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() #[traced_test] async fn handle_sandbox_error_user_denies_records_tool_decision() { let server = start_mock_server().await; + let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - ev_local_shell_call( - "sandbox_deny_call", - "completed", - vec!["/usr/bin/touch", "codex-otel-approval-test"], - ), + shell_command_call("sandbox_deny_call", &command), ev_completed("done"), ]), ) @@ -1623,7 +1534,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_assistant_message("msg-1", "local shell done"), + ev_assistant_message("msg-1", "shell command done"), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 01b1563a6b..56afa59b73 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -7,7 +7,6 @@ use core_test_support::assert_regex_match; 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_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; @@ -67,34 +66,6 @@ fn shell_responses( ]), ]) } - ShellModelOutput::Shell => { - let parameters = json!({ - "command": command, - "timeout_ms": 2_000, - }); - Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(¶meters)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]) - } - ShellModelOutput::LocalShell => Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_local_shell_call(call_id, "completed", command), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]), } } @@ -103,12 +74,8 @@ fn configure_shell_model( output_type: ShellModelOutput, include_apply_patch_tool: bool, ) -> TestCodexBuilder { - let builder = match (output_type, include_apply_patch_tool) { - (ShellModelOutput::ShellCommand, _) => builder.with_model("test-gpt-5-codex"), - (ShellModelOutput::LocalShell, true) => builder.with_model("gpt-5.4"), - (ShellModelOutput::Shell, true) => builder.with_model("gpt-5.4"), - (ShellModelOutput::LocalShell, false) => builder.with_model("test-local-shell-json"), - (ShellModelOutput::Shell, false) => builder.with_model("test-shell-json"), + let builder = match output_type { + ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), }; builder.with_config(move |config| { @@ -117,64 +84,7 @@ fn configure_shell_model( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_stays_json_without_freeform_apply_patch( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ false, - ); - let test = builder.build(&server).await?; - - let call_id = "shell-json"; - let responses = shell_responses(call_id, vec!["/bin/echo", "shell json"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the json shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let mut parsed: Value = serde_json::from_str(output)?; - if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { - let _ = metadata.remove("duration_seconds"); - } - - assert_eq!( - parsed - .get("metadata") - .and_then(|metadata| metadata.get("exit_code")) - .and_then(Value::as_i64), - Some(0), - "expected zero exit code in unformatted JSON output", - ); - let stdout = parsed - .get("output") - .and_then(Value::as_str) - .unwrap_or_default(); - assert_regex_match(r"(?s)^shell json\n?$", stdout); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_is_structured_with_freeform_apply_patch( output_type: ShellModelOutput, ) -> Result<()> { @@ -222,76 +132,7 @@ freeform shell } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_preserves_fixture_json_without_serialization( - output_type: ShellModelOutput, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ false, - ); - let test = builder.build(&server).await?; - - let fixture_path = test.cwd.path().join("fixture.json"); - fs::write(&fixture_path, FIXTURE_JSON)?; - let fixture_path_str = fixture_path.to_string_lossy().to_string(); - - let call_id = "shell-json-fixture"; - let responses = shell_responses( - call_id, - vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()], - output_type, - )?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "read the fixture JSON with sed", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let mut parsed: Value = serde_json::from_str(output)?; - if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { - let _ = metadata.remove("duration_seconds"); - } - - assert_eq!( - parsed - .get("metadata") - .and_then(|metadata| metadata.get("exit_code")) - .and_then(Value::as_i64), - Some(0), - "expected zero exit code when serialization is disabled", - ); - let stdout = parsed - .get("output") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - assert_eq!( - stdout, FIXTURE_JSON, - "expected shell output to match the fixture contents" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_structures_fixture_with_serialization( output_type: ShellModelOutput, ) -> Result<()> { @@ -352,9 +193,7 @@ async fn shell_output_structures_fixture_with_serialization( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_for_freeform_tool_records_duration( output_type: ShellModelOutput, ) -> Result<()> { @@ -408,72 +247,8 @@ $"#; Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] -#[test_case(ShellModelOutput::LocalShell)] -async fn shell_output_reserializes_truncated_content(output_type: ShellModelOutput) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ) - .with_config(move |config| { - config.tool_output_token_limit = Some(200); - }); - let test = builder.build(&server).await?; - - let call_id = "shell-truncated"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "seq 1 400"], output_type)?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the truncation shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("truncated output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("truncated output string"); - - assert!( - serde_json::from_str::(output).is_err(), - "expected truncated shell output to be plain text", - ); - let truncated_pattern = r#"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: 400 -Output: -1 -2 -3 -4 -5 -6 -.*…46 tokens truncated….* -396 -397 -398 -399 -400 -$"#; - assert_regex_match(truncated_pattern, output); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_output_is_structured( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -517,8 +292,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_creates_file( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -564,8 +337,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_updates_existing_file( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -616,8 +387,6 @@ M {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_reports_failure_output( output_type: ApplyPatchModelOutput, ) -> Result<()> { @@ -660,8 +429,6 @@ async fn apply_patch_custom_tool_call_reports_failure_output( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Shell)] -#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -702,9 +469,7 @@ A {file_name} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(ShellModelOutput::Shell)] #[test_case(ShellModelOutput::ShellCommand)] -#[test_case(ShellModelOutput::LocalShell)] async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); @@ -897,52 +662,3 @@ 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_model("gpt-5.4").with_config(|config| { - 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"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the local shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("local shell output request recorded"); - let output_item = req.function_call_output(call_id); - 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/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap index 724efd706a..d6bd5a7b22 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__pending_input__pending_input_user_input_no_preempt_after_reasoning.snap @@ -16,5 +16,5 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot 03:reasoning:summary=thinking:encrypted=true 04:function_call/shell 05:message/assistant:first answer -06:function_call_output:failed to parse function arguments: invalid type: string "echo preserved tool call", expected a sequence at line 1 column 37 +06:function_call_output:unsupported call: shell 07:message/user:second prompt diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a2017c99b3..ac41d06c3f 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -18,7 +18,6 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -66,12 +65,12 @@ fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option< } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> { +async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-local-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let TestCodex { codex, cwd, @@ -79,11 +78,15 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> .. } = builder.build(&server).await?; - let call_id = "shell-tool-call"; - let command = vec!["/bin/echo", "tool harness"]; + let call_id = "shell-command-tool-call"; + let command_args = json!({ + "command": "echo tool harness", + "login": false, + }) + .to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_local_shell_call(call_id, "completed", command), + ev_function_call(call_id, "shell_command", &command_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 737ea122d4..0f46e50386 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -182,24 +182,26 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { +async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let command = ["/bin/echo", "shell ok"]; - let call_id_blocked = "shell-blocked"; - let call_id_success = "shell-success"; + let command = "echo shell ok"; + let call_id_blocked = "shell-command-blocked"; + let call_id_success = "shell-command-success"; let first_args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, }); @@ -209,7 +211,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-1"), ev_function_call( call_id_blocked, - "shell", + "shell_command", &serde_json::to_string(&first_args)?, ), ev_completed("resp-1"), @@ -222,7 +224,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-2"), ev_function_call( call_id_success, - "shell", + "shell_command", &serde_json::to_string(&second_args)?, ), ev_completed("resp-2"), @@ -239,7 +241,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { .await; test.submit_turn_with_approval_and_permission_profile( - "run the shell command", + "run the shell_command script", AskForApproval::Never, PermissionProfile::Disabled, ) @@ -274,35 +276,32 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sandbox_denied_shell_returns_original_output() -> Result<()> { +async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; - let call_id = "sandbox-denied-shell"; + let call_id = "sandbox-denied-shell-command"; let target_path = fixture.workspace_path("sandbox-denied.txt"); let sentinel = "sandbox-denied sentinel output"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - format!( - "printf {sentinel:?}; printf {content:?} > {path:?}", - sentinel = format!("{sentinel}\n"), - content = "sandbox denied", - path = &target_path - ), - ]; + let command = format!( + "printf {sentinel:?}; printf {content:?} > {path:?}", + sentinel = format!("{sentinel}\n"), + content = "sandbox denied", + path = &target_path + ); let args = json!({ "command": command, + "login": false, "timeout_ms": 5_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -367,7 +366,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_enforces_glob_deny_read_policy() -> Result<()> { +async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -403,24 +402,22 @@ async fn shell_enforces_glob_deny_read_policy() -> Result<()> { fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?; fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?; - let call_id = "shell-glob-deny-read"; - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "status=0; cat \"$1\" || status=$?; cat \"$2\"; exit \"$status\"".to_string(), - "sh".to_string(), - denied_path.to_string_lossy().into_owned(), - allowed_path.to_string_lossy().into_owned(), - ]; + let call_id = "shell-command-glob-deny-read"; + let command = format!( + "rc=0; cat {denied_path:?} || rc=$?; cat {allowed_path:?}; exit \"$rc\"", + denied_path = denied_path.to_string_lossy(), + allowed_path = allowed_path.to_string_lossy(), + ); let args = json!({ "command": command, + "login": false, "timeout_ms": 1_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -537,17 +534,18 @@ async fn unified_exec_spec_toggle_end_to_end() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { +async fn shell_command_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-shell-json"); + let mut builder = test_codex().with_model("test-gpt-5-codex"); let test = builder.build(&server).await?; - let call_id = "shell-timeout"; + let call_id = "shell-command-timeout"; let timeout_ms = 50u64; let args = json!({ - "command": ["/bin/sh", "-c", "yes line | head -n 400; sleep 1"], + "command": "yes line | head -n 400; sleep 1", + "login": false, "timeout_ms": timeout_ms, }); @@ -555,7 +553,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -622,7 +620,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> { +async fn shell_command_timeout_handles_background_grandchild_stdout() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -634,7 +632,7 @@ async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> { }); let test = builder.build(&server).await?; - let call_id = "shell-grandchild-timeout"; + let call_id = "shell-command-grandchild-timeout"; let pid_path = test.cwd.path().join("grandchild_pid.txt"); let script_path = test.cwd.path().join("spawn_detached.py"); let script = format!( @@ -651,7 +649,8 @@ time.sleep(60) fs::write(&script_path, script)?; let args = json!({ - "command": ["python3", script_path.to_string_lossy()], + "command": format!("python3 {:?}", script_path.to_string_lossy()), + "login": false, "timeout_ms": 200, }); @@ -659,7 +658,7 @@ time.sleep(60) &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -716,82 +715,3 @@ time.sleep(60) Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_spawn_failure_truncates_exec_error() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|cfg| { - cfg.permissions - .set_permission_profile(PermissionProfile::Disabled) - .expect("set permission profile"); - }); - let test = builder.build(&server).await?; - - let call_id = "shell-spawn-failure"; - let bogus_component = "missing-bin-".repeat(700); - let bogus_exe = test - .cwd - .path() - .join(bogus_component) - .to_string_lossy() - .into_owned(); - - let args = json!({ - "command": [bogus_exe], - "timeout_ms": 1_000, - }); - - mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - ) - .await; - let second_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - test.submit_turn_with_approval_and_permission_profile( - "spawn a missing binary", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - - let failure_item = second_mock.single_request().function_call_output(call_id); - - let output = failure_item - .get("output") - .and_then(Value::as_str) - .expect("spawn failure output string"); - - let spawn_error_pattern = r#"(?s)^Exit code: -?\d+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -execution error: .*$"#; - let spawn_truncated_pattern = r#"(?s)^Exit code: -?\d+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: \d+ -Output: - -execution error: .*$"#; - let spawn_error_regex = Regex::new(spawn_error_pattern)?; - let spawn_truncated_regex = Regex::new(spawn_truncated_pattern)?; - if !spawn_error_regex.is_match(output) && !spawn_truncated_regex.is_match(output) { - let fallback_pattern = r"(?s)^execution error: .*$"; - assert_regex_match(fallback_pattern, output); - } - assert!(output.len() <= 10 * 1024); - - Ok(()) -} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5a0fafad94..13a4f6e009 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1253,30 +1253,6 @@ pub struct SearchToolCallParams { pub limit: Option, } -/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` -/// or `shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -pub struct ShellToolCallParams { - pub command: Vec, - pub workdir: Option, - - /// This is the maximum time in milliseconds that the command is allowed to run. - #[serde(alias = "timeout")] - pub timeout_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub sandbox_permissions: Option, - /// Suggests a command prefix to persist for future sessions - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub prefix_rule: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub additional_permissions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub justification: Option, -} - /// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the /// `arguments` field should deserialize to this struct. #[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -2545,30 +2521,6 @@ mod tests { Ok(()) } - #[test] - fn deserialize_shell_tool_call_params() -> Result<()> { - let json = r#"{ - "command": ["ls", "-l"], - "workdir": "/tmp", - "timeout": 1000 - }"#; - - let params: ShellToolCallParams = serde_json::from_str(json)?; - assert_eq!( - ShellToolCallParams { - command: vec!["ls".to_string(), "-l".to_string()], - workdir: Some("/tmp".to_string()), - timeout_ms: Some(1000), - sandbox_permissions: None, - prefix_rule: None, - additional_permissions: None, - justification: None, - }, - params - ); - Ok(()) - } - #[test] fn wraps_image_user_input_with_tags() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index a0c2173cac..052edbac59 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -136,8 +136,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec Vec::new(), } diff --git a/codex-rs/tools/src/function_call_error.rs b/codex-rs/tools/src/function_call_error.rs index 3881c7af1b..1ab8335b25 100644 --- a/codex-rs/tools/src/function_call_error.rs +++ b/codex-rs/tools/src/function_call_error.rs @@ -5,8 +5,6 @@ use thiserror::Error; pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), - #[error("LocalShellCall without call_id or id")] - MissingLocalShellCallId, #[error("Fatal error: {0}")] Fatal(String), } diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 4639b404b1..17e0dccaae 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -203,6 +203,9 @@ impl ToolsConfig { ConfigShellToolType::UnifiedExec if !unified_exec_enabled => { ConfigShellToolType::ShellCommand } + ConfigShellToolType::Default | ConfigShellToolType::Local => { + ConfigShellToolType::ShellCommand + } other => other, }; let shell_type = if !features.enabled(Feature::ShellTool) { diff --git a/codex-rs/tools/src/tool_payload.rs b/codex-rs/tools/src/tool_payload.rs index b335e58372..71af457743 100644 --- a/codex-rs/tools/src/tool_payload.rs +++ b/codex-rs/tools/src/tool_payload.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use codex_protocol::models::SearchToolCallParams; -use codex_protocol::models::ShellToolCallParams; /// Canonical payload shapes accepted by model-visible tool runtimes. #[derive(Clone, Debug)] @@ -9,7 +8,6 @@ pub enum ToolPayload { Function { arguments: String }, ToolSearch { arguments: SearchToolCallParams }, Custom { input: String }, - LocalShell { params: ShellToolCallParams }, } impl ToolPayload { @@ -18,7 +16,6 @@ impl ToolPayload { ToolPayload::Function { arguments } => Cow::Borrowed(arguments), ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()), ToolPayload::Custom { input } => Cow::Borrowed(input), - ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")), } } } diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 2a0183a905..98f705363c 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -25,8 +25,6 @@ pub enum ToolSpec { description: String, parameters: JsonSchema, }, - #[serde(rename = "local_shell")] - LocalShell {}, #[serde(rename = "image_generation")] ImageGeneration { output_format: String }, // TODO: Understand why we get an error on web_search although the API docs @@ -58,7 +56,6 @@ impl ToolSpec { ToolSpec::Function(tool) => tool.name.as_str(), ToolSpec::Namespace(namespace) => namespace.name.as_str(), ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", ToolSpec::ImageGeneration { .. } => "image_generation", ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(tool) => tool.name.as_str(), diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 6d2017e553..a181cd247b 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -57,7 +57,6 @@ fn tool_spec_name_covers_all_variants() { .name(), "tool_search" ); - assert_eq!(ToolSpec::LocalShell {}.name(), "local_shell"); assert_eq!( ToolSpec::ImageGeneration { output_format: "png".to_string(), diff --git a/sdk/typescript/tests/abort.test.ts b/sdk/typescript/tests/abort.test.ts index 0af318272b..ca93b1e89d 100644 --- a/sdk/typescript/tests/abort.test.ts +++ b/sdk/typescript/tests/abort.test.ts @@ -127,7 +127,7 @@ describe("AbortSignal support", () => { void event; // Consume the event eventCount++; // Abort after first event - if (eventCount === 5) { + if (eventCount === 1) { controller.abort("Aborted during iteration"); } // Continue iterating - should eventually throw diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index e9d3b29146..012cbf2320 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -181,15 +181,14 @@ export function assistantMessage(text: string, itemId: string = DEFAULT_MESSAGE_ } export function shell_call(): SseEvent { - const command = ["bash", "-lc", "echo 'Hello, world!'"]; return { type: "response.output_item.done", item: { type: "function_call", call_id: `call_id${Math.random().toString(36).slice(2)}`, - name: "shell", + name: "shell_command", arguments: JSON.stringify({ - command, + command: "echo 'Hello, world!'", timeout_ms: 100, }), }, From 2b3b220605a85754316fc738335c5b90e0ab05fa Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 13 May 2026 10:32:15 -0700 Subject: [PATCH 05/52] revert: mark Feature::RemoteControl as removed (#22520) reverts: https://github.com/openai/codex/pull/22386 --- codex-rs/app-server-daemon/src/backend/pid.rs | 8 +++++++- codex-rs/app-server/src/in_process.rs | 1 + codex-rs/app-server/src/lib.rs | 12 +++++------ codex-rs/app-server/src/main.rs | 6 ------ codex-rs/app-server/src/message_processor.rs | 4 ++++ .../src/message_processor_tracing_tests.rs | 1 + .../request_processors/config_processor.rs | 20 +++++++++++++++++++ codex-rs/app-server/src/transport.rs | 1 + codex-rs/cli/src/main.rs | 18 ++++------------- codex-rs/features/src/lib.rs | 2 +- codex-rs/features/src/tests.rs | 4 ++-- 11 files changed, 47 insertions(+), 30 deletions(-) diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 7f8aa1660d..64228d9c9b 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -349,7 +349,13 @@ impl PidBackend { match self.command_kind { PidCommandKind::AppServer { remote_control_enabled: true, - } => vec!["app-server", "--remote-control", "--listen", "unix://"], + } => vec![ + "--enable", + "remote_control", + "app-server", + "--listen", + "unix://", + ], PidCommandKind::AppServer { remote_control_enabled: false, } => vec!["app-server", "--listen", "unix://"], diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d4fc50fea7..c75c2d5ad1 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -433,6 +433,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult Self { Self { plugin_startup_tasks: PluginStartupTasks::Start, - remote_control_enabled: false, } } } @@ -682,15 +681,15 @@ pub async fn run_main_with_transport_options( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; - let remote_control_requested = runtime_options.remote_control_enabled; - let remote_control_enabled = remote_control_requested && state_db.is_some(); - if remote_control_requested && state_db.is_none() { + let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); + let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); + if remote_control_config_enabled && state_db.is_none() { error!("remote control disabled because sqlite state db is unavailable"); } if transport_accept_handles.is_empty() && !remote_control_enabled { return Err(std::io::Error::new( ErrorKind::InvalidInput, - if remote_control_requested && state_db.is_none() { + if remote_control_config_enabled && state_db.is_none() { "no transport configured; remote control disabled because sqlite state db is unavailable" } else { "no transport configured; use --listen or enable remote control" @@ -794,6 +793,7 @@ pub async fn run_main_with_transport_options( auth_manager, installation_id, rpc_transport: analytics_rpc_transport(&transport), + remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, })); let mut thread_created_rx = processor.thread_created_receiver(); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 6f5ecabf90..af30db8cf0 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -48,10 +48,6 @@ struct AppServerArgs { #[cfg(debug_assertions)] #[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)] disable_plugin_startup_tasks_for_tests: bool, - - /// Enable remote control for this app-server process. - #[arg(long = "remote-control", hide = true)] - remote_control: bool, } fn main() -> anyhow::Result<()> { @@ -63,7 +59,6 @@ fn main() -> anyhow::Result<()> { strict_config, #[cfg(debug_assertions)] disable_plugin_startup_tasks_for_tests, - remote_control, } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() @@ -79,7 +74,6 @@ fn main() -> anyhow::Result<()> { if disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } - runtime_options.remote_control_enabled = remote_control; run_main_with_transport_options( arg0_paths, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 86a3de09e9..a4eb9b6c9d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -42,6 +42,7 @@ use crate::skills_watcher::SkillsWatcher; use crate::thread_state::ConnectionCapabilities; use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; +use crate::transport::RemoteControlHandle; use async_trait::async_trait; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; @@ -264,6 +265,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, } @@ -286,6 +288,7 @@ impl MessageProcessor { auth_manager, installation_id, rpc_transport, + remote_control_handle, plugin_startup_tasks, } = args; auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { @@ -443,6 +446,7 @@ impl MessageProcessor { auth_manager, thread_manager.clone(), analytics_events_client, + remote_control_handle, ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( outgoing.clone(), diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 3daeeeb5c9..c955d06ba2 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -265,6 +265,7 @@ async fn build_test_processor( auth_manager, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, + remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b076..1533728e9f 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -6,6 +6,7 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use crate::transport::RemoteControlHandle; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; @@ -38,6 +39,7 @@ use codex_config::MatcherGroup as CoreMatcherGroup; use codex_config::ResidencyRequirement as CoreResidencyRequirement; use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; +use codex_features::Feature; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; use codex_login::AuthManager; @@ -65,6 +67,7 @@ pub(crate) struct ConfigRequestProcessor { auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, + remote_control_handle: Option, } impl ConfigRequestProcessor { @@ -74,6 +77,7 @@ impl ConfigRequestProcessor { auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, + remote_control_handle: Option, ) -> Self { Self { outgoing, @@ -81,6 +85,7 @@ impl ConfigRequestProcessor { auth_manager, thread_manager, analytics_events_client, + remote_control_handle, } } @@ -182,6 +187,21 @@ impl ConfigRequestProcessor { pub(crate) async fn handle_config_mutation(&self) { self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); + let Some(remote_control_handle) = &self.remote_control_handle else { + return; + }; + + match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => { + remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl)); + } + Err(error) => { + tracing::warn!( + "failed to load config for remote control enablement refresh after config mutation: {}", + error.message + ); + } + } } async fn handle_config_mutation_result( diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3d18cf321b..4eae17e469 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -18,6 +18,7 @@ pub(crate) use codex_app_server_transport::ConnectionId; pub(crate) use codex_app_server_transport::ConnectionOrigin; pub(crate) use codex_app_server_transport::OutgoingMessage; pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; +pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub(crate) use codex_app_server_transport::TransportEvent; pub use codex_app_server_transport::app_server_control_socket_path; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index df3a9ab44e..ef54aa52cd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -449,10 +449,6 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, - /// Enable remote control for this app-server process. - #[arg(long = "remote-control", hide = true)] - remote_control: bool, - /// Controls whether analytics are enabled by default. /// /// Analytics are disabled by default for app-server. Users have to explicitly opt in @@ -531,10 +527,10 @@ enum AppServerDaemonSubcommand { /// Restart the local app server daemon. Restart, - /// Enable remote control for future starts and a currently running managed daemon. + /// Enable remote_control for future starts and a currently running managed daemon. EnableRemoteControl, - /// Disable remote control for future starts and a currently running managed daemon. + /// Disable remote_control for future starts and a currently running managed daemon. DisableRemoteControl, /// Stop the local app server daemon. @@ -557,7 +553,7 @@ struct AppServerProxyCommand { #[derive(Debug, Args)] struct AppServerBootstrapCommand { - /// Launch the managed app-server with remote control enabled. + /// Launch the managed app-server with remote_control enabled. #[arg(long = "remote-control")] remote_control: bool, } @@ -929,7 +925,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, strict_config: app_server_strict_config, listen, - remote_control, analytics_default_enabled, auth, } = app_server_cli; @@ -944,10 +939,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { None => { let transport = listen; let auth = auth.try_into_settings()?; - let runtime_options = codex_app_server::AppServerRuntimeOptions { - remote_control_enabled: remote_control, - ..Default::default() - }; codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, @@ -957,7 +948,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { transport, codex_protocol::protocol::SessionSource::VSCode, auth, - runtime_options, + codex_app_server::AppServerRuntimeOptions::default(), ) .await?; } @@ -2560,7 +2551,6 @@ mod tests { fn app_server_analytics_default_disabled_without_flag() { let app_server = app_server_from_args(["codex", "app-server"].as_ref()); assert!(!app_server.analytics_default_enabled); - assert!(!app_server.remote_control); assert_eq!( app_server.listen, codex_app_server::AppServerTransport::Stdio diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index e019178c21..75535cbaaa 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -1126,7 +1126,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RemoteControl, key: "remote_control", - stage: Stage::Removed, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 073c2064eb..a635ca0740 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -287,8 +287,8 @@ fn auth_elicitation_is_under_development() { } #[test] -fn remote_control_is_removed_and_disabled_by_default() { - assert_eq!(Feature::RemoteControl.stage(), Stage::Removed); +fn remote_control_is_under_development() { + assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); assert_eq!(Feature::RemoteControl.default_enabled(), false); } From 157fffc6a45de6efe47c10733729a81667f3366b Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 13 May 2026 10:41:45 -0700 Subject: [PATCH 06/52] Revert "Scope macOS signing secrets to release environment" (#22513) Reverts openai/codex#22443 --- .github/workflows/rust-release.yml | 76 ++++++++++-------------------- 1 file changed, 25 insertions(+), 51 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f9c4b4655c..6dd751b7f6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -69,6 +69,30 @@ jobs: fail-fast: false matrix: include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -95,7 +119,7 @@ jobs: binaries: "codex-app-server" build_dmg: "false" - steps: &rust_release_build_steps + steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -478,55 +502,6 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* - build-macos: - needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - environment: macos-signing - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - env: - # Preserve the LTO setting from the original combined release build matrix. - # Linux ARM is why this currently resolves to thin for every target. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: *rust_release_build_steps - build-windows: needs: tag-check uses: ./.github/workflows/rust-release-windows.yml @@ -549,7 +524,6 @@ jobs: release: needs: - build - - build-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets From fc26af377fc64cc42ff0709a9fcebefb4f5b6b80 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 13 May 2026 19:49:47 +0200 Subject: [PATCH 07/52] feat: expose multi-agent v2 as model-only tools (#22514) ## Why `code_mode_only` filters code-mode nested tools out of the top-level tool list. For multi-agent v2, we need a rollout shape where the collaboration tools remain callable as normal model tools without also being embedded into the code-mode `exec` tool declaration. Related to this: https://openai-corpws.slack.com/archives/C0AQLHB4U75/p1778660267922549 ## What Changed - Adds `features.multi_agent_v2.non_code_mode_only`, including config resolution, profile override handling, and generated schema coverage. - Introduces `ToolExposure::DirectModelOnly` so a tool can be included in the initial model-visible list while staying out of the nested code-mode tool surface. - Applies that exposure to the multi-agent v2 tools when the new flag is set: `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `close_agent`, and `list_agents`. - Updates code-mode-only filtering so direct-model-only tools remain visible while ordinary nested code-mode tools are still hidden. ## Verification - Added config parsing/profile tests for `non_code_mode_only`. - Added tool spec coverage for the code-mode-only multi-agent v2 exposure behavior. --- codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/config_tests.rs | 6 ++ codex-rs/core/src/config/mod.rs | 7 ++ codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 2 + codex-rs/core/src/tools/registry.rs | 79 +++++++++++++++++++++- codex-rs/core/src/tools/router.rs | 21 ++++-- codex-rs/core/src/tools/spec_plan.rs | 80 ++++++++++++++++------- codex-rs/core/src/tools/spec_tests.rs | 64 ++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 2 + codex-rs/features/src/tests.rs | 3 + codex-rs/tools/src/tool_config.rs | 11 ++++ codex-rs/tools/src/tool_executor.rs | 22 ++++++- 13 files changed, 269 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5c0100c981..faf7ec6817 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1485,6 +1485,9 @@ "minimum": 1.0, "type": "integer" }, + "non_code_mode_only": { + "type": "boolean" + }, "root_agent_usage_hint_text": { "type": "string" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b56ad5afc2..9fc8c2643f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9656,6 +9656,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, )?; @@ -9683,6 +9684,7 @@ hide_spawn_agent_metadata = true Some("Subagent guidance.") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9702,6 +9704,7 @@ usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true +non_code_mode_only = false [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 @@ -9711,6 +9714,7 @@ usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false +non_code_mode_only = true "#, )?; @@ -9736,6 +9740,7 @@ hide_spawn_agent_metadata = false Some("profile subagent hint") ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9759,6 +9764,7 @@ enabled = true assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.agent_max_threads, Some(3)); + assert!(!config.multi_agent_v2.non_code_mode_only); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b42a660e8..3b4e674446 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -846,6 +846,7 @@ pub struct MultiAgentV2Config { pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, + pub non_code_mode_only: bool, } impl Default for MultiAgentV2Config { @@ -859,6 +860,7 @@ impl Default for MultiAgentV2Config { root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, + non_code_mode_only: false, } } } @@ -1995,6 +1997,10 @@ fn resolve_multi_agent_v2_config( .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); + let non_code_mode_only = profile + .and_then(|config| config.non_code_mode_only) + .or_else(|| base.and_then(|config| config.non_code_mode_only)) + .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { max_concurrent_threads_per_session, @@ -2004,6 +2010,7 @@ fn resolve_multi_agent_v2_config( root_agent_usage_hint_text, subagent_usage_hint_text, hide_spawn_agent_metadata, + non_code_mode_only, } } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index f884a95a27..083aac8c04 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_wait_agent_min_timeout_ms( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 12a1ccb79d..7b22ddcc39 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -218,6 +218,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( config @@ -512,6 +513,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( per_turn_config diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index ad54b5fb77..85c346f48f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -263,6 +263,79 @@ where } } +pub(crate) fn override_tool_exposure( + handler: Arc, + exposure: ToolExposure, +) -> Arc { + if handler.exposure() == exposure { + return handler; + } + + Arc::new(ExposureOverride { handler, exposure }) +} + +struct ExposureOverride { + handler: Arc, + exposure: ToolExposure, +} + +impl RegisteredTool for ExposureOverride { + fn tool_name(&self) -> ToolName { + self.handler.tool_name() + } + + fn spec(&self) -> Option { + self.handler.spec() + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + self.handler.pre_tool_use_payload(invocation) + } + + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: Value, + ) -> Result { + self.handler + .with_updated_hook_input(invocation, updated_input) + } + + fn telemetry_tags<'a>( + &'a self, + invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + self.handler.telemetry_tags(invocation) + } + + fn create_diff_consumer(&self) -> Option> { + self.handler.create_diff_consumer() + } + + fn handle_any<'a>( + &'a self, + invocation: ToolInvocation, + ) -> BoxFuture<'a, Result> { + self.handler.handle_any(invocation) + } +} + pub struct ToolRegistry { handlers: HashMap>, } @@ -290,6 +363,10 @@ impl ToolRegistry { self.handlers.get(name).map(Arc::clone) } + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.handlers.get(name).map(|handler| handler.exposure()) + } + #[cfg(test)] pub(crate) fn has_handler(&self, name: &ToolName) -> bool { self.handler(name).is_some() @@ -584,7 +661,7 @@ impl ToolRegistryBuilder { } if include_spec - && handler.exposure() == ToolExposure::Direct + && handler.exposure().is_direct() && let Some(spec) = handler.spec() { self.push_spec(spec); diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index d69e41ab19..23f0207734 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolArgumentDiffConsumer; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; use crate::tools::spec::build_specs_with_discoverable_tools; use codex_extension_api::ExtensionToolExecutor; @@ -63,10 +64,7 @@ impl ToolRouter { let (specs, registry) = builder.build(); let model_visible_specs = specs .into_iter() - .filter(|spec| { - !config.code_mode_only_enabled - || !codex_code_mode::is_code_mode_nested_tool(spec.name()) - }) + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); Self { @@ -173,6 +171,21 @@ impl ToolRouter { } } +fn is_hidden_by_code_mode_only( + config: &ToolsConfig, + registry: &ToolRegistry, + spec: &ToolSpec, +) -> bool { + if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { + return false; + } + + let exposure = registry + .tool_exposure(&ToolName::plain(spec.name())) + .unwrap_or(ToolExposure::Direct); + exposure != ToolExposure::DirectModelOnly +} + pub(crate) fn extension_tool_executors(session: &Session) -> Vec> { session .services diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c4db408c55..3d37d58b1b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -43,6 +43,7 @@ use crate::tools::hosted_spec::create_web_search_tool; use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::override_tool_exposure; use crate::tools::spec_plan_types::ToolRegistryBuildParams; use crate::tools::spec_plan_types::agent_type_description; use codex_extension_api::ExtensionToolExecutor; @@ -79,9 +80,9 @@ pub fn build_tool_registry_builder( let mut deferred_search_infos = Vec::new(); for handler in &handlers { match handler.exposure() { - ToolExposure::Direct => { + ToolExposure::Direct | ToolExposure::DirectModelOnly => { if let Some(spec) = handler.spec() { - non_deferred_specs.push(spec); + non_deferred_specs.push((spec, handler.exposure())); } } ToolExposure::Deferred => { @@ -97,21 +98,27 @@ pub fn build_tool_registry_builder( web_search_config: config.web_search_config.as_ref(), web_search_tool_type: config.web_search_tool_type, }) { - non_deferred_specs.push(web_search_tool); + non_deferred_specs.push((web_search_tool, ToolExposure::Direct)); } if config.image_gen_tool { - non_deferred_specs.push(create_image_generation_tool("png")); + non_deferred_specs.push((create_image_generation_tool("png"), ToolExposure::Direct)); } + let non_deferred_specs = non_deferred_specs + .into_iter() + .map(|(spec, exposure)| { + if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly { + codex_tools::augment_tool_spec_for_code_mode(spec) + } else { + spec + } + }) + .collect(); + for spec in merge_into_namespaces(non_deferred_specs) { if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) { continue; } - let spec = if config.code_mode_enabled { - codex_tools::augment_tool_spec_for_code_mode(spec) - } else { - spec - }; builder.push_spec(spec); } @@ -142,7 +149,14 @@ fn build_code_mode_handlers( let mut code_mode_nested_tool_specs = handlers .iter() - .filter_map(|handler| handler.spec()) + .filter_map(|handler| { + if handler.exposure() == ToolExposure::DirectModelOnly { + return None; + } + + let spec = handler.spec()?; + Some(spec) + }) .collect::>(); code_mode_nested_tool_specs.extend( extension_tool_executors @@ -344,23 +358,32 @@ fn collect_handler_tools( if config.collab_tools { if config.multi_agent_v2 { + let exposure = if config.multi_agent_v2_non_code_mode_only { + ToolExposure::DirectModelOnly + } else { + ToolExposure::Direct + }; let agent_type_description = agent_type_description(config, params.default_agent_type_description); - handlers.push(Arc::new(SpawnAgentHandlerV2::new(SpawnAgentToolOptions { - available_models: config.available_models.clone(), - agent_type_description, - hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, - include_usage_hint: config.spawn_agent_usage_hint, - usage_hint_text: config.spawn_agent_usage_hint_text.clone(), - max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, - }))); - handlers.push(Arc::new(SendMessageHandlerV2)); - handlers.push(Arc::new(FollowupTaskHandlerV2)); - handlers.push(Arc::new(WaitAgentHandlerV2::new( - params.wait_agent_timeouts, - ))); - handlers.push(Arc::new(CloseAgentHandlerV2)); - handlers.push(Arc::new(ListAgentsHandlerV2)); + handlers.push(multi_agent_v2_handler( + SpawnAgentHandlerV2::new(SpawnAgentToolOptions { + available_models: config.available_models.clone(), + agent_type_description, + hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata, + include_usage_hint: config.spawn_agent_usage_hint, + usage_hint_text: config.spawn_agent_usage_hint_text.clone(), + max_concurrent_threads_per_session: config.max_concurrent_threads_per_session, + }), + exposure, + )); + handlers.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler( + WaitAgentHandlerV2::new(params.wait_agent_timeouts), + exposure, + )); + handlers.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure)); + handlers.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure)); } else { let agent_type_description = agent_type_description(config, params.default_agent_type_description); @@ -416,6 +439,13 @@ fn collect_handler_tools( handlers } +fn multi_agent_v2_handler( + handler: impl RegisteredTool + 'static, + exposure: ToolExposure, +) -> Arc { + override_tool_exposure(Arc::new(handler), exposure) +} + fn compare_code_mode_tools( left: &codex_code_mode::ToolDefinition, right: &codex_code_mode::ToolDefinition, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 38645e69bb..d275b89de3 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1384,3 +1384,67 @@ async fn code_mode_only_restricts_model_tools_to_exec_tools() { ) .await; } + +#[tokio::test] +async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { + let config = test_config().await; + let model_info = construct_model_info_offline("gpt-5.4", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + features.enable(Feature::CodeModeOnly); + features.enable(Feature::MultiAgentV2); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true); + let router = ToolRouter::from_config( + &tools_config, + ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: &[], + }, + ); + let model_visible_specs = router.model_visible_specs(); + let tool_names = model_visible_specs + .iter() + .map(ToolSpec::name) + .collect::>(); + + assert_eq!( + tool_names, + vec![ + "exec", + "wait", + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + ] + ); + + let exec = find_tool(&model_visible_specs, "exec"); + let ToolSpec::Freeform(exec) = exec else { + panic!("exec should be a freeform tool"); + }; + assert!(!exec.description.contains("spawn_agent")); + assert!(!exec.description.contains("wait_agent")); + + let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); + let ToolSpec::Function(spawn_agent) = spawn_agent else { + panic!("spawn_agent should be a function tool"); + }; + assert!(!spawn_agent.description.contains("exec tool declaration")); +} diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 7665a4ca8b..b50d14f4eb 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -25,6 +25,8 @@ pub struct MultiAgentV2ConfigToml { pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub non_code_mode_only: Option, } impl FeatureConfig for MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a635ca0740..04e236f5ee 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -481,6 +481,7 @@ usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, ) .expect("features table should deserialize"); @@ -500,6 +501,7 @@ hide_spawn_agent_metadata = true root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), hide_spawn_agent_metadata: Some(true), + non_code_mode_only: Some(true), })) ); } @@ -535,6 +537,7 @@ usage_hint_enabled = false root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: None, + non_code_mode_only: None, })) ); } diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 17e0dccaae..390c4ee386 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -117,6 +117,7 @@ pub struct ToolsConfig { pub collab_tools: bool, pub goal_tools: bool, pub multi_agent_v2: bool, + pub multi_agent_v2_non_code_mode_only: bool, pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, @@ -257,6 +258,7 @@ impl ToolsConfig { collab_tools: include_collab_tools, goal_tools: include_goal_tools, multi_agent_v2: include_multi_agent_v2, + multi_agent_v2_non_code_mode_only: false, hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, @@ -314,6 +316,15 @@ impl ToolsConfig { self } + pub fn with_multi_agent_v2_non_code_mode_only( + mut self, + multi_agent_v2_non_code_mode_only: bool, + ) -> Self { + self.multi_agent_v2_non_code_mode_only = + self.multi_agent_v2 && multi_agent_v2_non_code_mode_only; + self + } + pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { self.goal_tools = self.goal_tools && allowed; self diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs index 1d7169ca1d..4dc328523f 100644 --- a/codex-rs/tools/src/tool_executor.rs +++ b/codex-rs/tools/src/tool_executor.rs @@ -5,12 +5,30 @@ use crate::ToolName; use crate::ToolOutput; use crate::ToolSpec; -/// Controls whether a tool is exposed in the initial model-visible tool list -/// or registered for later discovery. +/// Controls where a tool is exposed to the model. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ToolExposure { + /// Include this tool in the initial model-visible tool list. + /// + /// When code mode is enabled, this tool is also available as a nested + /// code-mode tool. Direct, + + /// Register this tool for later discovery, but omit it from the initial + /// model-visible tool list. Deferred, + + /// Include this tool in the initial model-visible tool list only. + /// + /// In code-mode-only sessions, this keeps the tool callable as a normal + /// model tool while excluding it from the nested code-mode tool surface. + DirectModelOnly, +} + +impl ToolExposure { + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct | Self::DirectModelOnly) + } } /// Shared runtime contract for model-visible tools. From 610b86fefb1873d5178a2fe30e04f807c4f6206a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Wed, 13 May 2026 11:00:54 -0700 Subject: [PATCH 08/52] Pass Codex product SKU to ChatGPT backend (#22366) # Description We need to set the appropriate Product SKU for full functionality for the apps endpoints for each type of client # Testing `./target/debug/codex --enable app` CleanShot 2026-05-12 at 11 51 25@2x Regular slack flows seem to work, also curling these endpoints with the correct SKU returns the right apps --- codex-rs/chatgpt/src/chatgpt_client.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 05d8186686..372f62e696 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -6,6 +6,9 @@ use anyhow::Context; use serde::de::DeserializeOwned; use std::time::Duration; +const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; +const CODEX_PRODUCT_SKU: &str = "codex"; + /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, @@ -46,6 +49,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout( let mut request = client .get(&url) .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU) .header("Content-Type", "application/json"); if let Some(timeout) = timeout { From 4454e1411b82dd755e487e4b7ecd4d949c0ed336 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 13 May 2026 11:15:25 -0700 Subject: [PATCH 09/52] Deprecate TurnContext cwd and resolve_path (#22519) ## Why `TurnContext::cwd` and `TurnContext::resolve_path` are being phased out in favor of using the selected turn environment cwd directly. Deprecating both APIs makes any new direct dependency visible while preserving the existing migration path for current callers. ## What Changed - Marked `TurnContext::cwd` and `TurnContext::resolve_path` as deprecated with guidance to use the selected turn environment cwd instead. - Added exact `#[allow(deprecated)]` suppressions at each existing direct usage site, including tests, rather than adding crate-wide suppression. - Kept the change behavior-preserving: current cwd reads, writes, and path resolution continue to use the same values. ## Verification - `just fmt` - `cargo check -p codex-core` - `cargo check -p codex-core --tests` - `git diff --check` --- codex-rs/core/src/codex_delegate.rs | 11 ++++- codex-rs/core/src/codex_delegate_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 1 + codex-rs/core/src/guardian/review_session.rs | 2 + codex-rs/core/src/hook_runtime.rs | 7 +++ codex-rs/core/src/mcp_openai_file.rs | 16 +++++-- codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 18 ++++++-- codex-rs/core/src/memory_usage.rs | 21 ++++----- codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mcp.rs | 1 + codex-rs/core/src/session/mod.rs | 3 ++ codex-rs/core/src/session/review.rs | 2 + .../session/rollout_reconstruction_tests.rs | 8 ++++ codex-rs/core/src/session/tests.rs | 45 ++++++++++++++----- .../core/src/session/tests/guardian_tests.rs | 12 +++-- codex-rs/core/src/session/turn.rs | 5 +++ codex-rs/core/src/session/turn_context.rs | 9 ++++ codex-rs/core/src/tasks/user_shell.rs | 2 + .../agent_jobs/spawn_agents_on_csv.rs | 6 ++- .../src/tools/handlers/multi_agents_common.rs | 4 +- .../src/tools/handlers/multi_agents_tests.rs | 22 ++++++--- .../src/tools/handlers/request_permissions.rs | 1 + codex-rs/core/src/tools/handlers/shell.rs | 2 + .../src/tools/handlers/shell/shell_command.rs | 6 ++- .../core/src/tools/handlers/shell_tests.rs | 1 + .../core/src/tools/handlers/view_image.rs | 5 ++- codex-rs/core/src/tools/network_approval.rs | 1 + codex-rs/core/src/tools/orchestrator.rs | 1 + codex-rs/core/src/tools/registry.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 9 +++- .../src/unified_exec/process_manager_tests.rs | 3 ++ 32 files changed, 183 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5a01384bf0..493771e28c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -530,7 +530,10 @@ async fn handle_patch_approval( let guardian_decision = if routes_approval_to_guardian(parent_ctx) { let files = changes .keys() - .map(|path| parent_ctx.cwd.join(path)) + .map(|path| { + #[allow(deprecated)] + parent_ctx.cwd.join(path) + }) .collect::>(); let review_cancel = cancel_token.child_token(); let patch = changes @@ -566,6 +569,7 @@ async fn handle_patch_approval( new_guardian_review_id(), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), + #[allow(deprecated)] cwd: parent_ctx.cwd.clone(), files, patch, @@ -739,7 +743,10 @@ async fn handle_request_permissions( reason: event.reason, permissions: event.permissions, }; - let cwd = event.cwd.unwrap_or_else(|| parent_ctx.cwd.clone()); + let cwd = event.cwd.unwrap_or_else(|| { + #[allow(deprecated)] + parent_ctx.cwd.clone() + }); let response_fut = parent_session.request_permissions_for_cwd( parent_ctx, call_id.clone(), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index ecd392e3e7..66cde8d1ea 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -207,6 +207,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }; + #[allow(deprecated)] let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); let cancel_token = CancellationToken::new(); let request_call_id = call_id.clone(); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0cdbc0b010..d7302bbbf8 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -61,6 +61,7 @@ fn build_permissions_update_item( next.approval_policy.value(), next.config.approvals_reviewer, exec_policy, + #[allow(deprecated)] &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), next.features.enabled(Feature::RequestPermissionsTool), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index a419d7cbfa..d3fae0f2e5 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -708,6 +708,7 @@ async fn run_review_on_session( Box::pin(review_session.codex.submit(Op::UserTurn { environments: None, items: prompt_items.items, + #[allow(deprecated)] cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, @@ -1086,6 +1087,7 @@ mod tests { let reasoning_effort = turn.reasoning_effort; let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; + #[allow(deprecated)] let cwd = turn.cwd.clone(); let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3d72e4a7c6..78fc24833e 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -116,6 +116,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -150,6 +151,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -209,6 +211,7 @@ pub(crate) async fn run_permission_request_hooks( let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -249,6 +252,7 @@ pub(crate) async fn run_post_tool_use_hooks( let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -276,6 +280,7 @@ pub(crate) async fn run_pre_compact_hooks( let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -313,6 +318,7 @@ pub(crate) async fn run_post_compact_hooks( let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -338,6 +344,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 0e0d4a6008..ae44515c62 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -102,6 +102,7 @@ async fn build_uploaded_local_argument_value( index: Option, file_path: &str, ) -> Result { + #[allow(deprecated)] let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); let Some(auth) = auth else { return Err( @@ -216,7 +217,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -297,7 +301,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -411,7 +418,10 @@ mod tests { tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8855aae62e..d1faa421d9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -732,6 +732,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), })?; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 65f8977283..8b4f1ba223 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1221,7 +1221,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: turn_context.sub_id.clone(), session.get_tx_event(), turn_context.permission_profile(), - codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + codex_mcp::McpRuntimeEnvironment::new(environment, { + #[allow(deprecated)] + turn_context.cwd.to_path_buf() + }), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), /*host_owned_codex_apps_enabled*/ true, @@ -2230,7 +2233,10 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve .build() .await .expect("load project config"); - turn_context.cwd = config.cwd.clone(); + #[allow(deprecated)] + { + turn_context.cwd = config.cwd.clone(); + } turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), @@ -2431,12 +2437,14 @@ async fn permission_request_hook_allows_mcp_tool_call() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", @@ -2491,12 +2499,14 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index bd856d5e6f..b5bc6eef53 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -44,10 +44,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec Option<(Vec serde_json::from_str::(arguments) .ok() @@ -71,10 +69,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec None, } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index ab40cb8eed..5cb37f9828 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -699,6 +699,7 @@ pub async fn review( .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) .await; + #[allow(deprecated)] match resolve_review_request(review_request, &turn_context.cwd) { Ok(resolved) => { spawn_review_thread( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d32ecf2b19..fcaaa17c57 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -296,6 +296,7 @@ impl Session { .environment_manager .default_environment() .unwrap_or_else(|| self.services.environment_manager.local_environment()), + #[allow(deprecated)] turn_context.cwd.to_path_buf(), ), }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ef9466812..fd3f31771d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2051,6 +2051,7 @@ impl Session { turn_context, call_id, args, + #[allow(deprecated)] turn_context.cwd.clone(), cancellation_token, ) @@ -2630,6 +2631,7 @@ impl Session { turn_context.approval_policy.value(), turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), + #[allow(deprecated)] &turn_context.cwd, turn_context .features @@ -2763,6 +2765,7 @@ impl Session { contextual_user_sections.push( UserInstructions { text: user_instructions.to_string(), + #[allow(deprecated)] directory: turn_context.cwd.to_string_lossy().into_owned(), } .render(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 083aac8c04..e84465508a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -107,6 +107,7 @@ pub(super) async fn spawn_review_thread( sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), + #[allow(deprecated)] parent_turn_context.cwd.clone(), &parent_turn_context.permission_profile, parent_turn_context.windows_sandbox_level, @@ -143,6 +144,7 @@ pub(super) async fn spawn_review_thread( network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), + #[allow(deprecated)] cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, codex_self_exe: parent_turn_context.codex_self_exe.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 5cfcc38053..143b23d3a3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -60,6 +60,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -101,6 +102,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -911,6 +913,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -989,6 +992,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1020,6 +1024,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1135,6 +1140,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1249,6 +1255,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1401,6 +1408,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9ef67bcb8e..577b2fa5ec 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2216,6 +2216,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -3769,6 +3770,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up #[tokio::test] async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let (session, turn_context) = make_session_and_context().await; + #[allow(deprecated)] let updated_cwd = turn_context.cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); @@ -3788,8 +3790,12 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let next_turn = session.new_default_turn().await; assert_eq!(session_cwd, updated_cwd); - assert_eq!(config.cwd, turn_context.cwd); - assert_eq!(next_turn.cwd, updated_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + #[allow(deprecated)] + let next_turn_cwd = next_turn.cwd.clone(); + assert_eq!(config.cwd, turn_cwd); + assert_eq!(next_turn_cwd, updated_cwd); assert_eq!(next_turn.config.cwd, updated_cwd); } @@ -3873,7 +3879,9 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .await .expect("absolute cwd with explicit environments should succeed"); - assert_eq!(turn_context.cwd, absolute_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, absolute_cwd); assert_eq!(turn_context.config.cwd, absolute_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 1); } @@ -4674,7 +4682,9 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); - assert_eq!(request.cwd, Some(turn_context.cwd.clone())); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(request.cwd, Some(turn_cwd)); session .notify_request_permissions_response(&request.call_id, expected_response.clone()) @@ -5103,7 +5113,9 @@ async fn turn_environments_set_primary_environment() { &turn_environments.turn_environments[0].environment )); assert!(!turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd.as_path(), selected_cwd.as_path()); assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); } @@ -5134,7 +5146,9 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { &turn_environment.environment, &turn_environments.turn_environments[0].environment )); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); } @@ -5152,7 +5166,9 @@ async fn default_turn_honors_empty_stored_thread_environments() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 0); } @@ -5161,6 +5177,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { async fn primary_environment_uses_first_turn_environment() { let (_session, mut turn_context) = make_session_and_context().await; let first_environment = turn_context.environments.turn_environments[0].clone(); + #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); turn_context .environments @@ -5214,7 +5231,9 @@ async fn empty_turn_environments_clear_primary_environment() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session.get_config().await.cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session.get_config().await.cwd); assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); } @@ -7033,13 +7052,16 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is } fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy { + #[allow(deprecated)] let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &turn_context.sandbox_policy(), &turn_context.cwd, ); + #[allow(deprecated)] + let cwd_display = turn_context.cwd.as_path().display().to_string(); policy.entries.push(FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: format!("{}/**/*.env", turn_context.cwd.as_path().display()), + pattern: format!("{cwd_display}/**/*.env"), }, access: FileSystemAccessMode::None, }); @@ -9381,6 +9403,8 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let call_id = "test-call".to_string(); let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -9393,7 +9417,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { payload: ToolPayload::Function { arguments: serde_json::json!({ "command": command_script, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": timeout_ms, "sandbox_permissions": sandbox_permissions, "justification": Some("test"), @@ -9433,6 +9457,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index ffb0d94d80..718b9bf05f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -291,6 +291,8 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -304,7 +306,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli arguments: serde_json::json!({ "command": "echo hi", "login": false, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": expiration_ms, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { @@ -392,6 +394,8 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -405,7 +409,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ arguments: serde_json::json!({ "command": "echo hi", "login": false, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": 1_000_u64, }) .to_string(), @@ -558,6 +562,8 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -572,7 +578,7 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per "command": "echo hi", "login": false, "timeout_ms": 1_000_u64, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, }) .to_string(), }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2dd84213ed..d910e03b21 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -366,6 +366,7 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. + #[allow(deprecated)] let display_root = get_git_repo_root(turn_context.cwd.as_path()) .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()); let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( @@ -524,6 +525,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -573,6 +575,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), @@ -697,6 +700,7 @@ async fn track_turn_resolved_config_analytics( model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), permission_profile: turn_context.permission_profile(), + #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), @@ -993,6 +997,7 @@ pub(crate) fn build_prompt( } #[allow(clippy::too_many_arguments)] +#[allow(deprecated)] #[instrument(level = "trace", skip_all, fields( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 7b22ddcc39..8caec7aaea 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -69,6 +69,7 @@ pub struct TurnContext { /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. + #[deprecated(note = "use the selected turn environment cwd instead")] pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, @@ -118,6 +119,7 @@ impl TurnContext { &self.permission_profile, &file_system_sandbox_policy, network_sandbox_policy, + #[allow(deprecated)] &self.cwd, ) } @@ -253,6 +255,7 @@ impl TurnContext { session_source: self.session_source.clone(), thread_source: self.thread_source, environments: self.environments.clone(), + #[allow(deprecated)] cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -288,7 +291,9 @@ impl TurnContext { } } + #[deprecated(note = "resolve paths from the selected turn environment cwd instead")] pub(crate) fn resolve_path(&self, path: Option) -> AbsolutePathBuf { + #[allow(deprecated)] path.as_ref() .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } @@ -314,6 +319,7 @@ impl TurnContext { ); FileSystemSandboxContext { permissions, + #[allow(deprecated)] cwd: Some(self.cwd.clone()), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self @@ -332,6 +338,7 @@ impl TurnContext { let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &self.sandbox_policy(), + #[allow(deprecated)] &self.cwd, ); let file_system_sandbox_policy = self.file_system_sandbox_policy(); @@ -349,6 +356,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), + #[allow(deprecated)] cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -566,6 +574,7 @@ impl Session { session_source, thread_source: session_configuration.thread_source, environments, + #[allow(deprecated)] cwd, current_date: Some(current_date), timezone: Some(timezone), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 683856b90e..23f3882eda 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -148,6 +148,7 @@ pub(crate) async fn execute_user_shell_command( let exec_command = maybe_wrap_shell_lc_with_snapshot( &display_command, session_shell.as_ref(), + #[allow(deprecated)] &turn_context.cwd, &turn_context.shell_environment_policy.r#set, &exec_env_map, @@ -155,6 +156,7 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; + #[allow(deprecated)] let cwd = turn_context.cwd.clone(); let parsed_cmd = parse_command(&display_command); diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 6dd7ea2b31..e1a37be6b6 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -68,6 +68,7 @@ pub async fn handle( } let db = required_state_db(&session)?; + #[allow(deprecated)] let input_path = turn.resolve_path(Some(args.csv_path)); let input_path_display = input_path.display().to_string(); let csv_content = tokio::fs::read_to_string(&input_path) @@ -141,7 +142,10 @@ pub async fn handle( let job_id = Uuid::new_v4().to_string(); let output_csv_path = args.output_csv_path.map_or_else( || default_output_csv_path(&input_path, job_id.as_str()), - |path| turn.resolve_path(Some(path)), + |path| { + #[allow(deprecated)] + turn.resolve_path(Some(path)) + }, ); let job_suffix = &job_id[..8]; let job_name = format!("agent-job-{job_suffix}"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index cc4e9ebfca..216a420e32 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -268,7 +268,9 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( })?; config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); + config.cwd = turn_cwd; config .permissions .set_permission_profile(turn.permission_profile()) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f051fb0f70..1a58561737 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2088,6 +2088,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let expected_sandbox = turn.config.legacy_sandbox_policy(); + #[allow(deprecated)] let mut expected_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&expected_sandbox, &turn.cwd); expected_file_system_sandbox_policy @@ -3767,15 +3768,20 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.abs(); + #[allow(deprecated)] + { + turn.cwd = temp_dir.abs(); + } turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.permission_profile, turn.config.legacy_sandbox_policy(), - turn.cwd.as_path(), + turn_cwd.as_path(), ); let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn.cwd); + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), @@ -3798,7 +3804,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy @@ -3849,7 +3858,10 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 243ed52d15..5d49ad861b 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -47,6 +47,7 @@ impl ToolExecutor for RequestPermissionsHandler { } }; + #[allow(deprecated)] let mut args: RequestPermissionsArgs = parse_arguments_with_base_path(&arguments, &turn.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index e6ce6908f3..84f400854d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -92,6 +92,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { ))); }; + #[allow(deprecated)] let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + #[allow(deprecated)] let workdir = turn.resolve_path(params.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 9db561d577..660bac6789 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -88,6 +88,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex let expected_command = session .user_shell() .derive_exec_args(&command, /*use_login_shell*/ true); + #[allow(deprecated)] let expected_cwd = turn_context.resolve_path(workdir.clone()); let expected_env = create_env( &turn_context.shell_environment_policy, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 3efbedc0ee..bb74adb86d 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -282,7 +282,10 @@ mod tests { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - turn.cwd = image_cwd.clone(); + #[allow(deprecated)] + { + turn.cwd = image_cwd.clone(); + } turn.environments .turn_environments .first_mut() diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 14af2c9c5f..39441d96f5 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -525,6 +525,7 @@ impl NetworkApprovalService { guardian_approval_id, /*approval_id*/ None, prompt_command, + #[allow(deprecated)] turn_context.cwd.clone(), Some(prompt_reason), Some(network_approval_context.clone()), diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 648d3f79e8..deb9ae596e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -234,6 +234,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + #[allow(deprecated)] let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 85c346f48f..6486e7db71 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -409,6 +409,7 @@ impl ToolRegistry { "sandbox_policy", permission_profile_policy_tag( &invocation.turn.permission_profile, + #[allow(deprecated)] invocation.turn.cwd.as_path(), ), ), diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index f8a1480af8..6926f9cbf1 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -83,6 +83,7 @@ async fn exec_command_with_tty( ) -> Result { let manager = &session.services.unified_exec_manager; let process_id = manager.allocate_process_id().await; + #[allow(deprecated)] let cwd = workdir .as_ref() .map_or_else(|| turn.cwd.clone(), |workdir| turn.cwd.join(workdir)); @@ -501,10 +502,12 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; + #[allow(deprecated)] + let cwd = turn.cwd.clone(); let request = test_exec_request( &turn, vec!["bash".to_string(), "-lc".to_string(), "exit 17".to_string()], - turn.cwd.clone(), + cwd, shell_env(), ); @@ -598,10 +601,12 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() turn.environments.turn_environments[0].environment = Arc::new(remote_test_env.environment().clone()); + #[allow(deprecated)] + let cwd = turn.cwd.clone(); let request = test_exec_request( &turn, vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()], - turn.cwd.clone(), + cwd, shell_env(), ); diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5ef5994030..87d9553dd3 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -175,7 +175,9 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { process_id: 123, yield_time_ms: 1000, max_output_tokens: None, + #[allow(deprecated)] cwd: turn.cwd.clone(), + #[allow(deprecated)] sandbox_cwd: turn.cwd.clone(), environment: turn .environments @@ -200,6 +202,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { /*process_started_alive*/ false, &context, &request, + #[allow(deprecated)] turn.cwd.clone(), transcript, "PRE_DENIAL_MARKER".to_string(), From 1ae811ddb263cda3d7b6b074e00691f5324c14c6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 13 May 2026 11:26:37 -0700 Subject: [PATCH 10/52] Refactor chatwidget settings surfaces into modules (phase 4) (#22518) ## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow, and #22433 completed phase 3 by extracting protocol, replay, streaming, and tool lifecycle handling. This PR is phase 4. It keeps moving high-churn UI coordination out of the central widget by extracting settings, popups, and status surfaces without changing the visible behavior those flows already provide. This is once again a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for runtime settings/model coordination, model/reasoning/collaboration popups, settings/personality/theme/audio/experimental popups, permission prompts, status setup/output controls, and Windows sandbox prompt flows. - Moved the remaining rate-limit nudge/status helpers and connectors popup/loading/update helpers into their existing focused modules. - Preserved the existing picker flows, approval behavior, status/title setup previews, rate-limit notices, and connectors/app list behavior while shrinking `chatwidget.rs` back toward orchestration. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and composition surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. Completed in #22433. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. This PR. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. ## Verification - `cargo check -p codex-tui` - `cargo test -p codex-tui chatwidget::tests::permissions` - `cargo test -p codex-tui chatwidget::tests::status_surface_previews` - `cargo test -p codex-tui chatwidget::tests::popups_and_settings` - `cargo test -p codex-tui chatwidget::tests::status_and_layout` `cargo test -p codex-tui` also compiles and begins running, but aborts in the unchanged app-side test `app::tests::discard_side_thread_keeps_local_state_when_server_close_fails` with a reproducible stack overflow. --- codex-rs/tui/src/chatwidget.rs | 3454 +---------------- codex-rs/tui/src/chatwidget/connectors.rs | 415 +- codex-rs/tui/src/chatwidget/model_popups.rs | 583 +++ .../tui/src/chatwidget/permission_popups.rs | 463 +++ codex-rs/tui/src/chatwidget/rate_limits.rs | 327 +- codex-rs/tui/src/chatwidget/settings.rs | 611 +++ .../tui/src/chatwidget/settings_popups.rs | 285 ++ .../tui/src/chatwidget/status_controls.rs | 380 ++ .../chatwidget/tests/popups_and_settings.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + .../src/chatwidget/windows_sandbox_prompts.rs | 446 +++ 11 files changed, 3517 insertions(+), 3450 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/model_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/permission_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/settings.rs create mode 100644 codex-rs/tui/src/chatwidget/settings_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/status_controls.rs create mode 100644 codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6723577754..f8bb6cd778 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -252,7 +252,6 @@ fn queued_message_edit_hint_binding( } use crate::app_event::AppEvent; -use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] @@ -318,7 +317,6 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod command_lifecycle; mod connectors; -use self::connectors::ConnectorsCacheState; use self::connectors::ConnectorsState; mod exec_state; use self::exec_state::RunningCommand; @@ -362,11 +360,11 @@ use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod model_popups; +mod permission_popups; mod protocol; mod protocol_requests; mod rate_limits; -use self::rate_limits::NUDGE_MODEL_SLUG; -use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD; use self::rate_limits::RateLimitErrorKind; use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; @@ -380,11 +378,15 @@ mod reasoning_shortcuts; mod review; use self::review::ReviewState; mod service_tiers; +mod settings; +mod settings_popups; mod side; mod status_state; +mod windows_sandbox_prompts; use self::status_state::StatusIndicatorState; use self::status_state::StatusState; use self::status_state::TerminalTitleStatusKind; +mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; @@ -896,187 +898,6 @@ impl ChatWidget { self.realtime_conversation_enabled() } - /// Update the status indicator header and details. - /// - /// Passing `None` clears any existing details. - fn set_status( - &mut self, - header: String, - details: Option, - details_capitalization: StatusDetailsCapitalization, - details_max_lines: usize, - ) { - let details = details - .filter(|details| !details.is_empty()) - .map(|details| { - let trimmed = details.trim_start(); - match details_capitalization { - StatusDetailsCapitalization::CapitalizeFirst => { - crate::text_formatting::capitalize_first(trimmed) - } - StatusDetailsCapitalization::Preserve => trimmed.to_string(), - } - }); - self.status_state.set_status(StatusIndicatorState { - header: header.clone(), - details: details.clone(), - details_max_lines, - }); - self.bottom_pane.update_status( - header, - details, - StatusDetailsCapitalization::Preserve, - details_max_lines, - ); - let title_uses_status = self - .config - .tui_terminal_title - .as_ref() - .is_some_and(|items| { - items - .iter() - .any(|item| item == "run-state" || item == "status") - }); - if title_uses_status { - self.refresh_status_surfaces(); - } - } - - /// Convenience wrapper around [`Self::set_status`]; - /// updates the status indicator header and clears any existing details. - fn set_status_header(&mut self, header: String) { - self.set_status( - header, - /*details*/ None, - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - } - - /// Sets the currently rendered footer status-line value. - pub(crate) fn set_status_line(&mut self, status_line: Option>) { - self.bottom_pane.set_status_line(status_line); - } - - /// Sets the terminal hyperlink target for the currently rendered footer status line. - pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { - self.bottom_pane.set_status_line_hyperlink(url); - } - - /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. - /// - /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the - /// user actually looking at?" and the footer stack remains a pure renderer of that decision. - pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { - self.bottom_pane.set_active_agent_label(active_agent_label); - } - - /// Recomputes footer status-line content from config and current runtime state. - /// - /// This method is the status-line orchestrator: it parses configured item identifiers, - /// warns once per session about invalid items, updates whether status-line mode is enabled, - /// schedules async git-branch lookup when needed, and renders only values that are currently - /// available. - /// - /// The omission behavior is intentional. If selected items are unavailable (for example before - /// a session id exists or before branch lookup completes), those items are skipped without - /// placeholders so the line remains compact and stable. - pub(crate) fn refresh_status_line(&mut self) { - self.refresh_status_surfaces(); - } - - /// Records that status-line setup was canceled. - /// - /// Cancellation is intentionally side-effect free for config state; the existing configuration - /// remains active and no persistence is attempted. - pub(crate) fn cancel_status_line_setup(&self) { - tracing::info!("Status line setup canceled by user"); - } - - /// Applies status-line item selection from the setup view to in-memory config. - /// - /// An empty selection persists as an explicit empty list. - pub(crate) fn setup_status_line(&mut self, items: Vec, use_theme_colors: bool) { - tracing::info!( - "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" - ); - let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_status_line = Some(ids); - self.config.tui_status_line_use_colors = use_theme_colors; - self.refresh_status_line(); - } - - /// Applies a temporary terminal-title selection while the setup UI is open. - pub(crate) fn preview_terminal_title(&mut self, items: Vec) { - if self.terminal_title_setup_original_items.is_none() { - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); - } - - let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_terminal_title = Some(ids); - self.refresh_terminal_title(); - } - - /// Restores the terminal-title config that was active before the setup UI - /// opened, undoing any preview changes. No-op if no setup session is active. - pub(crate) fn revert_terminal_title_setup_preview(&mut self) { - let Some(original_items) = self.terminal_title_setup_original_items.take() else { - return; - }; - - self.config.tui_terminal_title = original_items; - self.refresh_terminal_title(); - } - - /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. - pub(crate) fn cancel_terminal_title_setup(&mut self) { - tracing::info!("Terminal title setup canceled by user"); - self.revert_terminal_title_setup_preview(); - } - - /// Commits a confirmed terminal-title selection, ending the setup session. - /// - /// After this call, `revert_terminal_title_setup_preview` becomes a no-op - /// because the original config snapshot is discarded. - pub(crate) fn setup_terminal_title(&mut self, items: Vec) { - tracing::info!("terminal title setup confirmed with items: {items:#?}"); - let ids = items.iter().map(ToString::to_string).collect::>(); - self.terminal_title_setup_original_items = None; - self.config.tui_terminal_title = Some(ids); - self.refresh_terminal_title(); - } - - /// Stores async git-branch lookup results for the current status-line cwd. - /// - /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch - /// names after directory changes. - pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { - if self.status_line_branch_cwd.as_ref() != Some(&cwd) { - self.status_line_branch_pending = false; - return; - } - self.status_line_branch = branch; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = true; - self.refresh_status_surfaces(); - } - - /// Stores async Git summary lookup results for the current status-line cwd. - pub(crate) fn set_status_line_git_summary( - &mut self, - cwd: PathBuf, - summary: StatusLineGitSummary, - ) { - if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { - self.status_line_git_summary_pending = false; - return; - } - self.status_line_git_summary = Some(summary); - self.status_line_git_summary_pending = false; - self.status_line_git_summary_lookup_complete = true; - self.refresh_status_surfaces(); - } - fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); @@ -1487,107 +1308,6 @@ impl ChatWidget { } } - pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { - if let Some(mut snapshot) = snapshot { - let limit_id = snapshot - .limit_id - .clone() - .unwrap_or_else(|| "codex".to_string()); - let limit_label = snapshot - .limit_name - .clone() - .unwrap_or_else(|| limit_id.clone()); - if snapshot.credits.is_none() { - snapshot.credits = self - .rate_limit_snapshots_by_limit_id - .get(&limit_id) - .and_then(|display| display.credits.as_ref()) - .map(|credits| CreditsSnapshot { - has_credits: credits.has_credits, - unlimited: credits.unlimited, - balance: credits.balance.clone(), - }); - } - - self.plan_type = snapshot.plan_type.or(self.plan_type); - - let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); - if is_codex_limit - && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type - { - self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); - } - let warnings = if is_codex_limit { - self.rate_limit_warnings.take_warnings( - snapshot - .secondary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_duration_mins), - snapshot - .primary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .primary - .as_ref() - .and_then(|window| window.window_duration_mins), - ) - } else { - vec![] - }; - - let high_usage = is_codex_limit - && (snapshot - .secondary - .as_ref() - .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false) - || snapshot - .primary - .as_ref() - .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false)); - - let has_workspace_credits = snapshot - .credits - .as_ref() - .map(|credits| credits.has_credits) - .unwrap_or(false); - - if high_usage - && !has_workspace_credits - && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != NUDGE_MODEL_SLUG - && !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - ) - { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; - } - - let display = - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); - self.rate_limit_snapshots_by_limit_id - .insert(limit_id, display); - - if !warnings.is_empty() { - for warning in warnings { - self.add_to_history(history_cell::new_warning_event(warning)); - } - self.request_redraw(); - } - } else { - self.rate_limit_snapshots_by_limit_id.clear(); - self.codex_rate_limit_reached_type = None; - } - self.refresh_status_line(); - } - pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { offset, @@ -2399,91 +2119,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_status_output( - &mut self, - refreshing_rate_limits: bool, - request_id: Option, - ) { - let default_usage = TokenUsage::default(); - let token_info = self.token_info.as_ref(); - let total_usage = token_info - .map(|ti| &ti.total_token_usage) - .unwrap_or(&default_usage); - let collaboration_mode = self.collaboration_mode_label(); - let model = self.current_model().to_string(); - let model_default_reasoning_effort = - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.default_reasoning_effort) - }); - let reasoning_effort_override = Some( - self.effective_reasoning_effort() - .or(self.config.model_reasoning_effort) - .or(model_default_reasoning_effort), - ); - let rate_limit_snapshots: Vec = self - .rate_limit_snapshots_by_limit_id - .values() - .cloned() - .collect(); - let agents_summary = - crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); - let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( - &self.config, - self.runtime_model_provider_base_url.as_deref(), - self.status_account_display.as_ref(), - token_info, - total_usage, - &self.thread_id, - self.thread_name.clone(), - self.forked_from, - rate_limit_snapshots.as_slice(), - self.plan_type, - Local::now(), - self.model_display_name(), - collaboration_mode, - reasoning_effort_override, - agents_summary, - refreshing_rate_limits, - ); - if let Some(request_id) = request_id { - self.refreshing_status_outputs.push((request_id, handle)); - } - self.add_to_history(cell); - } - - pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { - return; - } - - let rate_limit_snapshots: Vec = self - .rate_limit_snapshots_by_limit_id - .values() - .cloned() - .collect(); - let now = Local::now(); - let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); - let mut updated_any = false; - for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { - if pending_request_id == request_id { - updated_any = true; - handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); - } else { - remaining.push((pending_request_id, handle)); - } - } - self.refreshing_status_outputs = remaining; - if updated_any { - self.request_redraw(); - } - } - pub(crate) fn add_debug_config_output(&mut self) { self.add_to_history(crate::debug_config::new_debug_config_output( &self.config, @@ -2491,122 +2126,6 @@ impl ChatWidget { )); } - fn open_status_line_setup(&mut self) { - let configured_status_line_items = self.configured_status_line_items(); - let view = StatusLineSetupView::new( - Some(configured_status_line_items.as_slice()), - self.config.tui_status_line_use_colors, - self.status_surface_preview_data(), - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn open_terminal_title_setup(&mut self) { - let configured_terminal_title_items = self.configured_terminal_title_items(); - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); - let view = TerminalTitleSetupView::new( - Some(configured_terminal_title_items.as_slice()), - self.terminal_title_preview_data(), - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { - StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { - self.status_surface_preview_value_for_item(item) - .map(|value| (item, value)) - })) - } - - fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { - let mut preview_data = self.status_surface_preview_data(); - let now = Instant::now(); - for item in TerminalTitleItem::iter() { - let Some(preview_item) = item.preview_item() else { - continue; - }; - let Some(value) = self.terminal_title_value_for_item(item, now) else { - continue; - }; - preview_data.set_live(preview_item, value); - } - preview_data - } - fn open_theme_picker(&mut self) { - let codex_home = crate::legacy_core::config::find_codex_home().ok(); - let terminal_width = self - .last_rendered_width - .get() - .and_then(|width| u16::try_from(width).ok()); - let params = crate::theme_picker::build_theme_picker_params( - self.config.tui_theme.as_deref(), - codex_home.as_deref(), - terminal_width, - ); - self.bottom_pane.show_selection_view(params); - } - - fn status_line_context_window_size(&self) -> Option { - self.token_info - .as_ref() - .and_then(|info| info.model_context_window) - .or(self.config.model_context_window) - } - - fn status_line_context_remaining_percent(&self) -> Option { - let Some(context_window) = self.status_line_context_window_size() else { - return Some(100); - }; - let default_usage = TokenUsage::default(); - let usage = self - .token_info - .as_ref() - .map(|info| &info.last_token_usage) - .unwrap_or(&default_usage); - Some( - usage - .percent_of_context_window_remaining(context_window) - .clamp(0, 100), - ) - } - - fn status_line_context_used_percent(&self) -> Option { - let remaining = self.status_line_context_remaining_percent().unwrap_or(100); - Some((100 - remaining).clamp(0, 100)) - } - - fn status_line_total_usage(&self) -> TokenUsage { - self.token_info - .as_ref() - .map(|info| info.total_token_usage.clone()) - .unwrap_or_default() - } - - fn status_line_limit_display( - &self, - window: Option<&RateLimitWindowDisplay>, - label: &str, - ) -> Option { - let window = window?; - let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); - Some(format!("{label} {remaining:.0}%")) - } - - fn status_line_reasoning_effort_label(effort: Option) -> &'static str { - match effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - } - } - pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes @@ -2629,2659 +2148,6 @@ impl ChatWidget { ); } - fn stop_rate_limit_poller(&mut self) {} - - pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { - self.prefetch_connectors_with_options(force_refetch); - } - - fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); - } - - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { - if !self.connectors_enabled() { - return; - } - if self.connectors.prefetch_in_flight { - if force_refetch { - self.connectors.force_refetch_pending = true; - } - return; - } - - self.connectors.prefetch_in_flight = true; - if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { - self.connectors.cache = ConnectorsCacheState::Loading; - } - - let config = self.config.clone(); - let environment_manager = Arc::clone(&self.environment_manager); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let accessible_result = - match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - force_refetch, - &environment_manager, - ) - .await - { - Ok(connectors) => connectors, - Err(err) => { - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Err(format!("Failed to load apps: {err}")), - is_final: true, - }); - return; - } - }; - let should_schedule_force_refetch = - !force_refetch && !accessible_result.codex_apps_ready; - let accessible_connectors = accessible_result.connectors; - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Ok(ConnectorsSnapshot { - connectors: accessible_connectors.clone(), - }), - is_final: false, - }); - - let result: Result = async { - let all_connectors = - chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) - .await?; - let connectors = chatgpt_connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ); - Ok(ConnectorsSnapshot { connectors }) - } - .await - .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result, - is_final: true, - }); - - if should_schedule_force_refetch { - app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); - } - }); - } - - #[cfg_attr(not(test), allow(dead_code))] - fn prefetch_rate_limits(&mut self) { - self.stop_rate_limit_poller(); - } - - #[cfg_attr(not(test), allow(dead_code))] - fn should_prefetch_rate_limits(&self) -> bool { - self.config.model_provider.requires_openai_auth && self.has_chatgpt_account - } - - fn lower_cost_preset(&self) -> Option { - let models = self.model_catalog.try_list_models().ok()?; - models - .iter() - .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) - .cloned() - } - - fn rate_limit_switch_prompt_hidden(&self) -> bool { - self.config - .notices - .hide_rate_limit_model_nudge - .unwrap_or(false) - } - - fn maybe_show_pending_rate_limit_prompt(&mut self) { - if self.rate_limit_switch_prompt_hidden() { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - return; - } - if !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - if let Some(preset) = self.lower_cost_preset() { - self.open_rate_limit_switch_prompt(preset); - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; - } else { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { - let switch_model = preset.model; - let switch_model_for_events = switch_model.clone(); - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - - let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - Some(switch_model_for_events.clone()), - Some(Some(default_effort)), - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); - })]; - - let keep_actions: Vec = Vec::new(); - let never_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); - tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); - })]; - let description = if preset.description.is_empty() { - Some("Uses fewer credits for upcoming turns.".to_string()) - } else { - Some(preset.description) - }; - - let items = vec![ - SelectionItem { - name: format!("Switch to {switch_model}"), - description, - selected_description: None, - is_current: false, - actions: switch_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model".to_string(), - description: None, - selected_description: None, - is_current: false, - actions: keep_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model (never show again)".to_string(), - description: Some( - "Hide future rate limit reminders about switching models.".to_string(), - ), - selected_description: None, - is_current: false, - actions: never_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Approaching rate limits".to_string()), - subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { - if self.add_credits_nudge_email_in_flight.is_some() { - return; - } - - let (title, prompt) = match credit_type { - AddCreditsNudgeCreditType::Credits => ( - "You've reached your workspace credit limit", - "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", - ), - AddCreditsNudgeCreditType::UsageLimit => ( - "Usage limit reached", - "Request a limit increase from your owner to continue using codex. Request increase?", - ), - }; - let send_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); - })]; - let items = vec![ - SelectionItem { - name: "Yes".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), - actions: send_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "No".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), - is_default: true, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(title.to_string()), - subtitle: Some(prompt.to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx: Some(1), - ..Default::default() - }); - } - - pub(crate) fn start_add_credits_nudge_email_request( - &mut self, - credit_type: AddCreditsNudgeCreditType, - ) -> bool { - self.add_credits_nudge_email_in_flight = Some(credit_type); - true - } - - pub(crate) fn finish_add_credits_nudge_email_request( - &mut self, - result: Result, - ) { - let credit_type = self - .add_credits_nudge_email_in_flight - .take() - .unwrap_or(AddCreditsNudgeCreditType::Credits); - let message = match (credit_type, result) { - (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { - "Workspace owner notified." - } - ( - AddCreditsNudgeCreditType::Credits, - Ok(AddCreditsNudgeEmailStatus::CooldownActive), - ) => "Workspace owner was already notified recently.", - (AddCreditsNudgeCreditType::Credits, Err(_)) => { - "Could not notify your workspace owner. Please try again." - } - (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { - "Limit increase requested." - } - ( - AddCreditsNudgeCreditType::UsageLimit, - Ok(AddCreditsNudgeEmailStatus::CooldownActive), - ) => "A limit increase was already requested recently.", - (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { - "Could not request a limit increase. Please try again." - } - }; - self.add_to_history(history_cell::new_info_event( - message.to_string(), - /*hint*/ None, - )); - self.request_redraw(); - } - - /// Open a popup to choose a quick auto model. Selecting "All models" - /// opens the full picker with every available preset. - pub(crate) fn open_model_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Model selection is disabled until startup completes.".to_string(), - /*hint*/ None, - ); - return; - } - - let presets: Vec = match self.model_catalog.try_list_models() { - Ok(models) => models, - Err(_) => { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - /*hint*/ None, - ); - return; - } - }; - self.open_model_popup_with_presets(presets); - } - - pub(crate) fn open_personality_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Personality selection is disabled until startup completes.".to_string(), - /*hint*/ None, - ); - return; - } - if !self.current_model_supports_personality() { - let current_model = self.current_model(); - self.add_error_message(format!( - "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." - )); - return; - } - self.open_personality_popup_for_current_model(); - } - - fn open_personality_popup_for_current_model(&mut self) { - let current_personality = self.config.personality.unwrap_or(Personality::Friendly); - let personalities = [Personality::Friendly, Personality::Pragmatic]; - let supports_personality = self.current_model_supports_personality(); - - let items: Vec = personalities - .into_iter() - .map(|personality| { - let name = Self::personality_label(personality).to_string(); - let description = Some(Self::personality_description(personality).to_string()); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - Some(personality), - ))); - tx.send(AppEvent::UpdatePersonality(personality)); - tx.send(AppEvent::PersistPersonalitySelection { personality }); - })]; - SelectionItem { - name, - description, - is_current: current_personality == personality, - is_disabled: !supports_personality, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - let mut header = ColumnRenderable::new(); - header.push(Line::from("Select Personality".bold())); - header.push(Line::from("Choose a communication style for Codex.".dim())); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) fn open_realtime_audio_popup(&mut self) { - let items = [ - RealtimeAudioDeviceKind::Microphone, - RealtimeAudioDeviceKind::Speaker, - ] - .into_iter() - .map(|kind| { - let description = Some(format!( - "Current: {}", - self.current_realtime_audio_selection_label(kind) - )); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); - })]; - SelectionItem { - name: kind.title().to_string(), - description, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Settings".to_string()), - subtitle: Some("Configure settings for Codex.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - #[cfg(not(target_os = "linux"))] - pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { - match list_realtime_audio_device_names(kind) { - Ok(device_names) => { - self.open_realtime_audio_device_selection_with_names(kind, device_names); - } - Err(err) => { - self.add_error_message(format!( - "Failed to load realtime {} devices: {err}", - kind.noun() - )); - } - } - } - - #[cfg(target_os = "linux")] - pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { - let _ = kind; - } - - #[cfg(not(target_os = "linux"))] - fn open_realtime_audio_device_selection_with_names( - &mut self, - kind: RealtimeAudioDeviceKind, - device_names: Vec, - ) { - let current_selection = self.current_realtime_audio_device_name(kind); - let current_available = current_selection - .as_deref() - .is_some_and(|name| device_names.iter().any(|device_name| device_name == name)); - let mut items = vec![SelectionItem { - name: "System default".to_string(), - description: Some("Use your operating system default device.".to_string()), - is_current: current_selection.is_none(), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); - })], - dismiss_on_select: true, - ..Default::default() - }]; - - if let Some(selection) = current_selection.as_deref() - && !current_available - { - items.push(SelectionItem { - name: format!("Unavailable: {selection}"), - description: Some("Configured device is not currently available.".to_string()), - is_current: true, - is_disabled: true, - disabled_reason: Some("Reconnect the device or choose another one.".to_string()), - ..Default::default() - }); - } - - items.extend(device_names.into_iter().map(|device_name| { - let persisted_name = device_name.clone(); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { - kind, - name: Some(persisted_name.clone()), - }); - })]; - SelectionItem { - is_current: current_selection.as_deref() == Some(device_name.as_str()), - name: device_name, - actions, - dismiss_on_select: true, - ..Default::default() - } - })); - - let mut header = ColumnRenderable::new(); - header.push(Line::from(format!("Select {}", kind.title()).bold())); - header.push(Line::from( - "Saved devices apply to realtime voice only.".dim(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) { - let restart_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::RestartRealtimeAudioDevice { kind }); - })]; - let items = vec![ - SelectionItem { - name: "Restart now".to_string(), - description: Some(format!("Restart local {} audio now.", kind.noun())), - actions: restart_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Apply later".to_string(), - description: Some(format!( - "Keep the current {} until local audio starts again.", - kind.noun() - )), - dismiss_on_select: true, - ..Default::default() - }, - ]; - - let mut header = ColumnRenderable::new(); - header.push(Line::from(format!("Restart {} now?", kind.title()).bold())); - header.push(Line::from( - "Configuration is saved. Restart local audio to use it immediately.".dim(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { - let title = title.to_string(); - let subtitle = subtitle.to_string(); - let mut header = ColumnRenderable::new(); - header.push(Line::from(title.bold())); - header.push(Line::from(subtitle.dim())); - if let Some(warning) = self.model_menu_warning_line() { - header.push(warning); - } - Box::new(header) - } - - fn model_menu_warning_line(&self) -> Option> { - let base_url = self.custom_openai_base_url()?; - let warning = format!( - "Warning: OpenAI base URL is overridden to {base_url}. Selecting models may not be supported or work properly." - ); - Some(Line::from(warning.red())) - } - - fn custom_openai_base_url(&self) -> Option { - if !self.config.model_provider.is_openai() { - return None; - } - - let base_url = self.config.model_provider.base_url.as_ref()?; - let trimmed = base_url.trim(); - if trimmed.is_empty() { - return None; - } - - let normalized = trimmed.trim_end_matches('/'); - if normalized == DEFAULT_OPENAI_BASE_URL { - return None; - } - - Some(trimmed.to_string()) - } - - pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { - let presets: Vec = presets - .into_iter() - .filter(|preset| preset.show_in_picker) - .collect(); - - let current_model = self.current_model(); - let current_label = presets - .iter() - .find(|preset| preset.model.as_str() == current_model) - .map(|preset| preset.model.to_string()) - .unwrap_or_else(|| self.model_display_name().to_string()); - - let (mut auto_presets, other_presets): (Vec, Vec) = presets - .into_iter() - .partition(|preset| Self::is_auto_model(&preset.model)); - - if auto_presets.is_empty() { - self.open_all_models_popup(other_presets); - return; - } - - auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); - let mut items: Vec = auto_presets - .into_iter() - .map(|preset| { - let description = - (!preset.description.is_empty()).then_some(preset.description.clone()); - let model = preset.model.clone(); - let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( - model.as_str(), - Some(preset.default_reasoning_effort), - ); - let actions = Self::model_selection_actions( - model.clone(), - Some(preset.default_reasoning_effort), - should_prompt_plan_mode_scope, - ); - SelectionItem { - name: model.clone(), - description, - is_current: model.as_str() == current_model, - is_default: preset.is_default, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - if !other_presets.is_empty() { - let all_models = other_presets; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAllModelsPopup { - models: all_models.clone(), - }); - })]; - - let is_current = !items.iter().any(|item| item.is_current); - let description = Some(format!( - "Choose a specific model and reasoning level (current: {current_label})" - )); - - items.push(SelectionItem { - name: "All models".to_string(), - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let header = self.model_menu_header( - "Select Model", - "Pick a quick auto mode or browse all models.", - ); - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header, - ..Default::default() - }); - } - - fn is_auto_model(model: &str) -> bool { - model.starts_with("codex-auto-") - } - - fn auto_model_order(model: &str) -> usize { - match model { - "codex-auto-fast" => 0, - "codex-auto-balanced" => 1, - "codex-auto-thorough" => 2, - _ => 3, - } - } - - pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { - if presets.is_empty() { - self.add_info_message( - "No additional models are available right now.".to_string(), - /*hint*/ None, - ); - return; - } - - let mut items: Vec = Vec::new(); - for preset in presets.into_iter() { - let description = - (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = preset.model.as_str() == self.current_model(); - let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; - let preset_for_action = preset.clone(); - let actions: Vec = vec![Box::new(move |tx| { - let preset_for_event = preset_for_action.clone(); - tx.send(AppEvent::OpenReasoningPopup { - model: preset_for_event, - }); - })]; - items.push(SelectionItem { - name: preset.model.clone(), - description, - is_current, - is_default: preset.is_default, - actions, - dismiss_on_select: single_supported_effort, - dismiss_parent_on_child_accept: !single_supported_effort, - ..Default::default() - }); - } - - let header = self.model_menu_header( - "Select Model and Effort", - "Access legacy models by running codex -m or in your config.toml", - ); - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), - items, - header, - ..Default::default() - }); - } - - pub(crate) fn open_collaboration_modes_popup(&mut self) { - let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()); - if presets.is_empty() { - self.add_info_message( - "No collaboration modes are available right now.".to_string(), - /*hint*/ None, - ); - return; - } - - let current_kind = self - .active_collaboration_mask - .as_ref() - .and_then(|mask| mask.mode) - .or_else(|| { - collaboration_modes::default_mask(self.model_catalog.as_ref()) - .and_then(|mask| mask.mode) - }); - let items: Vec = presets - .into_iter() - .map(|mask| { - let name = mask.name.clone(); - let is_current = current_kind == mask.mode; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); - })]; - SelectionItem { - name, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Collaboration Mode".to_string()), - subtitle: Some("Pick a collaboration preset.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn model_selection_actions( - model_for_action: String, - effort_for_action: Option, - should_prompt_plan_mode_scope: bool, - ) -> Vec { - vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { - tx.send(AppEvent::OpenPlanReasoningScopePrompt { - model: model_for_action.clone(), - effort: effort_for_action, - }); - return; - } - - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - })] - } - - fn should_prompt_plan_mode_reasoning_scope( - &self, - selected_model: &str, - selected_effort: Option, - ) -> bool { - if !self.collaboration_modes_enabled() - || self.active_mode_kind() != ModeKind::Plan - || selected_model != self.current_model() - { - return false; - } - - // Prompt whenever the selection is not a true no-op for both: - // 1) the active Plan-mode effective reasoning, and - // 2) the stored global defaults that would be updated by the fallback path. - selected_effort != self.effective_reasoning_effort() - || selected_model != self.current_collaboration_mode.model() - || selected_effort != self.current_collaboration_mode.reasoning_effort() - } - - pub(crate) fn open_plan_reasoning_scope_prompt( - &mut self, - model: String, - effort: Option, - ) { - let reasoning_phrase = match effort { - Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), - Some(selected_effort) => { - format!( - "{} reasoning", - Self::reasoning_effort_label(selected_effort).to_lowercase() - ) - } - None => "the selected reasoning".to_string(), - }; - let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); - let plan_reasoning_source = if let Some(plan_override) = - self.config.plan_mode_reasoning_effort - { - format!( - "user-chosen Plan override ({})", - Self::reasoning_effort_label(plan_override).to_lowercase() - ) - } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) - { - match plan_mask.reasoning_effort.flatten() { - Some(plan_effort) => format!( - "built-in Plan default ({})", - Self::reasoning_effort_label(plan_effort).to_lowercase() - ), - None => "built-in Plan default (no reasoning)".to_string(), - } - } else { - "built-in Plan default".to_string() - }; - let all_modes_description = format!( - "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." - ); - let subtitle = format!("Choose where to apply {reasoning_phrase}."); - - let plan_only_actions: Vec = vec![Box::new({ - let model = model.clone(); - move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - } - })]; - let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); - })]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), - subtitle: Some(subtitle), - footer_hint: Some(standard_popup_hint_line()), - items: vec![ - SelectionItem { - name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), - description: Some(plan_only_description), - actions: plan_only_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), - description: Some(all_modes_description), - actions: all_modes_actions, - dismiss_on_select: true, - ..Default::default() - }, - ], - ..Default::default() - }); - self.notify(Notification::PlanModePrompt { - title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), - }); - } - - /// Open a popup to choose the reasoning effort (stage 2) for the given model. - pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - let supported = preset.supported_reasoning_efforts; - let in_plan_mode = - self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; - - let warn_effort = if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::XHigh) - { - Some(ReasoningEffortConfig::XHigh) - } else if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::High) - { - Some(ReasoningEffortConfig::High) - } else { - None - }; - let warning_text = warn_effort.map(|effort| { - let effort_label = Self::reasoning_effort_label(effort); - format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") - }); - let warn_for_model = preset.model.starts_with("gpt-5.1-codex") - || preset.model.starts_with("gpt-5.1-codex-max") - || preset.model.starts_with("gpt-5.2"); - - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } - if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); - } - - if choices.len() == 1 { - let selected_effort = choices.first().and_then(|c| c.stored); - let selected_model = preset.model; - if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { - self.app_event_tx - .send(AppEvent::OpenPlanReasoningScopePrompt { - model: selected_model, - effort: selected_effort, - }); - } else { - self.apply_model_and_effort(selected_model, selected_effort); - } - return; - } - - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) - .or(Some(default_effort)); - - let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == preset.model.as_str(); - let highlight_choice = if is_current_model { - if in_plan_mode { - self.config - .plan_mode_reasoning_effort - .or(self.effective_reasoning_effort()) - } else { - self.effective_reasoning_effort() - } - } else { - default_choice - }; - let selection_choice = highlight_choice.or(default_choice); - let initial_selected_idx = choices - .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); - let mut items: Vec = Vec::new(); - for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { - effort_label.push_str(" (default)"); - } - - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) - .filter(|text| !text.is_empty()); - - let show_warning = warn_for_model && warn_effort == Some(effort); - let selected_description = if show_warning { - warning_text.as_ref().map(|warning_message| { - description.as_ref().map_or_else( - || warning_message.clone(), - |d| format!("{d}\n{warning_message}"), - ) - }) - } else { - None - }; - - let model_for_action = model_slug.clone(); - let choice_effort = choice.stored; - let should_prompt_plan_mode_scope = - self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); - let actions: Vec = vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { - tx.send(AppEvent::OpenPlanReasoningScopePrompt { - model: model_for_action.clone(), - effort: choice_effort, - }); - } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: choice_effort, - }); - } - })]; - - items.push(SelectionItem { - name: effort_label, - description, - selected_description, - is_current: is_current_model && choice.stored == highlight_choice, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let mut header = ColumnRenderable::new(); - header.push(Line::from( - format!("Select Reasoning Level for {model_slug}").bold(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx, - ..Default::default() - }); - } - - fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { - match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", - } - } - - fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } - - /// Open the permissions popup. - pub(crate) fn open_approvals_popup(&mut self) { - self.open_permissions_popup(); - } - - /// Open a popup to choose the permissions mode. - pub(crate) fn open_permissions_popup(&mut self) { - let include_read_only = cfg!(target_os = "windows"); - let current_approval = - AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile(); - let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); - let current_review_policy = self.config.approvals_reviewer; - let mut items: Vec = Vec::new(); - let presets: Vec = builtin_approval_presets(); - - #[cfg(target_os = "windows")] - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - #[cfg(target_os = "windows")] - let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); - #[cfg(not(target_os = "windows"))] - let windows_degraded_sandbox_enabled = false; - - let show_elevate_sandbox_hint = - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); - - let guardian_disabled_reason = |enabled: bool| { - let mut next_features = self.config.features.get().clone(); - next_features.set_enabled(Feature::GuardianApproval, enabled); - self.config - .features - .can_set(&next_features) - .err() - .map(|err| err.to_string()) - }; - - for preset in presets.into_iter() { - if !include_read_only && preset.id == "read-only" { - continue; - } - let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Default (non-admin sandbox)".to_string() - } else { - preset.label.to_string() - }; - let preset_approval = AskForApproval::from(preset.approval); - let base_description = - Some(preset.description.replace(" (Identical to Agent mode)", "")); - let approval_disabled_reason = match self - .config - .permissions - .approval_policy - .can_set(&preset.approval) - { - Ok(()) => None, - Err(err) => Some(err.to_string()), - }; - let default_disabled_reason = approval_disabled_reason - .clone() - .or_else(|| guardian_disabled_reason(false)); - let requires_confirmation = preset.id == "full-access" - && !self - .config - .notices - .hide_full_access_warning - .unwrap_or(false); - let default_actions: Vec = if requires_confirmation { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenFullAccessConfirmation { - preset: preset_clone.clone(), - return_to_permissions: !include_read_only, - }); - })] - } else if preset.id == "auto" { - #[cfg(target_os = "windows")] - { - if WindowsSandboxLevel::from_config(&self.config) - == WindowsSandboxLevel::Disabled - { - let preset_clone = preset.clone(); - if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) - { - vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); - })] - } else { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] - } - } else if let Some((sample_paths, extra_count, failed_scan)) = - self.world_writable_warning_details() - { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset_clone.clone()), - sample_paths: sample_paths.clone(), - extra_count, - failed_scan, - }); - })] - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } - #[cfg(not(target_os = "windows"))] - { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - }; - if preset.id == "auto" { - items.push(SelectionItem { - name: base_name.clone(), - description: base_description.clone(), - is_current: current_review_policy == ApprovalsReviewer::User - && Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: default_actions, - dismiss_on_select: true, - disabled_reason: default_disabled_reason, - ..Default::default() - }); - - if guardian_approval_enabled { - items.push(SelectionItem { - name: "Auto-review".to_string(), - description: Some( - "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." - .to_string(), - ), - is_current: current_review_policy == ApprovalsReviewer::AutoReview - && Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - "Auto-review".to_string(), - ApprovalsReviewer::AutoReview, - ), - dismiss_on_select: true, - disabled_reason: approval_disabled_reason - .or_else(|| guardian_disabled_reason(true)), - ..Default::default() - }); - } - } else { - items.push(SelectionItem { - name: base_name, - description: base_description, - is_current: Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: default_actions, - dismiss_on_select: true, - disabled_reason: default_disabled_reason, - ..Default::default() - }); - } - } - - let footer_note = show_elevate_sandbox_hint.then(|| { - vec![ - "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), - "/setup-default-sandbox".cyan(), - ".".dim(), - ] - .into() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Update Model Permissions".to_string()), - footer_note, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(()), - ..Default::default() - }); - } - - pub(crate) fn open_auto_review_denials_popup(&mut self) { - if self.review.recent_auto_review_denials.is_empty() { - self.add_info_message( - "No recent auto-review denials in this thread.".to_string(), - Some("Denials are recorded after auto-review rejects an action.".to_string()), - ); - return; - } - let Some(thread_id) = self.thread_id() else { - self.add_error_message("That thread is no longer available.".to_string()); - return; - }; - - let mut items = vec![SelectionItem { - name: "Command".to_string(), - description: Some("Rationale".to_string()), - is_disabled: true, - search_value: Some(String::new()), - ..Default::default() - }]; - items.extend( - self.review - .recent_auto_review_denials - .entries() - .map(|event| { - let id = event.id.clone(); - let summary = auto_review_denials::action_summary(&event.action); - let rationale = event - .rationale - .as_deref() - .unwrap_or("Auto-review did not include a rationale."); - SelectionItem { - name: summary.clone(), - description: Some(rationale.to_string()), - selected_description: Some(rationale.to_string()), - search_value: Some(format!("{summary} {rationale}")), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ApproveRecentAutoReviewDenial { - thread_id, - id: id.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - } - }), - ); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Auto-review Denials".to_string()), - subtitle: Some("Select a denied action to approve.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() - }); - self.request_redraw(); - } - - pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { - let Some(event) = self.review.recent_auto_review_denials.take(&id) else { - self.add_error_message("That auto-review denial is no longer available.".to_string()); - return; - }; - - self.app_event_tx.send(AppEvent::SubmitThreadOp { - thread_id, - op: AppCommand::approve_guardian_denied_action(event), - }); - self.add_info_message( - "Approval recorded for one retry of the selected auto-review denial.".to_string(), - Some( - "The model will see the approval context; the retry still goes through auto-review." - .to_string(), - ), - ); - } - - pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES - .iter() - .filter_map(|spec| { - let name = spec.stage.experimental_menu_name()?; - let description = spec.stage.experimental_menu_description()?; - Some(ExperimentalFeatureItem { - feature: spec.id, - name: name.to_string(), - description: description.to_string(), - enabled: self.config.features.enabled(spec.id), - }) - }) - .collect(); - - let view = ExperimentalFeaturesView::new( - features, - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn approval_preset_actions( - approval: AskForApproval, - permission_profile: PermissionProfile, - label: String, - approvals_reviewer: ApprovalsReviewer, - ) -> Vec { - vec![Box::new(move |tx| { - let permission_profile_clone = permission_profile.clone(); - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - Some(approval), - Some(approvals_reviewer), - Some(permission_profile_clone.clone()), - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); - tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - format!("Permissions updated to {label}"), - /*hint*/ None, - ), - ))); - })] - } - - fn preset_matches_current( - current_approval: AskForApproval, - current_permission_profile: &PermissionProfile, - cwd: &std::path::Path, - preset: &ApprovalPreset, - ) -> bool { - let preset_approval = AskForApproval::from(preset.approval); - if current_approval != preset_approval { - return false; - } - - match preset.id { - "full-access" => matches!(current_permission_profile, PermissionProfile::Disabled), - "read-only" => { - let file_system_policy = current_permission_profile.file_system_sandbox_policy(); - matches!( - current_permission_profile, - PermissionProfile::Managed { .. } - ) && !file_system_policy.has_full_disk_write_access() - && file_system_policy - .get_writable_roots_with_cwd(cwd) - .is_empty() - && current_permission_profile.network_sandbox_policy() - == preset.permission_profile.network_sandbox_policy() - } - "auto" => { - let file_system_policy = current_permission_profile.file_system_sandbox_policy(); - matches!( - current_permission_profile, - PermissionProfile::Managed { .. } - ) && file_system_policy.can_write_path_with_cwd(cwd, cwd) - && !file_system_policy.has_full_disk_write_access() - && current_permission_profile.network_sandbox_policy() - == preset.permission_profile.network_sandbox_policy() - } - _ => current_permission_profile == &preset.permission_profile, - } - } - - #[cfg(target_os = "windows")] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return None; - } - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - let Ok(policy) = self - .config - .permissions - .permission_profile() - .to_legacy_sandbox_policy(self.config.cwd.as_path()) - else { - return Some((Vec::new(), 0, true)); - }; - match codex_windows_sandbox::apply_world_writable_scan_and_denies( - self.config.codex_home.as_path(), - cwd.as_path(), - &env_map, - &policy, - Some(self.config.codex_home.as_path()), - ) { - Ok(_) => None, - Err(_) => Some((Vec::new(), 0, true)), - } - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - None - } - - pub(crate) fn open_full_access_confirmation( - &mut self, - preset: ApprovalPreset, - return_to_permissions: bool, - ) { - let selected_name = preset.label.to_string(); - let approval = AskForApproval::from(preset.approval); - let permission_profile = preset.permission_profile; - 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, - permission_profile.clone(), - selected_name.clone(), - ApprovalsReviewer::User, - ); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions( - approval, - permission_profile, - selected_name, - ApprovalsReviewer::User, - ); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); - - let deny_actions: Vec = vec![Box::new(move |tx| { - if return_to_permissions { - tx.send(AppEvent::OpenPermissionsPopup); - } else { - 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() - }); - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - preset: Option, - sample_paths: Vec, - extra_count: usize, - failed_scan: bool, - ) { - let (approval, permission_profile) = match &preset { - Some(p) => ( - Some(AskForApproval::from(p.approval)), - Some(p.permission_profile.clone()), - ), - None => (None, None), - }; - let mut header_children: Vec> = Vec::new(); - let describe_profile = |profile: &PermissionProfile| { - if matches!(profile, PermissionProfile::Disabled) { - "Full Access mode" - } else if profile - .file_system_sandbox_policy() - .can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path()) - { - "Agent mode" - } else { - "Read-Only mode" - } - }; - let mode_label = preset - .as_ref() - .map(|p| describe_profile(&p.permission_profile)) - .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); - let info_line = if failed_scan { - Line::from(vec![ - "We couldn't complete the world-writable scan, so protections cannot be verified. " - .into(), - format!("The Windows sandbox cannot guarantee protection in {mode_label}.") - .fg(Color::Red), - ]) - } else { - Line::from(vec![ - "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), - " Consider removing write access for Everyone from the following folders:".into(), - ]) - }; - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - - if !sample_paths.is_empty() { - // Show up to three examples and optionally an "and X more" line. - let mut lines: Vec = Vec::new(); - lines.push(Line::from("")); - for p in &sample_paths { - lines.push(Line::from(format!(" - {p}"))); - } - if extra_count > 0 { - lines.push(Line::from(format!("and {extra_count} more"))); - } - header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - } - let header = ColumnRenderable::with(header_children); - - // Build actions ensuring acknowledgement happens before applying the - // new permission profile, so downstream policy-change hooks don't - // re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied via - // /permissions, to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { - accept_actions.extend(Self::approval_preset_actions( - approval, - permission_profile, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { - accept_and_remember_actions.extend(Self::approval_preset_actions( - approval, - permission_profile, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let items = vec![ - SelectionItem { - name: "Continue".to_string(), - description: Some(format!("Apply {mode_label} for this session")), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Continue and don't warn again".to_string(), - description: Some(format!("Enable {mode_label} and remember this choice")), - actions: accept_and_remember_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() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - _preset: Option, - _sample_paths: Vec, - _extra_count: usize, - _failed_scan: bool, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { - // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it - // directly (no elevation prompts). - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let preset_clone = preset; - let items = vec![ - SelectionItem { - name: "Enable experimental sandbox".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Go back".to_string(), - description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - return; - } - - self.session_telemetry.counter( - "codex.windows_sandbox.elevated_prompt_shown", - /*inc*/ 1, - &[], - ); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], - ]) - .wrap(Wrap { trim: false }), - )); - - let accept_otel = self.session_telemetry.clone(); - let legacy_otel = self.session_telemetry.clone(); - let legacy_preset = preset.clone(); - let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Set up default sandbox (requires Administrator permissions)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - accept_otel.counter( - "codex.windows_sandbox.elevated_prompt_accept", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - legacy_otel.counter( - "codex.windows_sandbox.elevated_prompt_use_legacy", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: legacy_preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.elevated_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - let mut lines = Vec::new(); - lines.push(line![ - "Couldn't set up your sandbox with Administrator permissions".bold() - ]); - lines.push(line![""]); - lines.push(line![ - "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." - ]); - lines.push(line![ - "Learn more " - ]); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - - let elevated_preset = preset.clone(); - let legacy_preset = preset; - let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Try setting up admin sandbox again".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.session_telemetry.clone(); - let preset = elevated_preset; - move |tx| { - otel.counter( - "codex.windows_sandbox.fallback_retry_elevated", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use Codex with non-admin sandbox".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.session_telemetry.clone(); - let preset = legacy_preset; - move |tx| { - otel.counter( - "codex.windows_sandbox.fallback_use_legacy", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: preset.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.fallback_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { - if show_now - && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled - && let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - { - self.open_windows_sandbox_enable_prompt(preset); - } - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} - - #[cfg(target_os = "windows")] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) { - // While elevated sandbox setup runs, prevent typing so the user doesn't - // accidentally queue messages that will run under an unexpected mode. - self.bottom_pane.set_composer_input_enabled( - /*enabled*/ false, - Some("Input disabled until setup completes.".to_string()), - ); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ false); - self.set_status( - "Setting up sandbox...".to_string(), - Some("Hang tight, this may take a few minutes".to_string()), - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane - .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); - self.bottom_pane.hide_status_indicator(); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} - - /// Set the approval policy in the widget's config copy. - pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { - if let Err(err) = self - .config - .permissions - .approval_policy - .set(policy.to_core()) - { - tracing::warn!(%err, "failed to set approval_policy on chat config"); - } else { - self.refresh_status_surfaces(); - } - } - - /// Set the permission profile in the widget's config copy. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_permission_profile( - &mut self, - profile: PermissionProfile, - ) -> ConstraintResult<()> { - self.config.permissions.set_permission_profile(profile)?; - self.refresh_status_surfaces(); - Ok(()) - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { - self.config.permissions.windows_sandbox_mode = mode; - #[cfg(target_os = "windows")] - self.bottom_pane.set_windows_degraded_sandbox_active( - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&self.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { - if let Err(err) = self.config.features.set_enabled(feature, enabled) { - tracing::warn!( - error = %err, - feature = feature.key(), - "failed to update constrained chat widget feature state" - ); - } - let enabled = self.config.features.enabled(feature); - if feature == Feature::RealtimeConversation { - let realtime_conversation_enabled = self.realtime_conversation_enabled(); - self.bottom_pane - .set_realtime_conversation_enabled(realtime_conversation_enabled); - self.bottom_pane - .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); - if !realtime_conversation_enabled && self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(Some( - "Realtime voice mode was closed because the feature was disabled.".to_string(), - )); - } - } - if feature == Feature::FastMode { - self.sync_service_tier_commands(); - } - if feature == Feature::Personality { - self.sync_personality_command_enabled(); - } - if feature == Feature::Plugins { - self.sync_plugins_command_enabled(); - self.refresh_plugin_mentions(); - } - if feature == Feature::Goals { - self.sync_goal_command_enabled(); - if !enabled { - self.current_goal_status_indicator = None; - self.current_goal_status = None; - self.turn_lifecycle.goal_status_active_turn_started_at = None; - self.turn_lifecycle.budget_limited_turn_ids.clear(); - self.update_collaboration_mode_indicator(); - } - } - if feature == Feature::MentionsV2 { - self.sync_mentions_v2_enabled(); - } - if feature == Feature::PreventIdleSleep { - self.turn_lifecycle.set_prevent_idle_sleep(enabled); - } - #[cfg(target_os = "windows")] - if matches!( - feature, - Feature::WindowsSandbox | Feature::WindowsSandboxElevated - ) { - self.bottom_pane.set_windows_degraded_sandbox_active( - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&self.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - } - enabled - } - - pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { - self.config.approvals_reviewer = policy; - self.refresh_status_surfaces(); - } - - pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_full_access_warning = Some(acknowledged); - } - - pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_world_writable_warning = Some(acknowledged); - } - - pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { - self.config.notices.hide_rate_limit_model_nudge = Some(hidden); - if hidden { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn world_writable_warning_hidden(&self) -> bool { - self.config - .notices - .hide_world_writable_warning - .unwrap_or(false) - } - - /// Override the reasoning effort used when Plan mode is active. - /// - /// When the active mask is already Plan, the override is applied immediately - /// so the footer reflects it without waiting for the next mode switch. - /// Passing `None` resets to the Plan-mode preset default. - pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort; - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - && mask.mode == Some(ModeKind::Plan) - { - if let Some(effort) = effort { - mask.reasoning_effort = Some(Some(effort)); - } else if let Some(plan_mask) = - collaboration_modes::plan_mask(self.model_catalog.as_ref()) - { - mask.reasoning_effort = plan_mask.reasoning_effort; - } - } - self.refresh_model_dependent_surfaces(); - } - - /// Set the reasoning effort for the non-Plan collaboration mode. - /// - /// Does not touch the active Plan mask — Plan reasoning is controlled - /// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`. - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - /*model*/ None, - Some(effort), - /*developer_instructions*/ None, - ); - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - && mask.mode != Some(ModeKind::Plan) - { - // Generic "global default" updates should not mutate the active Plan mask. - // Plan reasoning is controlled by the Plan preset and Plan-only override updates. - mask.reasoning_effort = Some(effort); - } - self.refresh_model_dependent_surfaces(); - } - - /// Set the personality in the widget's config copy. - pub(crate) fn set_personality(&mut self, personality: Personality) { - self.config.personality = Some(personality); - } - - pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { - self.status_account_display.as_ref() - } - - pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { - self.runtime_model_provider_base_url.as_deref() - } - - #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn model_catalog(&self) -> Arc { - self.model_catalog.clone() - } - - pub(crate) fn current_plan_type(&self) -> Option { - self.plan_type - } - - pub(crate) fn has_chatgpt_account(&self) -> bool { - self.has_chatgpt_account - } - - pub(crate) fn update_account_state( - &mut self, - status_account_display: Option, - plan_type: Option, - has_chatgpt_account: bool, - ) { - self.status_account_display = status_account_display; - self.plan_type = plan_type; - self.has_chatgpt_account = has_chatgpt_account; - self.bottom_pane - .set_connectors_enabled(self.connectors_enabled()); - } - - pub(crate) fn set_realtime_audio_device( - &mut self, - kind: RealtimeAudioDeviceKind, - name: Option, - ) { - match kind { - RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name, - RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name, - } - } - - /// Set the syntax theme override in the widget's config copy. - pub(crate) fn set_tui_theme(&mut self, theme: Option) { - self.config.tui_theme = theme; - } - - /// Set the model in the widget's config copy and stored collaboration mode. - pub(crate) fn set_model(&mut self, model: &str) { - self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model.to_string()), - /*effort*/ None, - /*developer_instructions*/ None, - ); - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - { - mask.model = Some(model.to_string()); - } - self.refresh_model_dependent_surfaces(); - } - - pub(crate) fn current_model(&self) -> &str { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.model(); - } - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.model.as_deref()) - .unwrap_or_else(|| self.current_collaboration_mode.model()) - } - - pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_live() - } - - fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { - match kind { - RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(), - RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(), - } - } - - fn current_realtime_audio_selection_label(&self, kind: RealtimeAudioDeviceKind) -> String { - self.current_realtime_audio_device_name(kind) - .unwrap_or_else(|| "System default".to_string()) - } - - fn sync_personality_command_enabled(&mut self) { - self.bottom_pane - .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); - } - - fn sync_plugins_command_enabled(&mut self) { - self.bottom_pane - .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); - } - - fn sync_goal_command_enabled(&mut self) { - self.bottom_pane - .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); - } - - fn sync_mentions_v2_enabled(&mut self) { - self.bottom_pane - .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); - } - - fn current_model_supports_personality(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_personality) - }) - .unwrap_or(false) - } - - /// Return whether the effective model currently advertises image-input support. - /// - /// We intentionally default to `true` when model metadata cannot be read so transient catalog - /// failures do not hard-block user input in the UI. - fn current_model_supports_images(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.input_modalities.contains(&InputModality::Image)) - }) - .unwrap_or(true) - } - - fn sync_image_paste_enabled(&mut self) { - let enabled = self.current_model_supports_images(); - self.bottom_pane.set_image_paste_enabled(enabled); - } - - fn image_inputs_not_supported_message(&self) -> String { - format!( - "Model {} does not support image inputs. Remove images or switch models.", - self.current_model() - ) - } - - #[allow(dead_code)] // Used in tests - pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { - &self.current_collaboration_mode - } - - pub(crate) fn current_reasoning_effort(&self) -> Option { - self.effective_reasoning_effort() - } - - #[cfg(test)] - pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { - self.active_mode_kind() - } - - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - - fn collaboration_modes_enabled(&self) -> bool { - true - } - - /// Returns the dismissal scope that applies to the currently visible draft. - fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope { - self.thread_id - .map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread) - } - - /// Returns whether the current draft should replace the normal footer with the Plan-mode nudge. - /// - /// `ChatWidget` owns this policy because it can combine lexical draft matching with mode - /// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders - /// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a - /// mode switch while the user is intentionally composing another local command. - fn should_show_plan_mode_nudge(&self) -> bool { - let text = self.bottom_pane.composer_text(); - let trimmed = text.trim_start(); - self.collaboration_modes_enabled() - && collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some() - && self.active_mode_kind() != ModeKind::Plan - && self.bottom_pane.composer_input_enabled() - && !self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() - && !trimmed.starts_with('/') - && !trimmed.starts_with('!') - && contains_plan_keyword(&text) - && !self - .dismissed_plan_mode_nudge_scopes - .contains(&self.plan_mode_nudge_scope()) - } - - /// Synchronizes the footer presentation with the current Plan-mode nudge policy. - fn refresh_plan_mode_nudge(&mut self) { - self.bottom_pane - .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); - } - - /// Hides the nudge for the current thread scope until the user changes conversation context. - fn dismiss_plan_mode_nudge(&mut self) { - self.dismissed_plan_mode_nudge_scopes - .insert(self.plan_mode_nudge_scope()); - self.refresh_plan_mode_nudge(); - } - - fn initial_collaboration_mask( - _config: &Config, - model_catalog: &ModelCatalog, - model_override: Option<&str>, - ) -> Option { - let mut mask = collaboration_modes::default_mask(model_catalog)?; - if let Some(model_override) = model_override { - mask.model = Some(model_override.to_string()); - } - Some(mask) - } - - fn active_mode_kind(&self) -> ModeKind { - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.mode) - .unwrap_or(ModeKind::Default) - } - - fn effective_reasoning_effort(&self) -> Option { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.reasoning_effort(); - } - let current_effort = self.current_collaboration_mode.reasoning_effort(); - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.reasoning_effort) - .unwrap_or(current_effort) - } - - fn effective_collaboration_mode(&self) -> CollaborationMode { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.clone(); - } - self.active_collaboration_mask.as_ref().map_or_else( - || self.current_collaboration_mode.clone(), - |mask| self.current_collaboration_mode.apply_mask(mask), - ) - } - - fn refresh_model_display(&mut self) { - let effective = self.effective_collaboration_mode(); - self.session_header.set_model(effective.model()); - // Keep composer paste affordances aligned with the currently effective model. - self.sync_image_paste_enabled(); - self.sync_service_tier_commands(); - self.refresh_terminal_title(); - } - - /// Refresh every UI surface that depends on the effective model, reasoning - /// effort, or collaboration mode. - /// - /// Call this at the end of any setter that mutates `current_collaboration_mode`, - /// `active_collaboration_mask`, or per-mode reasoning-effort overrides. - /// Consolidating both refreshes here prevents the bug where callers update the - /// header/title (`refresh_model_display`) but forget the footer status line - /// (`refresh_status_line`). - fn refresh_model_dependent_surfaces(&mut self) { - self.refresh_model_display(); - self.refresh_status_line(); - } - - fn model_display_name(&self) -> &str { - let model = self.current_model(); - if model.is_empty() { - DEFAULT_MODEL_DISPLAY_NAME - } else { - model - } - } - - /// Get the label for the current collaboration mode. - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.collaboration_modes_enabled() { - return None; - } - let active_mode = self.active_mode_kind(); - active_mode - .is_tui_visible() - .then_some(active_mode.display_name()) - } - - fn collaboration_mode_indicator(&self) -> Option { - if !self.collaboration_modes_enabled() { - return None; - } - match self.active_mode_kind() { - ModeKind::Plan => Some(CollaborationModeIndicator::Plan), - ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None, - } - } - - fn update_collaboration_mode_indicator(&mut self) { - let indicator = self.collaboration_mode_indicator(); - let goal_indicator = if indicator.is_none() { - self.goal_status_indicator(Instant::now()) - } else { - None - }; - self.current_goal_status_indicator = goal_indicator.clone(); - self.bottom_pane.set_collaboration_mode_indicator(indicator); - self.bottom_pane.set_goal_status_indicator(goal_indicator); - } - - fn refresh_goal_status_indicator_for_time_tick(&mut self) { - if self.collaboration_mode_indicator().is_some() { - return; - } - let goal_indicator = self.goal_status_indicator(Instant::now()); - if goal_indicator != self.current_goal_status_indicator { - self.current_goal_status_indicator = goal_indicator.clone(); - self.bottom_pane.set_goal_status_indicator(goal_indicator); - } - } - - fn goal_status_indicator(&self, now: Instant) -> Option { - if !self.config.features.enabled(Feature::Goals) { - return None; - } - self.current_goal_status.as_ref().and_then(|state| { - state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at) - }) - } - - fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { - if let Some(active_thread_id) = self.thread_id - && active_thread_id.to_string() != goal.thread_id - { - return; - } - if !self.config.features.enabled(Feature::Goals) { - self.current_goal_status_indicator = None; - self.current_goal_status = None; - self.update_collaboration_mode_indicator(); - return; - } - if goal.status == AppThreadGoalStatus::BudgetLimited - && let Some(turn_id) = turn_id - { - self.turn_lifecycle.mark_budget_limited(turn_id); - } - self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); - self.update_collaboration_mode_indicator(); - } - - fn personality_label(personality: Personality) -> &'static str { - match personality { - Personality::None => "None", - Personality::Friendly => "Friendly", - Personality::Pragmatic => "Pragmatic", - } - } - - fn personality_description(personality: Personality) -> &'static str { - match personality { - Personality::None => "No personality instructions.", - Personality::Friendly => "Warm, collaborative, and helpful.", - Personality::Pragmatic => "Concise, task-focused, and direct.", - } - } - - /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - - if let Some(next_mask) = collaboration_modes::next_mask( - self.model_catalog.as_ref(), - self.active_collaboration_mask.as_ref(), - ) { - self.set_collaboration_mask(next_mask); - } - } - - /// Update the active collaboration mask. - /// - /// When collaboration modes are enabled and a preset is selected, - /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { - if !self.collaboration_modes_enabled() { - return; - } - let previous_mode = self.active_mode_kind(); - let previous_model = self.current_model().to_string(); - let previous_effort = self.effective_reasoning_effort(); - if mask.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort - { - mask.reasoning_effort = Some(Some(effort)); - } - if mask.mode == Some(ModeKind::Plan) { - self.dismissed_plan_mode_nudge_scopes - .insert(self.plan_mode_nudge_scope()); - } - self.active_collaboration_mask = Some(mask); - self.update_collaboration_mode_indicator(); - self.refresh_plan_mode_nudge(); - self.refresh_model_dependent_surfaces(); - let next_mode = self.active_mode_kind(); - let next_model = self.current_model(); - let next_effort = self.effective_reasoning_effort(); - if previous_mode != next_mode - && (previous_model != next_model || previous_effort != next_effort) - { - let mut message = format!("Model changed to {next_model}"); - if !next_model.starts_with("codex-auto-") { - let reasoning_label = match next_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - }; - message.push(' '); - message.push_str(reasoning_label); - } - message.push_str(" for "); - message.push_str(next_mode.display_name()); - message.push_str(" mode."); - self.add_info_message(message, /*hint*/ None); - } - self.request_redraw(); - } - - fn connectors_enabled(&self) -> bool { - self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account - } - - fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { - if !self.connectors_enabled() { - return None; - } - - if let Some(snapshot) = &self.connectors.partial_snapshot { - return Some(snapshot.connectors.as_slice()); - } - - match &self.connectors.cache { - ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), - _ => None, - } - } - fn plugins_for_mentions(&self) -> Option<&[PluginCapabilitySummary]> { if !self.config.features.enabled(Feature::Plugins) { return None; @@ -5416,220 +2282,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_connectors_output(&mut self) { - if !self.connectors_enabled() { - self.add_info_message( - "Apps are disabled.".to_string(), - Some("Enable the apps feature to use $ or /apps.".to_string()), - ); - return; - } - - let connectors_cache = self.connectors.cache.clone(); - let should_force_refetch = !self.connectors.prefetch_in_flight - || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); - self.prefetch_connectors_with_options(should_force_refetch); - - match connectors_cache { - ConnectorsCacheState::Ready(snapshot) => { - if snapshot.connectors.is_empty() { - self.add_info_message("No apps available.".to_string(), /*hint*/ None); - } else { - self.open_connectors_popup(&snapshot.connectors); - } - } - ConnectorsCacheState::Failed(err) => { - self.add_to_history(history_cell::new_error_event(err)); - } - ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { - self.open_connectors_loading_popup(); - } - } - self.request_redraw(); - } - - fn open_connectors_loading_popup(&mut self) { - if !self.bottom_pane.replace_selection_view_if_active( - CONNECTORS_SELECTION_VIEW_ID, - self.connectors_loading_popup_params(), - ) { - self.bottom_pane - .show_selection_view(self.connectors_loading_popup_params()); - } - } - - fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { - self.bottom_pane.show_selection_view( - self.connectors_popup_params(connectors, /*selected_connector_id*/ None), - ); - } - - fn connectors_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Apps".bold())); - header.push(Line::from("Loading installed and available apps...".dim())); - - SelectionViewParams { - view_id: Some(CONNECTORS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Loading apps...".to_string(), - description: Some("This updates when the full list is ready.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn connectors_popup_params( - &self, - connectors: &[AppInfo], - selected_connector_id: Option<&str>, - ) -> SelectionViewParams { - let total = connectors.len(); - let installed = connectors - .iter() - .filter(|connector| connector.is_accessible) - .count(); - let mut header = ColumnRenderable::new(); - header.push(Line::from("Apps".bold())); - header.push(Line::from( - "Use $ to insert an installed app into your prompt.".dim(), - )); - header.push(Line::from( - format!("Installed {installed} of {total} available apps.").dim(), - )); - let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { - connectors - .iter() - .position(|connector| connector.id == selected_connector_id) - }); - let mut items: Vec = Vec::with_capacity(connectors.len()); - for connector in connectors { - let connector_label = codex_connectors::metadata::connector_display_label(connector); - let connector_title = connector_label.clone(); - let link_description = Self::connector_description(connector); - let description = Self::connector_brief_description(connector); - let status_label = Self::connector_status_label(connector); - let search_value = format!("{connector_label} {}", connector.id); - let mut item = SelectionItem { - name: connector_label, - description: Some(description), - search_value: Some(search_value), - ..Default::default() - }; - let is_installed = connector.is_accessible; - let selected_label = if is_installed { - format!( - "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." - ) - } else { - format!("{status_label}. Press Enter to open the app page to install this app.") - }; - let missing_label = format!("{status_label}. App link unavailable."); - let instructions = if connector.is_accessible { - "Manage this app in your browser." - } else { - "Install this app in your browser, then reload Codex." - }; - if let Some(install_url) = connector.install_url.clone() { - let app_id = connector.id.clone(); - let is_enabled = connector.is_enabled; - let title = connector_title.clone(); - let instructions = instructions.to_string(); - let description = link_description.clone(); - item.actions = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAppLink { - app_id: app_id.clone(), - title: title.clone(), - description: description.clone(), - instructions: instructions.clone(), - url: install_url.clone(), - is_installed, - is_enabled, - }); - })]; - item.dismiss_on_select = true; - item.selected_description = Some(selected_label); - } else { - let missing_label_for_action = missing_label.clone(); - item.actions = vec![Box::new(move |tx| { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - missing_label_for_action.clone(), - /*hint*/ None, - ), - ))); - })]; - item.dismiss_on_select = true; - item.selected_description = Some(missing_label); - } - items.push(item); - } - - SelectionViewParams { - view_id: Some(CONNECTORS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search apps".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - initial_selected_idx, - ..Default::default() - } - } - - fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { - let selected_connector_id = - if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( - self.bottom_pane - .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), - &self.connectors.cache, - ) { - snapshot - .connectors - .get(selected_index) - .map(|connector| connector.id.as_str()) - } else { - None - }; - let _ = self.bottom_pane.replace_selection_view_if_active( - CONNECTORS_SELECTION_VIEW_ID, - self.connectors_popup_params(connectors, selected_connector_id), - ); - } - - fn connector_brief_description(connector: &AppInfo) -> String { - let status_label = Self::connector_status_label(connector); - match Self::connector_description(connector) { - Some(description) => format!("{status_label} · {description}"), - None => status_label.to_string(), - } - } - - fn connector_status_label(connector: &AppInfo) -> &'static str { - if connector.is_accessible { - if connector.is_enabled { - "Installed" - } else { - "Installed · Disabled" - } - } else { - "Can be installed" - } - } - - fn connector_description(connector: &AppInfo) -> Option { - connector - .description - .as_deref() - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) - } - /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -5989,100 +2641,6 @@ impl ChatWidget { self.refresh_plugin_mentions(); } - pub(crate) fn on_connectors_loaded( - &mut self, - result: Result, - is_final: bool, - ) { - let mut trigger_pending_force_refetch = false; - if is_final { - self.connectors.prefetch_in_flight = false; - if self.connectors.force_refetch_pending { - self.connectors.force_refetch_pending = false; - trigger_pending_force_refetch = true; - } - } - - match result { - Ok(mut snapshot) => { - if !is_final { - snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( - Vec::new(), - snapshot.connectors, - /*all_connectors_loaded*/ false, - ); - } - snapshot.connectors = - chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); - if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { - let enabled_by_id: HashMap<&str, bool> = existing_snapshot - .connectors - .iter() - .map(|connector| (connector.id.as_str(), connector.is_enabled)) - .collect(); - for connector in &mut snapshot.connectors { - if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { - connector.is_enabled = *is_enabled; - } - } - } - if is_final { - self.connectors.partial_snapshot = None; - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - } else { - self.connectors.partial_snapshot = Some(snapshot.clone()); - } - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } - Err(err) => { - let partial_snapshot = self.connectors.partial_snapshot.take(); - if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { - warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); - self.bottom_pane - .set_connectors_snapshot(Some(snapshot.clone())); - } else if let Some(snapshot) = partial_snapshot { - warn!( - "failed to load full apps list; falling back to installed apps snapshot: {err}" - ); - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } else { - self.connectors.cache = ConnectorsCacheState::Failed(err); - self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); - } - } - } - - if trigger_pending_force_refetch { - self.prefetch_connectors_with_options(/*force_refetch*/ true); - } - } - - pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { - let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { - return; - }; - - let mut changed = false; - for connector in &mut snapshot.connectors { - if connector.id == connector_id { - changed = connector.is_enabled != enabled; - connector.is_enabled = enabled; - break; - } - } - - if !changed { - return; - } - - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } - pub(crate) fn refresh_plugin_mentions(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.bottom_pane.set_plugin_mentions(/*plugins*/ None); diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 0cd5429baf..942f2e9da8 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -1,5 +1,6 @@ -//! Connector list cache state for `ChatWidget`. +//! Connector cache, popup, and refresh handling for `ChatWidget`. +use super::*; use crate::app_event::ConnectorsSnapshot; #[derive(Debug, Clone, Default)] @@ -18,3 +19,415 @@ pub(super) struct ConnectorsState { pub(super) prefetch_in_flight: bool, pub(super) force_refetch_pending: bool, } + +impl ChatWidget { + pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { + self.prefetch_connectors_with_options(force_refetch); + } + + pub(super) fn prefetch_connectors(&mut self) { + self.prefetch_connectors_with_options(/*force_refetch*/ false); + } + + fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { + if !self.connectors_enabled() { + return; + } + if self.connectors.prefetch_in_flight { + if force_refetch { + self.connectors.force_refetch_pending = true; + } + return; + } + + self.connectors.prefetch_in_flight = true; + if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { + self.connectors.cache = ConnectorsCacheState::Loading; + } + + let config = self.config.clone(); + let environment_manager = Arc::clone(&self.environment_manager); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let accessible_result = + match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + force_refetch, + &environment_manager, + ) + .await + { + Ok(connectors) => connectors, + Err(err) => { + app_event_tx.send(AppEvent::ConnectorsLoaded { + result: Err(format!("Failed to load apps: {err}")), + is_final: true, + }); + return; + } + }; + let should_schedule_force_refetch = + !force_refetch && !accessible_result.codex_apps_ready; + let accessible_connectors = accessible_result.connectors; + + app_event_tx.send(AppEvent::ConnectorsLoaded { + result: Ok(ConnectorsSnapshot { + connectors: accessible_connectors.clone(), + }), + is_final: false, + }); + + let result: Result = async { + let all_connectors = + chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) + .await?; + let connectors = chatgpt_connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ); + Ok(ConnectorsSnapshot { connectors }) + } + .await + .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); + + app_event_tx.send(AppEvent::ConnectorsLoaded { + result, + is_final: true, + }); + + if should_schedule_force_refetch { + app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } + }); + } + + pub(super) fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account + } + + pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { + if !self.connectors_enabled() { + return None; + } + + if let Some(snapshot) = &self.connectors.partial_snapshot { + return Some(snapshot.connectors.as_slice()); + } + + match &self.connectors.cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), + _ => None, + } + } + + pub(crate) fn add_connectors_output(&mut self) { + if !self.connectors_enabled() { + self.add_info_message( + "Apps are disabled.".to_string(), + Some("Enable the apps feature to use $ or /apps.".to_string()), + ); + return; + } + + let connectors_cache = self.connectors.cache.clone(); + let should_force_refetch = !self.connectors.prefetch_in_flight + || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); + self.prefetch_connectors_with_options(should_force_refetch); + + match connectors_cache { + ConnectorsCacheState::Ready(snapshot) => { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), /*hint*/ None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err)); + } + ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { + self.open_connectors_loading_popup(); + } + } + self.request_redraw(); + } + + fn open_connectors_loading_popup(&mut self) { + if !self.bottom_pane.replace_selection_view_if_active( + CONNECTORS_SELECTION_VIEW_ID, + self.connectors_loading_popup_params(), + ) { + self.bottom_pane + .show_selection_view(self.connectors_loading_popup_params()); + } + } + + fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { + self.bottom_pane.show_selection_view( + self.connectors_popup_params(connectors, /*selected_connector_id*/ None), + ); + } + + fn connectors_loading_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from("Loading installed and available apps...".dim())); + + SelectionViewParams { + view_id: Some(CONNECTORS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Loading apps...".to_string(), + description: Some("This updates when the full list is ready.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn connectors_popup_params( + &self, + connectors: &[AppInfo], + selected_connector_id: Option<&str>, + ) -> SelectionViewParams { + let total = connectors.len(); + let installed = connectors + .iter() + .filter(|connector| connector.is_accessible) + .count(); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from( + "Use $ to insert an installed app into your prompt.".dim(), + )); + header.push(Line::from( + format!("Installed {installed} of {total} available apps.").dim(), + )); + let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { + connectors + .iter() + .position(|connector| connector.id == selected_connector_id) + }); + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = codex_connectors::metadata::connector_display_label(connector); + let connector_title = connector_label.clone(); + let link_description = Self::connector_description(connector); + let description = Self::connector_brief_description(connector); + let status_label = Self::connector_status_label(connector); + let search_value = format!("{connector_label} {}", connector.id); + let mut item = SelectionItem { + name: connector_label, + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + let is_installed = connector.is_accessible; + let selected_label = if is_installed { + format!( + "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." + ) + } else { + format!("{status_label}. Press Enter to open the app page to install this app.") + }; + let missing_label = format!("{status_label}. App link unavailable."); + let instructions = if connector.is_accessible { + "Manage this app in your browser." + } else { + "Install this app in your browser, then reload Codex." + }; + if let Some(install_url) = connector.install_url.clone() { + let app_id = connector.id.clone(); + let is_enabled = connector.is_enabled; + let title = connector_title.clone(); + let instructions = instructions.to_string(); + let description = link_description.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAppLink { + app_id: app_id.clone(), + title: title.clone(), + description: description.clone(), + instructions: instructions.clone(), + url: install_url.clone(), + is_installed, + is_enabled, + }); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(selected_label); + } else { + let missing_label_for_action = missing_label.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + missing_label_for_action.clone(), + /*hint*/ None, + ), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(missing_label); + } + items.push(item); + } + + SelectionViewParams { + view_id: Some(CONNECTORS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search apps".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + initial_selected_idx, + ..Default::default() + } + } + + fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { + let selected_connector_id = + if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( + self.bottom_pane + .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), + &self.connectors.cache, + ) { + snapshot + .connectors + .get(selected_index) + .map(|connector| connector.id.as_str()) + } else { + None + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + CONNECTORS_SELECTION_VIEW_ID, + self.connectors_popup_params(connectors, selected_connector_id), + ); + } + + fn connector_brief_description(connector: &AppInfo) -> String { + let status_label = Self::connector_status_label(connector); + match Self::connector_description(connector) { + Some(description) => format!("{status_label} · {description}"), + None => status_label.to_string(), + } + } + + fn connector_status_label(connector: &AppInfo) -> &'static str { + if connector.is_accessible { + if connector.is_enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + "Can be installed" + } + } + + fn connector_description(connector: &AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + + pub(crate) fn on_connectors_loaded( + &mut self, + result: Result, + is_final: bool, + ) { + let mut trigger_pending_force_refetch = false; + if is_final { + self.connectors.prefetch_in_flight = false; + if self.connectors.force_refetch_pending { + self.connectors.force_refetch_pending = false; + trigger_pending_force_refetch = true; + } + } + + match result { + Ok(mut snapshot) => { + if !is_final { + snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( + Vec::new(), + snapshot.connectors, + /*all_connectors_loaded*/ false, + ); + } + snapshot.connectors = + chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); + if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { + let enabled_by_id: HashMap<&str, bool> = existing_snapshot + .connectors + .iter() + .map(|connector| (connector.id.as_str(), connector.is_enabled)) + .collect(); + for connector in &mut snapshot.connectors { + if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { + connector.is_enabled = *is_enabled; + } + } + } + if is_final { + self.connectors.partial_snapshot = None; + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + } else { + self.connectors.partial_snapshot = Some(snapshot.clone()); + } + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } + Err(err) => { + let partial_snapshot = self.connectors.partial_snapshot.take(); + if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { + warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); + self.bottom_pane + .set_connectors_snapshot(Some(snapshot.clone())); + } else if let Some(snapshot) = partial_snapshot { + warn!( + "failed to load full apps list; falling back to installed apps snapshot: {err}" + ); + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } else { + self.connectors.cache = ConnectorsCacheState::Failed(err); + self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); + } + } + } + + if trigger_pending_force_refetch { + self.prefetch_connectors_with_options(/*force_refetch*/ true); + } + } + + pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { + let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { + return; + }; + + let mut changed = false; + for connector in &mut snapshot.connectors { + if connector.id == connector_id { + changed = connector.is_enabled != enabled; + connector.is_enabled = enabled; + break; + } + } + + if !changed { + return; + } + + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } +} diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs new file mode 100644 index 0000000000..0ee91c11fa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -0,0 +1,583 @@ +//! Model, collaboration, and reasoning popups for `ChatWidget`. +//! +//! These surfaces are tightly related because changing one often redirects +//! into another, especially while Plan mode is active. + +use super::*; + +impl ChatWidget { + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. + pub(crate) fn open_model_popup(&mut self) { + if !self.is_session_configured() { + self.add_info_message( + "Model selection is disabled until startup completes.".to_string(), + /*hint*/ None, + ); + return; + } + + let presets: Vec = match self.model_catalog.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment.".to_string(), + /*hint*/ None, + ); + return; + } + }; + self.open_model_popup_with_presets(presets); + } + + fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { + let title = title.to_string(); + let subtitle = subtitle.to_string(); + let mut header = ColumnRenderable::new(); + header.push(Line::from(title.bold())); + header.push(Line::from(subtitle.dim())); + if let Some(warning) = self.model_menu_warning_line() { + header.push(warning); + } + Box::new(header) + } + + fn model_menu_warning_line(&self) -> Option> { + let base_url = self.custom_openai_base_url()?; + let warning = format!( + "Warning: OpenAI base URL is overridden to {base_url}. Selecting models may not be supported or work properly." + ); + Some(Line::from(warning.red())) + } + + fn custom_openai_base_url(&self) -> Option { + if !self.config.model_provider.is_openai() { + return None; + } + + let base_url = self.config.model_provider.base_url.as_ref()?; + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed.trim_end_matches('/'); + if normalized == DEFAULT_OPENAI_BASE_URL { + return None; + } + + Some(trimmed.to_string()) + } + + pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { + let presets: Vec = presets + .into_iter() + .filter(|preset| preset.show_in_picker) + .collect(); + + let current_model = self.current_model(); + let current_label = presets + .iter() + .find(|preset| preset.model.as_str() == current_model) + .map(|preset| preset.model.to_string()) + .unwrap_or_else(|| self.model_display_name().to_string()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model.as_str(), + Some(preset.default_reasoning_effort), + ); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + should_prompt_plan_mode_scope, + ); + SelectionItem { + name: model.clone(), + description, + is_current: model.as_str() == current_model, + is_default: preset.is_default, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let header = self.model_menu_header( + "Select Model", + "Pick a quick auto mode or browse all models.", + ); + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + /*hint*/ None, + ); + return; + } + + let mut items: Vec = Vec::new(); + for preset in presets.into_iter() { + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); + let is_current = preset.model.as_str() == self.current_model(); + let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; + let preset_for_action = preset.clone(); + let actions: Vec = vec![Box::new(move |tx| { + let preset_for_event = preset_for_action.clone(); + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_event, + }); + })]; + items.push(SelectionItem { + name: preset.model.clone(), + description, + is_current, + is_default: preset.is_default, + actions, + dismiss_on_select: single_supported_effort, + dismiss_parent_on_child_accept: !single_supported_effort, + ..Default::default() + }); + } + + let header = self.model_menu_header( + "Select Model and Effort", + "Access legacy models by running codex -m or in your config.toml", + ); + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + items, + header, + ..Default::default() + }); + } + + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + /*hint*/ None, + ); + return; + } + + let current_kind = self + .active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .or_else(|| { + collaboration_modes::default_mask(self.model_catalog.as_ref()) + .and_then(|mask| mask.mode) + }); + let items: Vec = presets + .into_iter() + .map(|mask| { + let name = mask.name.clone(); + let is_current = current_kind == mask.mode; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); + })]; + SelectionItem { + name, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + should_prompt_plan_mode_scope: bool, + ) -> Vec { + vec![Box::new(move |tx| { + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: effort_for_action, + }); + return; + } + + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + })] + } + + fn should_prompt_plan_mode_reasoning_scope( + &self, + selected_model: &str, + selected_effort: Option, + ) -> bool { + if !self.collaboration_modes_enabled() + || self.active_mode_kind() != ModeKind::Plan + || selected_model != self.current_model() + { + return false; + } + + // Prompt whenever the selection is not a true no-op for both: + // 1) the active Plan-mode effective reasoning, and + // 2) the stored global defaults that would be updated by the fallback path. + selected_effort != self.effective_reasoning_effort() + || selected_model != self.current_collaboration_mode.model() + || selected_effort != self.current_collaboration_mode.reasoning_effort() + } + + pub(crate) fn open_plan_reasoning_scope_prompt( + &mut self, + model: String, + effort: Option, + ) { + let reasoning_phrase = match effort { + Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), + Some(selected_effort) => { + format!( + "{} reasoning", + Self::reasoning_effort_label(selected_effort).to_lowercase() + ) + } + None => "the selected reasoning".to_string(), + }; + let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); + let plan_reasoning_source = if let Some(plan_override) = + self.config.plan_mode_reasoning_effort + { + format!( + "user-chosen Plan override ({})", + Self::reasoning_effort_label(plan_override).to_lowercase() + ) + } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) + { + match plan_mask.reasoning_effort.flatten() { + Some(plan_effort) => format!( + "built-in Plan default ({})", + Self::reasoning_effort_label(plan_effort).to_lowercase() + ), + None => "built-in Plan default (no reasoning)".to_string(), + } + } else { + "built-in Plan default".to_string() + }; + let all_modes_description = format!( + "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." + ); + let subtitle = format!("Choose where to apply {reasoning_phrase}."); + + let plan_only_actions: Vec = vec![Box::new({ + let model = model.clone(); + move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + })]; + let all_modes_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + })]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), + description: Some(plan_only_description), + actions: plan_only_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), + description: Some(all_modes_description), + actions: all_modes_actions, + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + self.notify(Notification::PlanModePrompt { + title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), + }); + } + + /// Open a popup to choose the reasoning effort (stage 2) for the given model. + pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let supported = preset.supported_reasoning_efforts; + let in_plan_mode = + self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; + + let warn_effort = if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::XHigh) + { + Some(ReasoningEffortConfig::XHigh) + } else if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::High) + { + Some(ReasoningEffortConfig::High) + } else { + None + }; + let warning_text = warn_effort.map(|effort| { + let effort_label = Self::reasoning_effort_label(effort); + format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") + }); + let warn_for_model = preset.model.starts_with("gpt-5.1-codex") + || preset.model.starts_with("gpt-5.1-codex-max") + || preset.model.starts_with("gpt-5.2"); + + struct EffortChoice { + stored: Option, + display: ReasoningEffortConfig, + } + let mut choices: Vec = Vec::new(); + for effort in ReasoningEffortConfig::iter() { + if supported.iter().any(|option| option.effort == effort) { + choices.push(EffortChoice { + stored: Some(effort), + display: effort, + }); + } + } + if choices.is_empty() { + choices.push(EffortChoice { + stored: Some(default_effort), + display: default_effort, + }); + } + + if choices.len() == 1 { + let selected_effort = choices.first().and_then(|c| c.stored); + let selected_model = preset.model; + if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + self.app_event_tx + .send(AppEvent::OpenPlanReasoningScopePrompt { + model: selected_model, + effort: selected_effort, + }); + } else { + self.apply_model_and_effort(selected_model, selected_effort); + } + return; + } + + let default_choice: Option = choices + .iter() + .any(|choice| choice.stored == Some(default_effort)) + .then_some(Some(default_effort)) + .flatten() + .or_else(|| choices.iter().find_map(|choice| choice.stored)) + .or(Some(default_effort)); + + let model_slug = preset.model.to_string(); + let is_current_model = self.current_model() == preset.model.as_str(); + let highlight_choice = if is_current_model { + if in_plan_mode { + self.config + .plan_mode_reasoning_effort + .or(self.effective_reasoning_effort()) + } else { + self.effective_reasoning_effort() + } + } else { + default_choice + }; + let selection_choice = highlight_choice.or(default_choice); + let initial_selected_idx = choices + .iter() + .position(|choice| choice.stored == selection_choice) + .or_else(|| { + selection_choice + .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) + }); + let mut items: Vec = Vec::new(); + for choice in choices.iter() { + let effort = choice.display; + let mut effort_label = Self::reasoning_effort_label(effort).to_string(); + if choice.stored == default_choice { + effort_label.push_str(" (default)"); + } + + let description = choice + .stored + .and_then(|effort| { + supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) + }) + .filter(|text| !text.is_empty()); + + let show_warning = warn_for_model && warn_effort == Some(effort); + let selected_description = if show_warning { + warning_text.as_ref().map(|warning_message| { + description.as_ref().map_or_else( + || warning_message.clone(), + |d| format!("{d}\n{warning_message}"), + ) + }) + } else { + None + }; + + let model_for_action = model_slug.clone(); + let choice_effort = choice.stored; + let should_prompt_plan_mode_scope = + self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let actions: Vec = vec![Box::new(move |tx| { + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: choice_effort, + }); + } else { + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: choice_effort, + }); + } + })]; + + items.push(SelectionItem { + name: effort_label, + description, + selected_description, + is_current: is_current_model && choice.stored == highlight_choice, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + match effort { + ReasoningEffortConfig::None => "None", + ReasoningEffortConfig::Minimal => "Minimal", + ReasoningEffortConfig::Low => "Low", + ReasoningEffortConfig::Medium => "Medium", + ReasoningEffortConfig::High => "High", + ReasoningEffortConfig::XHigh => "Extra high", + } + } + + pub(super) fn apply_model_and_effort_without_persist( + &self, + model: String, + effort: Option, + ) { + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + } + + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.apply_model_and_effort_without_persist(model.clone(), effort); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } +} diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs new file mode 100644 index 0000000000..dc428b4092 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -0,0 +1,463 @@ +//! Permission and approval popup flows for `ChatWidget`. +//! +//! This module owns the generic permission pickers and confirmation surfaces; +//! Windows-specific sandbox prompting lives beside it in +//! `windows_sandbox_prompts`. + +use super::*; + +impl ChatWidget { + /// Open the permissions popup. + pub(crate) fn open_approvals_popup(&mut self) { + self.open_permissions_popup(); + } + + /// Open a popup to choose the permissions mode. + pub(crate) fn open_permissions_popup(&mut self) { + let include_read_only = cfg!(target_os = "windows"); + let current_approval = + AskForApproval::from(self.config.permissions.approval_policy.value()); + let current_permission_profile = self.config.permissions.permission_profile(); + let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); + let current_review_policy = self.config.approvals_reviewer; + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + + #[cfg(target_os = "windows")] + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + #[cfg(target_os = "windows")] + let windows_degraded_sandbox_enabled = + matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + #[cfg(not(target_os = "windows"))] + let windows_degraded_sandbox_enabled = false; + + let show_elevate_sandbox_hint = + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); + + let guardian_disabled_reason = |enabled: bool| { + let mut next_features = self.config.features.get().clone(); + next_features.set_enabled(Feature::GuardianApproval, enabled); + self.config + .features + .can_set(&next_features) + .err() + .map(|err| err.to_string()) + }; + + for preset in presets.into_iter() { + if !include_read_only && preset.id == "read-only" { + continue; + } + let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { + "Default (non-admin sandbox)".to_string() + } else { + preset.label.to_string() + }; + let preset_approval = AskForApproval::from(preset.approval); + let base_description = + Some(preset.description.replace(" (Identical to Agent mode)", "")); + let approval_disabled_reason = match self + .config + .permissions + .approval_policy + .can_set(&preset.approval) + { + Ok(()) => None, + Err(err) => Some(err.to_string()), + }; + let default_disabled_reason = approval_disabled_reason + .clone() + .or_else(|| guardian_disabled_reason(false)); + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let default_actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + return_to_permissions: !include_read_only, + }); + })] + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let preset_clone = preset.clone(); + if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + { + vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + } + } else { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + }; + if preset.id == "auto" { + items.push(SelectionItem { + name: base_name.clone(), + description: base_description.clone(), + is_current: current_review_policy == ApprovalsReviewer::User + && Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: default_actions, + dismiss_on_select: true, + disabled_reason: default_disabled_reason, + ..Default::default() + }); + + if guardian_approval_enabled { + items.push(SelectionItem { + name: "Auto-review".to_string(), + description: Some( + "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." + .to_string(), + ), + is_current: current_review_policy == ApprovalsReviewer::AutoReview + && Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + "Auto-review".to_string(), + ApprovalsReviewer::AutoReview, + ), + dismiss_on_select: true, + disabled_reason: approval_disabled_reason + .or_else(|| guardian_disabled_reason(true)), + ..Default::default() + }); + } + } else { + items.push(SelectionItem { + name: base_name, + description: base_description, + is_current: Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: default_actions, + dismiss_on_select: true, + disabled_reason: default_disabled_reason, + ..Default::default() + }); + } + } + + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), + "/setup-default-sandbox".cyan(), + ".".dim(), + ] + .into() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Update Model Permissions".to_string()), + footer_note, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(()), + ..Default::default() + }); + } + + pub(crate) fn open_auto_review_denials_popup(&mut self) { + if self.review.recent_auto_review_denials.is_empty() { + self.add_info_message( + "No recent auto-review denials in this thread.".to_string(), + Some("Denials are recorded after auto-review rejects an action.".to_string()), + ); + return; + } + let Some(thread_id) = self.thread_id() else { + self.add_error_message("That thread is no longer available.".to_string()); + return; + }; + + let mut items = vec![SelectionItem { + name: "Command".to_string(), + description: Some("Rationale".to_string()), + is_disabled: true, + search_value: Some(String::new()), + ..Default::default() + }]; + items.extend( + self.review + .recent_auto_review_denials + .entries() + .map(|event| { + let id = event.id.clone(); + let summary = auto_review_denials::action_summary(&event.action); + let rationale = event + .rationale + .as_deref() + .unwrap_or("Auto-review did not include a rationale."); + SelectionItem { + name: summary.clone(), + description: Some(rationale.to_string()), + selected_description: Some(rationale.to_string()), + search_value: Some(format!("{summary} {rationale}")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ApproveRecentAutoReviewDenial { + thread_id, + id: id.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }), + ); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Auto-review Denials".to_string()), + subtitle: Some("Select a denied action to approve.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }); + self.request_redraw(); + } + + pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { + let Some(event) = self.review.recent_auto_review_denials.take(&id) else { + self.add_error_message("That auto-review denial is no longer available.".to_string()); + return; + }; + + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::approve_guardian_denied_action(event), + }); + self.add_info_message( + "Approval recorded for one retry of the selected auto-review denial.".to_string(), + Some( + "The model will see the approval context; the retry still goes through auto-review." + .to_string(), + ), + ); + } + + pub(super) fn approval_preset_actions( + approval: AskForApproval, + permission_profile: PermissionProfile, + label: String, + approvals_reviewer: ApprovalsReviewer, + ) -> Vec { + vec![Box::new(move |tx| { + let permission_profile_clone = permission_profile.clone(); + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + Some(approval), + Some(approvals_reviewer), + Some(permission_profile_clone.clone()), + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); + tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + format!("Permissions updated to {label}"), + /*hint*/ None, + ), + ))); + })] + } + + pub(super) fn preset_matches_current( + current_approval: AskForApproval, + current_permission_profile: &PermissionProfile, + cwd: &std::path::Path, + preset: &ApprovalPreset, + ) -> bool { + let preset_approval = AskForApproval::from(preset.approval); + if current_approval != preset_approval { + return false; + } + + match preset.id { + "full-access" => matches!(current_permission_profile, PermissionProfile::Disabled), + "read-only" => { + let file_system_policy = current_permission_profile.file_system_sandbox_policy(); + matches!( + current_permission_profile, + PermissionProfile::Managed { .. } + ) && !file_system_policy.has_full_disk_write_access() + && file_system_policy + .get_writable_roots_with_cwd(cwd) + .is_empty() + && current_permission_profile.network_sandbox_policy() + == preset.permission_profile.network_sandbox_policy() + } + "auto" => { + let file_system_policy = current_permission_profile.file_system_sandbox_policy(); + matches!( + current_permission_profile, + PermissionProfile::Managed { .. } + ) && file_system_policy.can_write_path_with_cwd(cwd, cwd) + && !file_system_policy.has_full_disk_write_access() + && current_permission_profile.network_sandbox_policy() + == preset.permission_profile.network_sandbox_policy() + } + _ => current_permission_profile == &preset.permission_profile, + } + } + + pub(crate) fn open_full_access_confirmation( + &mut self, + preset: ApprovalPreset, + return_to_permissions: bool, + ) { + let selected_name = preset.label.to_string(); + let approval = AskForApproval::from(preset.approval); + let permission_profile = preset.permission_profile; + 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, + permission_profile.clone(), + selected_name.clone(), + ApprovalsReviewer::User, + ); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions( + approval, + permission_profile, + selected_name, + ApprovalsReviewer::User, + ); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(move |tx| { + if return_to_permissions { + tx.send(AppEvent::OpenPermissionsPopup); + } else { + 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() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 8d5bd89aee..6b5414536d 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,5 +1,6 @@ -//! Rate-limit warning and prompt state for `ChatWidget`. +//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. +use super::*; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; @@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind( pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { matches!(info, AppServerCodexErrorInfo::CyberPolicy) } + +impl ChatWidget { + pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(mut snapshot) = snapshot { + let limit_id = snapshot + .limit_id + .clone() + .unwrap_or_else(|| "codex".to_string()); + let limit_label = snapshot + .limit_name + .clone() + .unwrap_or_else(|| limit_id.clone()); + if snapshot.credits.is_none() { + snapshot.credits = self + .rate_limit_snapshots_by_limit_id + .get(&limit_id) + .and_then(|display| display.credits.as_ref()) + .map(|credits| CreditsSnapshot { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance.clone(), + }); + } + + self.plan_type = snapshot.plan_type.or(self.plan_type); + + let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit + && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type + { + self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); + } + let warnings = if is_codex_limit { + self.rate_limit_warnings.take_warnings( + snapshot + .secondary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .secondary + .as_ref() + .and_then(|window| window.window_duration_mins), + snapshot + .primary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_duration_mins), + ) + } else { + vec![] + }; + + let high_usage = is_codex_limit + && (snapshot + .secondary + .as_ref() + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false) + || snapshot + .primary + .as_ref() + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false)); + + let has_workspace_credits = snapshot + .credits + .as_ref() + .map(|credits| credits.has_credits) + .unwrap_or(false); + + if high_usage + && !has_workspace_credits + && !self.rate_limit_switch_prompt_hidden() + && self.current_model() != NUDGE_MODEL_SLUG + && !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; + } + + let display = + rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + self.rate_limit_snapshots_by_limit_id + .insert(limit_id, display); + + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } else { + self.rate_limit_snapshots_by_limit_id.clear(); + self.codex_rate_limit_reached_type = None; + } + self.refresh_status_line(); + } + + pub(super) fn stop_rate_limit_poller(&mut self) {} + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn should_prefetch_rate_limits(&self) -> bool { + self.config.model_provider.requires_openai_auth && self.has_chatgpt_account + } + + fn lower_cost_preset(&self) -> Option { + let models = self.model_catalog.try_list_models().ok()?; + models + .iter() + .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) + .cloned() + } + + fn rate_limit_switch_prompt_hidden(&self) -> bool { + self.config + .notices + .hide_rate_limit_model_nudge + .unwrap_or(false) + } + + pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.rate_limit_switch_prompt_hidden() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + return; + } + if !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + if let Some(preset) = self.lower_cost_preset() { + self.open_rate_limit_switch_prompt(preset); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; + } else { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { + let switch_model = preset.model; + let switch_model_for_events = switch_model.clone(); + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + + let switch_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + Some(switch_model_for_events.clone()), + Some(Some(default_effort)), + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); + tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + })]; + + let keep_actions: Vec = Vec::new(); + let never_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); + tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); + })]; + let description = if preset.description.is_empty() { + Some("Uses fewer credits for upcoming turns.".to_string()) + } else { + Some(preset.description) + }; + + let items = vec![ + SelectionItem { + name: format!("Switch to {switch_model}"), + description, + selected_description: None, + is_current: false, + actions: switch_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model".to_string(), + description: None, + selected_description: None, + is_current: false, + actions: keep_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model (never show again)".to_string(), + description: Some( + "Hide future rate limit reminders about switching models.".to_string(), + ), + selected_description: None, + is_current: false, + actions: never_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Approaching rate limits".to_string()), + subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(super) fn open_workspace_owner_nudge_prompt( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) { + if self.add_credits_nudge_email_in_flight.is_some() { + return; + } + + let (title, prompt) = match credit_type { + AddCreditsNudgeCreditType::Credits => ( + "You've reached your workspace credit limit", + "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", + ), + AddCreditsNudgeCreditType::UsageLimit => ( + "Usage limit reached", + "Request a limit increase from your owner to continue using codex. Request increase?", + ), + }; + let send_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); + })]; + let items = vec![ + SelectionItem { + name: "Yes".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + actions: send_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), + is_default: true, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(title.to_string()), + subtitle: Some(prompt.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(1), + ..Default::default() + }); + } + + pub(crate) fn start_add_credits_nudge_email_request( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) -> bool { + self.add_credits_nudge_email_in_flight = Some(credit_type); + true + } + + pub(crate) fn finish_add_credits_nudge_email_request( + &mut self, + result: Result, + ) { + let credit_type = self + .add_credits_nudge_email_in_flight + .take() + .unwrap_or(AddCreditsNudgeCreditType::Credits); + let message = match (credit_type, result) { + (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Workspace owner notified." + } + ( + AddCreditsNudgeCreditType::Credits, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "Workspace owner was already notified recently.", + (AddCreditsNudgeCreditType::Credits, Err(_)) => { + "Could not notify your workspace owner. Please try again." + } + (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Limit increase requested." + } + ( + AddCreditsNudgeCreditType::UsageLimit, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "A limit increase was already requested recently.", + (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { + "Could not request a limit increase. Please try again." + } + }; + self.add_to_history(history_cell::new_info_event( + message.to_string(), + /*hint*/ None, + )); + self.request_redraw(); + } + + pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { + self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + if hidden { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs new file mode 100644 index 0000000000..419070ef9e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -0,0 +1,611 @@ +//! Runtime settings state and model/collaboration coordination for `ChatWidget`. + +use super::*; + +impl ChatWidget { + /// Set the approval policy in the widget's config copy. + pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { + if let Err(err) = self + .config + .permissions + .approval_policy + .set(policy.to_core()) + { + tracing::warn!(%err, "failed to set approval_policy on chat config"); + } else { + self.refresh_status_surfaces(); + } + } + + /// Set the permission profile in the widget's config copy. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_permission_profile( + &mut self, + profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.config.permissions.set_permission_profile(profile)?; + self.refresh_status_surfaces(); + Ok(()) + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { + self.config.permissions.windows_sandbox_mode = mode; + #[cfg(target_os = "windows")] + self.bottom_pane.set_windows_degraded_sandbox_active( + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { + if let Err(err) = self.config.features.set_enabled(feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.key(), + "failed to update constrained chat widget feature state" + ); + } + let enabled = self.config.features.enabled(feature); + if feature == Feature::RealtimeConversation { + let realtime_conversation_enabled = self.realtime_conversation_enabled(); + self.bottom_pane + .set_realtime_conversation_enabled(realtime_conversation_enabled); + self.bottom_pane + .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); + if !realtime_conversation_enabled && self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(Some( + "Realtime voice mode was closed because the feature was disabled.".to_string(), + )); + } + } + if feature == Feature::FastMode { + self.sync_service_tier_commands(); + } + if feature == Feature::Personality { + self.sync_personality_command_enabled(); + } + if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); + self.refresh_plugin_mentions(); + } + if feature == Feature::Goals { + self.sync_goal_command_enabled(); + if !enabled { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.turn_lifecycle.goal_status_active_turn_started_at = None; + self.turn_lifecycle.budget_limited_turn_ids.clear(); + self.update_collaboration_mode_indicator(); + } + } + if feature == Feature::MentionsV2 { + self.sync_mentions_v2_enabled(); + } + if feature == Feature::PreventIdleSleep { + self.turn_lifecycle.set_prevent_idle_sleep(enabled); + } + #[cfg(target_os = "windows")] + if matches!( + feature, + Feature::WindowsSandbox | Feature::WindowsSandboxElevated + ) { + self.bottom_pane.set_windows_degraded_sandbox_active( + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + } + enabled + } + + pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { + self.config.approvals_reviewer = policy; + self.refresh_status_surfaces(); + } + + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + + pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_world_writable_warning = Some(acknowledged); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn world_writable_warning_hidden(&self) -> bool { + self.config + .notices + .hide_world_writable_warning + .unwrap_or(false) + } + + /// Override the reasoning effort used when Plan mode is active. + /// + /// When the active mask is already Plan, the override is applied immediately + /// so the footer reflects it without waiting for the next mode switch. + /// Passing `None` resets to the Plan-mode preset default. + pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { + self.config.plan_mode_reasoning_effort = effort; + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode == Some(ModeKind::Plan) + { + if let Some(effort) = effort { + mask.reasoning_effort = Some(Some(effort)); + } else if let Some(plan_mask) = + collaboration_modes::plan_mask(self.model_catalog.as_ref()) + { + mask.reasoning_effort = plan_mask.reasoning_effort; + } + } + self.refresh_model_dependent_surfaces(); + } + + /// Set the reasoning effort for the non-Plan collaboration mode. + /// + /// Does not touch the active Plan mask — Plan reasoning is controlled + /// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`. + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + /*model*/ None, + Some(effort), + /*developer_instructions*/ None, + ); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode != Some(ModeKind::Plan) + { + // Generic "global default" updates should not mutate the active Plan mask. + // Plan reasoning is controlled by the Plan preset and Plan-only override updates. + mask.reasoning_effort = Some(effort); + } + self.refresh_model_dependent_surfaces(); + } + + /// Set the personality in the widget's config copy. + pub(crate) fn set_personality(&mut self, personality: Personality) { + self.config.personality = Some(personality); + } + + pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { + self.status_account_display.as_ref() + } + + pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { + self.runtime_model_provider_base_url.as_deref() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn model_catalog(&self) -> Arc { + self.model_catalog.clone() + } + + pub(crate) fn current_plan_type(&self) -> Option { + self.plan_type + } + + pub(crate) fn has_chatgpt_account(&self) -> bool { + self.has_chatgpt_account + } + + pub(crate) fn update_account_state( + &mut self, + status_account_display: Option, + plan_type: Option, + has_chatgpt_account: bool, + ) { + self.status_account_display = status_account_display; + self.plan_type = plan_type; + self.has_chatgpt_account = has_chatgpt_account; + self.bottom_pane + .set_connectors_enabled(self.connectors_enabled()); + } + + pub(crate) fn set_realtime_audio_device( + &mut self, + kind: RealtimeAudioDeviceKind, + name: Option, + ) { + match kind { + RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name, + RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name, + } + } + + /// Set the syntax theme override in the widget's config copy. + pub(crate) fn set_tui_theme(&mut self, theme: Option) { + self.config.tui_theme = theme; + } + + /// Set the model in the widget's config copy and stored collaboration mode. + pub(crate) fn set_model(&mut self, model: &str) { + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model.to_string()), + /*effort*/ None, + /*developer_instructions*/ None, + ); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.model = Some(model.to_string()); + } + self.refresh_model_dependent_surfaces(); + } + + pub(crate) fn current_model(&self) -> &str { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.model(); + } + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.as_deref()) + .unwrap_or_else(|| self.current_collaboration_mode.model()) + } + + pub(crate) fn realtime_conversation_is_live(&self) -> bool { + self.realtime_conversation.is_live() + } + + pub(super) fn current_realtime_audio_device_name( + &self, + kind: RealtimeAudioDeviceKind, + ) -> Option { + match kind { + RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(), + RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(), + } + } + + pub(super) fn current_realtime_audio_selection_label( + &self, + kind: RealtimeAudioDeviceKind, + ) -> String { + self.current_realtime_audio_device_name(kind) + .unwrap_or_else(|| "System default".to_string()) + } + + pub(super) fn sync_personality_command_enabled(&mut self) { + self.bottom_pane + .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); + } + + pub(super) fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + + pub(super) fn sync_goal_command_enabled(&mut self) { + self.bottom_pane + .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); + } + + pub(super) fn sync_mentions_v2_enabled(&mut self) { + self.bottom_pane + .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); + } + + pub(super) fn current_model_supports_personality(&self) -> bool { + let model = self.current_model(); + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.supports_personality) + }) + .unwrap_or(false) + } + + /// Return whether the effective model currently advertises image-input support. + /// + /// We intentionally default to `true` when model metadata cannot be read so transient catalog + /// failures do not hard-block user input in the UI. + pub(super) fn current_model_supports_images(&self) -> bool { + let model = self.current_model(); + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.input_modalities.contains(&InputModality::Image)) + }) + .unwrap_or(true) + } + + pub(super) fn sync_image_paste_enabled(&mut self) { + let enabled = self.current_model_supports_images(); + self.bottom_pane.set_image_paste_enabled(enabled); + } + + pub(super) fn image_inputs_not_supported_message(&self) -> String { + format!( + "Model {} does not support image inputs. Remove images or switch models.", + self.current_model() + ) + } + + #[allow(dead_code)] // Used in tests + pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { + &self.current_collaboration_mode + } + + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.effective_reasoning_effort() + } + + #[cfg(test)] + pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { + self.active_mode_kind() + } + + pub(super) fn is_session_configured(&self) -> bool { + self.thread_id.is_some() + } + + pub(super) fn collaboration_modes_enabled(&self) -> bool { + true + } + + /// Returns the dismissal scope that applies to the currently visible draft. + fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope { + self.thread_id + .map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread) + } + + /// Returns whether the current draft should replace the normal footer with the Plan-mode nudge. + /// + /// `ChatWidget` owns this policy because it can combine lexical draft matching with mode + /// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders + /// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a + /// mode switch while the user is intentionally composing another local command. + pub(super) fn should_show_plan_mode_nudge(&self) -> bool { + let text = self.bottom_pane.composer_text(); + let trimmed = text.trim_start(); + self.collaboration_modes_enabled() + && collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some() + && self.active_mode_kind() != ModeKind::Plan + && self.bottom_pane.composer_input_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() + && !trimmed.starts_with('/') + && !trimmed.starts_with('!') + && contains_plan_keyword(&text) + && !self + .dismissed_plan_mode_nudge_scopes + .contains(&self.plan_mode_nudge_scope()) + } + + /// Synchronizes the footer presentation with the current Plan-mode nudge policy. + pub(super) fn refresh_plan_mode_nudge(&mut self) { + self.bottom_pane + .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); + } + + /// Hides the nudge for the current thread scope until the user changes conversation context. + pub(super) fn dismiss_plan_mode_nudge(&mut self) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + self.refresh_plan_mode_nudge(); + } + + pub(super) fn initial_collaboration_mask( + _config: &Config, + model_catalog: &ModelCatalog, + model_override: Option<&str>, + ) -> Option { + let mut mask = collaboration_modes::default_mask(model_catalog)?; + if let Some(model_override) = model_override { + mask.model = Some(model_override.to_string()); + } + Some(mask) + } + + pub(super) fn active_mode_kind(&self) -> ModeKind { + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .unwrap_or(ModeKind::Default) + } + + pub(super) fn effective_reasoning_effort(&self) -> Option { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.reasoning_effort(); + } + let current_effort = self.current_collaboration_mode.reasoning_effort(); + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.reasoning_effort) + .unwrap_or(current_effort) + } + + pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.clone(); + } + self.active_collaboration_mask.as_ref().map_or_else( + || self.current_collaboration_mode.clone(), + |mask| self.current_collaboration_mode.apply_mask(mask), + ) + } + + pub(super) fn refresh_model_display(&mut self) { + let effective = self.effective_collaboration_mode(); + self.session_header.set_model(effective.model()); + // Keep composer paste affordances aligned with the currently effective model. + self.sync_image_paste_enabled(); + self.sync_service_tier_commands(); + self.refresh_terminal_title(); + } + + /// Refresh every UI surface that depends on the effective model, reasoning + /// effort, or collaboration mode. + /// + /// Call this at the end of any setter that mutates `current_collaboration_mode`, + /// `active_collaboration_mask`, or per-mode reasoning-effort overrides. + /// Consolidating both refreshes here prevents the bug where callers update the + /// header/title (`refresh_model_display`) but forget the footer status line + /// (`refresh_status_line`). + pub(super) fn refresh_model_dependent_surfaces(&mut self) { + self.refresh_model_display(); + self.refresh_status_line(); + } + + pub(super) fn model_display_name(&self) -> &str { + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + pub(super) fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + let active_mode = self.active_mode_kind(); + active_mode + .is_tui_visible() + .then_some(active_mode.display_name()) + } + + fn collaboration_mode_indicator(&self) -> Option { + if !self.collaboration_modes_enabled() { + return None; + } + match self.active_mode_kind() { + ModeKind::Plan => Some(CollaborationModeIndicator::Plan), + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None, + } + } + + pub(super) fn update_collaboration_mode_indicator(&mut self) { + let indicator = self.collaboration_mode_indicator(); + let goal_indicator = if indicator.is_none() { + self.goal_status_indicator(Instant::now()) + } else { + None + }; + self.current_goal_status_indicator = goal_indicator.clone(); + self.bottom_pane.set_collaboration_mode_indicator(indicator); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + + pub(super) fn refresh_goal_status_indicator_for_time_tick(&mut self) { + if self.collaboration_mode_indicator().is_some() { + return; + } + let goal_indicator = self.goal_status_indicator(Instant::now()); + if goal_indicator != self.current_goal_status_indicator { + self.current_goal_status_indicator = goal_indicator.clone(); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + } + + fn goal_status_indicator(&self, now: Instant) -> Option { + if !self.config.features.enabled(Feature::Goals) { + return None; + } + self.current_goal_status.as_ref().and_then(|state| { + state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at) + }) + } + + pub(super) fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { + if let Some(active_thread_id) = self.thread_id + && active_thread_id.to_string() != goal.thread_id + { + return; + } + if !self.config.features.enabled(Feature::Goals) { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.update_collaboration_mode_indicator(); + return; + } + if goal.status == AppThreadGoalStatus::BudgetLimited + && let Some(turn_id) = turn_id + { + self.turn_lifecycle.mark_budget_limited(turn_id); + } + self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); + self.update_collaboration_mode_indicator(); + } + + /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). + pub(super) fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mask) = collaboration_modes::next_mask( + self.model_catalog.as_ref(), + self.active_collaboration_mask.as_ref(), + ) { + self.set_collaboration_mask(next_mask); + } + } + + /// Update the active collaboration mask. + /// + /// When collaboration modes are enabled and a preset is selected, + /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { + if !self.collaboration_modes_enabled() { + return; + } + let previous_mode = self.active_mode_kind(); + let previous_model = self.current_model().to_string(); + let previous_effort = self.effective_reasoning_effort(); + if mask.mode == Some(ModeKind::Plan) + && let Some(effort) = self.config.plan_mode_reasoning_effort + { + mask.reasoning_effort = Some(Some(effort)); + } + if mask.mode == Some(ModeKind::Plan) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + } + self.active_collaboration_mask = Some(mask); + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + self.refresh_model_dependent_surfaces(); + let next_mode = self.active_mode_kind(); + let next_model = self.current_model(); + let next_effort = self.effective_reasoning_effort(); + if previous_mode != next_mode + && (previous_model != next_model || previous_effort != next_effort) + { + let mut message = format!("Model changed to {next_model}"); + if !next_model.starts_with("codex-auto-") { + let reasoning_label = match next_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + }; + message.push(' '); + message.push_str(reasoning_label); + } + message.push_str(" for "); + message.push_str(next_mode.display_name()); + message.push_str(" mode."); + self.add_info_message(message, /*hint*/ None); + } + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs new file mode 100644 index 0000000000..cdd38ad3a8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -0,0 +1,285 @@ +//! Settings-adjacent popup surfaces for `ChatWidget`. +//! +//! This keeps theme, personality, audio-device, and experimental-feature UI +//! out of the main orchestration module without changing their event wiring. + +use super::*; + +impl ChatWidget { + pub(super) fn open_theme_picker(&mut self) { + let codex_home = crate::legacy_core::config::find_codex_home().ok(); + let terminal_width = self + .last_rendered_width + .get() + .and_then(|width| u16::try_from(width).ok()); + let params = crate::theme_picker::build_theme_picker_params( + self.config.tui_theme.as_deref(), + codex_home.as_deref(), + terminal_width, + ); + self.bottom_pane.show_selection_view(params); + } + + pub(crate) fn open_personality_popup(&mut self) { + if !self.is_session_configured() { + self.add_info_message( + "Personality selection is disabled until startup completes.".to_string(), + /*hint*/ None, + ); + return; + } + if !self.current_model_supports_personality() { + let current_model = self.current_model(); + self.add_error_message(format!( + "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." + )); + return; + } + self.open_personality_popup_for_current_model(); + } + + fn open_personality_popup_for_current_model(&mut self) { + let current_personality = self.config.personality.unwrap_or(Personality::Friendly); + let personalities = [Personality::Friendly, Personality::Pragmatic]; + let supports_personality = self.current_model_supports_personality(); + + let items: Vec = personalities + .into_iter() + .map(|personality| { + let name = Self::personality_label(personality).to_string(); + let description = Some(Self::personality_description(personality).to_string()); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + Some(personality), + ))); + tx.send(AppEvent::UpdatePersonality(personality)); + tx.send(AppEvent::PersistPersonalitySelection { personality }); + })]; + SelectionItem { + name, + description, + is_current: current_personality == personality, + is_disabled: !supports_personality, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Select Personality".bold())); + header.push(Line::from("Choose a communication style for Codex.".dim())); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_realtime_audio_popup(&mut self) { + let items = [ + RealtimeAudioDeviceKind::Microphone, + RealtimeAudioDeviceKind::Speaker, + ] + .into_iter() + .map(|kind| { + let description = Some(format!( + "Current: {}", + self.current_realtime_audio_selection_label(kind) + )); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); + })]; + SelectionItem { + name: kind.title().to_string(), + description, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Settings".to_string()), + subtitle: Some("Configure settings for Codex.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { + match list_realtime_audio_device_names(kind) { + Ok(device_names) => { + self.open_realtime_audio_device_selection_with_names(kind, device_names); + } + Err(err) => { + self.add_error_message(format!( + "Failed to load realtime {} devices: {err}", + kind.noun() + )); + } + } + } + + #[cfg(target_os = "linux")] + pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { + let _ = kind; + } + + #[cfg(not(target_os = "linux"))] + pub(super) fn open_realtime_audio_device_selection_with_names( + &mut self, + kind: RealtimeAudioDeviceKind, + device_names: Vec, + ) { + let current_selection = self.current_realtime_audio_device_name(kind); + let current_available = current_selection + .as_deref() + .is_some_and(|name| device_names.iter().any(|device_name| device_name == name)); + let mut items = vec![SelectionItem { + name: "System default".to_string(), + description: Some("Use your operating system default device.".to_string()), + is_current: current_selection.is_none(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); + })], + dismiss_on_select: true, + ..Default::default() + }]; + + if let Some(selection) = current_selection.as_deref() + && !current_available + { + items.push(SelectionItem { + name: format!("Unavailable: {selection}"), + description: Some("Configured device is not currently available.".to_string()), + is_current: true, + is_disabled: true, + disabled_reason: Some("Reconnect the device or choose another one.".to_string()), + ..Default::default() + }); + } + + items.extend(device_names.into_iter().map(|device_name| { + let persisted_name = device_name.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { + kind, + name: Some(persisted_name.clone()), + }); + })]; + SelectionItem { + is_current: current_selection.as_deref() == Some(device_name.as_str()), + name: device_name, + actions, + dismiss_on_select: true, + ..Default::default() + } + })); + + let mut header = ColumnRenderable::new(); + header.push(Line::from(format!("Select {}", kind.title()).bold())); + header.push(Line::from( + "Saved devices apply to realtime voice only.".dim(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) { + let restart_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::RestartRealtimeAudioDevice { kind }); + })]; + let items = vec![ + SelectionItem { + name: "Restart now".to_string(), + description: Some(format!("Restart local {} audio now.", kind.noun())), + actions: restart_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Apply later".to_string(), + description: Some(format!( + "Keep the current {} until local audio starts again.", + kind.noun() + )), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + let mut header = ColumnRenderable::new(); + header.push(Line::from(format!("Restart {} now?", kind.title()).bold())); + header.push(Line::from( + "Configuration is saved. Restart local audio to use it immediately.".dim(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_experimental_popup(&mut self) { + let features: Vec = FEATURES + .iter() + .filter_map(|spec| { + let name = spec.stage.experimental_menu_name()?; + let description = spec.stage.experimental_menu_description()?; + Some(ExperimentalFeatureItem { + feature: spec.id, + name: name.to_string(), + description: description.to_string(), + enabled: self.config.features.enabled(spec.id), + }) + }) + .collect(); + + let view = ExperimentalFeaturesView::new( + features, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + fn personality_label(personality: Personality) -> &'static str { + match personality { + Personality::None => "None", + Personality::Friendly => "Friendly", + Personality::Pragmatic => "Pragmatic", + } + } + + fn personality_description(personality: Personality) -> &'static str { + match personality { + Personality::None => "No personality instructions.", + Personality::Friendly => "Warm, collaborative, and helpful.", + Personality::Pragmatic => "Concise, task-focused, and direct.", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs new file mode 100644 index 0000000000..70f213bee4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -0,0 +1,380 @@ +//! Status output and setup controls for `ChatWidget`. +//! +//! Rendering details live in `status_surfaces`; this module owns the mutable +//! widget entrypoints that apply status state, open setup views, and update the +//! history-facing `/status` surface. + +use super::*; + +impl ChatWidget { + /// Update the status indicator header and details. + /// + /// Passing `None` clears any existing details. + pub(super) fn set_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { + let details = details + .filter(|details| !details.is_empty()) + .map(|details| { + let trimmed = details.trim_start(); + match details_capitalization { + StatusDetailsCapitalization::CapitalizeFirst => { + crate::text_formatting::capitalize_first(trimmed) + } + StatusDetailsCapitalization::Preserve => trimmed.to_string(), + } + }); + self.status_state.set_status(StatusIndicatorState { + header: header.clone(), + details: details.clone(), + details_max_lines, + }); + self.bottom_pane.update_status( + header, + details, + StatusDetailsCapitalization::Preserve, + details_max_lines, + ); + let title_uses_status = self + .config + .tui_terminal_title + .as_ref() + .is_some_and(|items| { + items + .iter() + .any(|item| item == "run-state" || item == "status") + }); + if title_uses_status { + self.refresh_status_surfaces(); + } + } + + /// Convenience wrapper around [`Self::set_status`]; + /// updates the status indicator header and clears any existing details. + pub(super) fn set_status_header(&mut self, header: String) { + self.set_status( + header, + /*details*/ None, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + } + + /// Sets the currently rendered footer status-line value. + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + self.bottom_pane.set_status_line(status_line); + } + + /// Sets the terminal hyperlink target for the currently rendered footer status line. + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + self.bottom_pane.set_status_line_hyperlink(url); + } + + /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. + /// + /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the + /// user actually looking at?" and the footer stack remains a pure renderer of that decision. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { + self.bottom_pane.set_active_agent_label(active_agent_label); + } + + /// Recomputes footer status-line content from config and current runtime state. + /// + /// This method is the status-line orchestrator: it parses configured item identifiers, + /// warns once per session about invalid items, updates whether status-line mode is enabled, + /// schedules async git-branch lookup when needed, and renders only values that are currently + /// available. + /// + /// The omission behavior is intentional. If selected items are unavailable (for example before + /// a session id exists or before branch lookup completes), those items are skipped without + /// placeholders so the line remains compact and stable. + pub(crate) fn refresh_status_line(&mut self) { + self.refresh_status_surfaces(); + } + + /// Records that status-line setup was canceled. + /// + /// Cancellation is intentionally side-effect free for config state; the existing configuration + /// remains active and no persistence is attempted. + pub(crate) fn cancel_status_line_setup(&self) { + tracing::info!("Status line setup canceled by user"); + } + + /// Applies status-line item selection from the setup view to in-memory config. + /// + /// An empty selection persists as an explicit empty list. + pub(crate) fn setup_status_line(&mut self, items: Vec, use_theme_colors: bool) { + tracing::info!( + "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" + ); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_status_line = Some(ids); + self.config.tui_status_line_use_colors = use_theme_colors; + self.refresh_status_line(); + } + + /// Applies a temporary terminal-title selection while the setup UI is open. + pub(crate) fn preview_terminal_title(&mut self, items: Vec) { + if self.terminal_title_setup_original_items.is_none() { + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + } + + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Restores the terminal-title config that was active before the setup UI + /// opened, undoing any preview changes. No-op if no setup session is active. + pub(crate) fn revert_terminal_title_setup_preview(&mut self) { + let Some(original_items) = self.terminal_title_setup_original_items.take() else { + return; + }; + + self.config.tui_terminal_title = original_items; + self.refresh_terminal_title(); + } + + /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Commits a confirmed terminal-title selection, ending the setup session. + /// + /// After this call, `revert_terminal_title_setup_preview` becomes a no-op + /// because the original config snapshot is discarded. + pub(crate) fn setup_terminal_title(&mut self, items: Vec) { + tracing::info!("terminal title setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.terminal_title_setup_original_items = None; + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Stores async git-branch lookup results for the current status-line cwd. + /// + /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch + /// names after directory changes. + pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { + if self.status_line_branch_cwd.as_ref() != Some(&cwd) { + self.status_line_branch_pending = false; + return; + } + self.status_line_branch = branch; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = true; + self.refresh_status_surfaces(); + } + + /// Stores async Git summary lookup results for the current status-line cwd. + pub(crate) fn set_status_line_git_summary( + &mut self, + cwd: PathBuf, + summary: StatusLineGitSummary, + ) { + if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { + self.status_line_git_summary_pending = false; + return; + } + self.status_line_git_summary = Some(summary); + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = true; + self.refresh_status_surfaces(); + } + + pub(crate) fn add_status_output( + &mut self, + refreshing_rate_limits: bool, + request_id: Option, + ) { + let default_usage = TokenUsage::default(); + let token_info = self.token_info.as_ref(); + let total_usage = token_info + .map(|ti| &ti.total_token_usage) + .unwrap_or(&default_usage); + let collaboration_mode = self.collaboration_mode_label(); + let model = self.current_model().to_string(); + let model_default_reasoning_effort = + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.default_reasoning_effort) + }); + let reasoning_effort_override = Some( + self.effective_reasoning_effort() + .or(self.config.model_reasoning_effort) + .or(model_default_reasoning_effort), + ); + let rate_limit_snapshots: Vec = self + .rate_limit_snapshots_by_limit_id + .values() + .cloned() + .collect(); + let agents_summary = + crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); + let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( + &self.config, + self.runtime_model_provider_base_url.as_deref(), + self.status_account_display.as_ref(), + token_info, + total_usage, + &self.thread_id, + self.thread_name.clone(), + self.forked_from, + rate_limit_snapshots.as_slice(), + self.plan_type, + Local::now(), + self.model_display_name(), + collaboration_mode, + reasoning_effort_override, + agents_summary, + refreshing_rate_limits, + ); + if let Some(request_id) = request_id { + self.refreshing_status_outputs.push((request_id, handle)); + } + self.add_to_history(cell); + } + + pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { + if self.refreshing_status_outputs.is_empty() { + return; + } + + let rate_limit_snapshots: Vec = self + .rate_limit_snapshots_by_limit_id + .values() + .cloned() + .collect(); + let now = Local::now(); + let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); + let mut updated_any = false; + for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { + if pending_request_id == request_id { + updated_any = true; + handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); + } else { + remaining.push((pending_request_id, handle)); + } + } + self.refreshing_status_outputs = remaining; + if updated_any { + self.request_redraw(); + } + } + + pub(super) fn open_status_line_setup(&mut self) { + let configured_status_line_items = self.configured_status_line_items(); + let view = StatusLineSetupView::new( + Some(configured_status_line_items.as_slice()), + self.config.tui_status_line_use_colors, + self.status_surface_preview_data(), + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) fn open_terminal_title_setup(&mut self) { + let configured_terminal_title_items = self.configured_terminal_title_items(); + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + let view = TerminalTitleSetupView::new( + Some(configured_terminal_title_items.as_slice()), + self.terminal_title_preview_data(), + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { + StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + })) + } + + pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { + let mut preview_data = self.status_surface_preview_data(); + let now = Instant::now(); + for item in TerminalTitleItem::iter() { + let Some(preview_item) = item.preview_item() else { + continue; + }; + let Some(value) = self.terminal_title_value_for_item(item, now) else { + continue; + }; + preview_data.set_live(preview_item, value); + } + preview_data + } + + pub(super) fn status_line_context_window_size(&self) -> Option { + self.token_info + .as_ref() + .and_then(|info| info.model_context_window) + .or(self.config.model_context_window) + } + + pub(super) fn status_line_context_remaining_percent(&self) -> Option { + let Some(context_window) = self.status_line_context_window_size() else { + return Some(100); + }; + let default_usage = TokenUsage::default(); + let usage = self + .token_info + .as_ref() + .map(|info| &info.last_token_usage) + .unwrap_or(&default_usage); + Some( + usage + .percent_of_context_window_remaining(context_window) + .clamp(0, 100), + ) + } + + pub(super) fn status_line_context_used_percent(&self) -> Option { + let remaining = self.status_line_context_remaining_percent().unwrap_or(100); + Some((100 - remaining).clamp(0, 100)) + } + + pub(super) fn status_line_total_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|info| info.total_token_usage.clone()) + .unwrap_or_default() + } + + pub(super) fn status_line_limit_display( + &self, + window: Option<&RateLimitWindowDisplay>, + label: &str, + ) -> Option { + let window = window?; + let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); + Some(format!("{label} {remaining:.0}%")) + } + + pub(super) fn status_line_reasoning_effort_label( + effort: Option, + ) -> &'static str { + match effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4f4398d349..379ff7c6de 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,4 +1,6 @@ use super::*; +use crate::app_event::ConnectorsSnapshot; +use crate::chatwidget::connectors::ConnectorsCacheState; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4cc01d7591..53b60e0d8d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,5 +1,6 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; +use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs new file mode 100644 index 0000000000..3f6de0879f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -0,0 +1,446 @@ +//! Windows sandbox prompts and warning surfaces for `ChatWidget`. + +use super::*; + +impl ChatWidget { + #[cfg(target_os = "windows")] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + if self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + { + return None; + } + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let Ok(policy) = self + .config + .permissions + .permission_profile() + .to_legacy_sandbox_policy(self.config.cwd.as_path()) + else { + return Some((Vec::new(), 0, true)); + }; + match codex_windows_sandbox::apply_world_writable_scan_and_denies( + self.config.codex_home.as_path(), + cwd.as_path(), + &env_map, + &policy, + Some(self.config.codex_home.as_path()), + ) { + Ok(_) => None, + Err(_) => Some((Vec::new(), 0, true)), + } + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + None + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + preset: Option, + sample_paths: Vec, + extra_count: usize, + failed_scan: bool, + ) { + let (approval, permission_profile) = match &preset { + Some(p) => ( + Some(AskForApproval::from(p.approval)), + Some(p.permission_profile.clone()), + ), + None => (None, None), + }; + let mut header_children: Vec> = Vec::new(); + let describe_profile = |profile: &PermissionProfile| { + if matches!(profile, PermissionProfile::Disabled) { + "Full Access mode" + } else if profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path()) + { + "Agent mode" + } else { + "Read-Only mode" + } + }; + let mode_label = preset + .as_ref() + .map(|p| describe_profile(&p.permission_profile)) + .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); + let info_line = if failed_scan { + Line::from(vec![ + "We couldn't complete the world-writable scan, so protections cannot be verified. " + .into(), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.") + .fg(Color::Red), + ]) + } else { + Line::from(vec![ + "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), + " Consider removing write access for Everyone from the following folders:".into(), + ]) + }; + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + + if !sample_paths.is_empty() { + // Show up to three examples and optionally an "and X more" line. + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + for p in &sample_paths { + lines.push(Line::from(format!(" - {p}"))); + } + if extra_count > 0 { + lines.push(Line::from(format!("and {extra_count} more"))); + } + header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + } + let header = ColumnRenderable::with(header_children); + + // Build actions ensuring acknowledgement happens before applying the + // new permission profile, so downstream policy-change hooks don't + // re-trigger the warning. + let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan only when a preset will be applied via + // /permissions, to avoid duplicate warnings from the ensuing policy change. + if preset.is_some() { + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + } + if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { + accept_actions.extend(Self::approval_preset_actions( + approval, + permission_profile, + mode_label.to_string(), + ApprovalsReviewer::User, + )); + } + + let mut accept_and_remember_actions: Vec = Vec::new(); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })); + if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + permission_profile, + mode_label.to_string(), + ApprovalsReviewer::User, + )); + } + + let items = vec![ + SelectionItem { + name: "Continue".to_string(), + description: Some(format!("Apply {mode_label} for this session")), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Continue and don't warn again".to_string(), + description: Some(format!("Enable {mode_label} and remember this choice")), + actions: accept_and_remember_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() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + _preset: Option, + _sample_paths: Vec, + _extra_count: usize, + _failed_scan: bool, + ) { + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { + // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it + // directly (no elevation prompts). + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], + line!["Learn more: https://developers.openai.com/codex/windows"], + ]) + .wrap(Wrap { trim: false }), + )); + + let preset_clone = preset; + let items = vec![ + SelectionItem { + name: "Enable experimental sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Legacy, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Go back".to_string(), + description: None, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + return; + } + + self.session_telemetry.counter( + "codex.windows_sandbox.elevated_prompt_shown", + /*inc*/ 1, + &[], + ); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], + ]) + .wrap(Wrap { trim: false }), + )); + + let accept_otel = self.session_telemetry.clone(); + let legacy_otel = self.session_telemetry.clone(); + let legacy_preset = preset.clone(); + let quit_otel = self.session_telemetry.clone(); + let items = vec![ + SelectionItem { + name: "Set up default sandbox (requires Administrator permissions)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + accept_otel.counter( + "codex.windows_sandbox.elevated_prompt_accept", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + legacy_otel.counter( + "codex.windows_sandbox.elevated_prompt_use_legacy", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { + preset: legacy_preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.elevated_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + let mut lines = Vec::new(); + lines.push(line![ + "Couldn't set up your sandbox with Administrator permissions".bold() + ]); + lines.push(line![""]); + lines.push(line![ + "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." + ]); + lines.push(line![ + "Learn more " + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let elevated_preset = preset.clone(); + let legacy_preset = preset; + let quit_otel = self.session_telemetry.clone(); + let items = vec![ + SelectionItem { + name: "Try setting up admin sandbox again".to_string(), + description: None, + actions: vec![Box::new({ + let otel = self.session_telemetry.clone(); + let preset = elevated_preset; + move |tx| { + otel.counter( + "codex.windows_sandbox.fallback_retry_elevated", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Codex with non-admin sandbox".to_string(), + description: None, + actions: vec![Box::new({ + let otel = self.session_telemetry.clone(); + let preset = legacy_preset; + move |tx| { + otel.counter( + "codex.windows_sandbox.fallback_use_legacy", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { + preset: preset.clone(), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.fallback_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { + if show_now + && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled + && let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + { + self.open_windows_sandbox_enable_prompt(preset); + } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} + + #[cfg(target_os = "windows")] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) { + // While elevated sandbox setup runs, prevent typing so the user doesn't + // accidentally queue messages that will run under an unexpected mode. + self.bottom_pane.set_composer_input_enabled( + /*enabled*/ false, + Some("Input disabled until setup completes.".to_string()), + ); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ false); + self.set_status( + "Setting up sandbox...".to_string(), + Some("Hang tight, this may take a few minutes".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.bottom_pane.hide_status_indicator(); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} +} From 3d2a0b5517fada3b368165605a1b098f96d92b6c Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 13 May 2026 11:28:52 -0700 Subject: [PATCH 11/52] [codex] Scope Windows sandbox write-root capability SIDs (#21479) ## Summary - fix by scoping Windows workspace-write capability SIDs to active effective write roots - build legacy/elevated tokens from only the active effective write roots - align setup/audit deny ACL handling with active root-specific SIDs ## Testing - just fmt - git diff --check --cached - just argument-comment-lint - cargo check -p codex-windows-sandbox --locked (blocked by libwebrtc -> libyuv fetch: CONNECT tunnel failed, response 403) --- codex-rs/windows-sandbox-rs/src/audit.rs | 80 ++-- .../src/bin/setup_main/win.rs | 246 ++++++++--- codex-rs/windows-sandbox-rs/src/cap.rs | 77 ++++ .../windows-sandbox-rs/src/elevated_impl.rs | 38 +- codex-rs/windows-sandbox-rs/src/lib.rs | 252 ++++------- codex-rs/windows-sandbox-rs/src/setup.rs | 103 ++++- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 418 ++++++++++++++---- codex-rs/windows-sandbox-rs/src/token.rs | 27 ++ .../src/unified_exec/backends/legacy.rs | 56 ++- 9 files changed, 881 insertions(+), 416 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index c45e3341b7..c7e7fc0b11 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -2,14 +2,16 @@ use crate::acl::add_deny_write_ace; use crate::acl::path_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; +use crate::cap::workspace_write_cap_sid_for_root; +use crate::cap::workspace_write_root_contains_path; use crate::logging::debug_log; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; -use crate::token::convert_string_sid_to_sid; +use crate::setup::effective_write_roots_for_setup; +use crate::token::LocalSid; use crate::token::world_sid; use anyhow::Result; -use anyhow::anyhow; use std::collections::HashSet; use std::ffi::OsStr; use std::ffi::c_void; @@ -231,6 +233,7 @@ pub fn apply_world_writable_scan_and_denies( &flagged, sandbox_policy, cwd, + env_map, logs_base_dir, ) { log_note( @@ -246,6 +249,7 @@ pub fn apply_capability_denies_for_world_writable( flagged: &[PathBuf], sandbox_policy: &SandboxPolicy, cwd: &Path, + env_map: &std::collections::HashMap, logs_base_dir: Option<&Path>, ) -> Result<()> { if flagged.is_empty() { @@ -255,46 +259,56 @@ pub fn apply_capability_denies_for_world_writable( let cap_path = cap_sid_file(codex_home); let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; - let (active_sid, workspace_roots): (*mut c_void, Vec) = match sandbox_policy { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { - let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) } - .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?; - let mut roots: Vec = - vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; - for root in writable_roots { - let candidate = root.as_path(); - roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf())); - } - (sid, roots) + let (active_sids, workspace_roots): (Vec, Vec) = match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } => { + let roots = effective_write_roots_for_setup( + sandbox_policy, + cwd, + cwd, + env_map, + codex_home, + /*write_roots_override*/ None, + ); + let active_sids = roots + .iter() + .map(|root| { + workspace_write_cap_sid_for_root(codex_home, cwd, root) + .and_then(|sid| LocalSid::from_string(&sid)) + }) + .collect::>>()?; + (active_sids, roots) + } + SandboxPolicy::ReadOnly { .. } => { + (vec![LocalSid::from_string(&caps.readonly)?], Vec::new()) } - SandboxPolicy::ReadOnly { .. } => ( - unsafe { convert_string_sid_to_sid(&caps.readonly) } - .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for readonly capability"))?, - Vec::new(), - ), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return Ok(()); } }; for path in flagged { - if workspace_roots.iter().any(|root| path.starts_with(root)) { + if workspace_roots + .iter() + .any(|root| workspace_write_root_contains_path(root, path)) + { continue; } - let res = unsafe { add_deny_write_ace(path, active_sid) }; - match res { - Ok(true) => log_note( - &format!("AUDIT: applied capability deny ACE to {}", path.display()), - logs_base_dir, - ), - Ok(false) => {} - Err(err) => log_note( - &format!( - "AUDIT: failed to apply capability deny ACE to {}: {}", - path.display(), - err + for active_sid in &active_sids { + let res = unsafe { add_deny_write_ace(path, active_sid.as_ptr()) }; + match res { + Ok(true) => log_note( + &format!("AUDIT: applied capability deny ACE to {}", path.display()), + logs_base_dir, ), - logs_base_dir, - ), + Ok(false) => {} + Err(err) => log_note( + &format!( + "AUDIT: failed to apply capability deny ACE to {}: {}", + path.display(), + err + ), + logs_base_dir, + ), + } } } Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index d862f33eca..1f93d0a5ee 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -20,7 +20,6 @@ use codex_windows_sandbox::extract_setup_failure; use codex_windows_sandbox::hide_newly_created_users; use codex_windows_sandbox::install_wfp_filters; use codex_windows_sandbox::is_command_cwd_root; -use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::sandbox_bin_dir; @@ -29,7 +28,8 @@ use codex_windows_sandbox::sandbox_secrets_dir; use codex_windows_sandbox::string_from_sid_bytes; use codex_windows_sandbox::sync_persistent_deny_read_acls; use codex_windows_sandbox::to_wide; -use codex_windows_sandbox::workspace_cap_sid_for_cwd; +use codex_windows_sandbox::workspace_write_cap_sid_for_root; +use codex_windows_sandbox::workspace_write_root_overlaps_path; use codex_windows_sandbox::write_setup_error_report; use serde::Deserialize; use serde::Serialize; @@ -120,6 +120,42 @@ fn log_line(log: &mut File, msg: &str) -> Result<()> { Ok(()) } +fn workspace_write_cap_sids_for_path( + codex_home: &Path, + command_cwd: &Path, + write_roots: &[PathBuf], + path: &Path, +) -> Result> { + let mut sid_strs = Vec::new(); + for root in write_roots { + if workspace_write_root_overlaps_path(root, path) { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + root, + )?); + } + } + if sid_strs.is_empty() { + if write_roots.is_empty() { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + command_cwd, + )?); + } else { + for root in write_roots { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + root, + )?); + } + } + } + Ok(sid_strs) +} + fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; @@ -566,25 +602,6 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let sandbox_group_sid_str = string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?; - let caps = load_or_create_cap_sids(&payload.codex_home).map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperCapabilitySidFailed, - format!("load or create capability SIDs failed: {err}"), - )) - })?; - let cap_psid = unsafe { - convert_string_sid_to_sid(&caps.workspace).ok_or_else(|| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperCapabilitySidFailed, - format!("convert capability SID {} failed", caps.workspace), - )) - })? - }; - let workspace_sid_str = workspace_cap_sid_for_cwd(&payload.codex_home, &payload.command_cwd)?; - let workspace_psid = unsafe { - convert_string_sid_to_sid(&workspace_sid_str) - .ok_or_else(|| anyhow::anyhow!("convert workspace capability SID failed"))? - }; let mut refresh_errors: Vec = Vec::new(); if !refresh_only { let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist( @@ -681,10 +698,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( )?; } - let cap_sid_str = caps.workspace; let write_mask = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; - let mut grant_tasks: Vec = Vec::new(); + let mut grant_tasks: Vec<(PathBuf, String)> = Vec::new(); let mut seen_deny_paths: HashSet = HashSet::new(); let mut seen_write_roots: HashSet = HashSet::new(); @@ -706,16 +722,17 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let cap_label = if is_command_cwd { "workspace_cap" } else { - "cap" + "root_cap" }; - let cap_psid_for_root = if is_command_cwd { - workspace_psid - } else { - cap_psid + let root_cap_sid_str = + workspace_write_cap_sid_for_root(&payload.codex_home, &payload.command_cwd, root)?; + let root_cap_psid = unsafe { + convert_string_sid_to_sid(&root_cap_sid_str) + .ok_or_else(|| anyhow::anyhow!("convert write root capability SID failed"))? }; for (label, psid) in [ ("sandbox_group", sandbox_group_psid), - (cap_label, cap_psid_for_root), + (cap_label, root_cap_psid), ] { let has = match path_mask_allows(root, &[psid], write_mask, /*require_all_bits*/ true) { @@ -741,6 +758,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( need_grant = true; } } + unsafe { + LocalFree(root_cap_psid as HLOCAL); + } if need_grant { log_line( log, @@ -749,19 +769,14 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( root.display() ), )?; - grant_tasks.push(root.clone()); + grant_tasks.push((root.clone(), root_cap_sid_str)); } } let (tx, rx) = mpsc::channel::<(PathBuf, Result)>(); std::thread::scope(|scope| { - for root in grant_tasks { - let is_command_cwd = is_command_cwd_root(&root, &canonical_command_cwd); - let sid_strings = if is_command_cwd { - vec![sandbox_group_sid_str.clone(), workspace_sid_str.clone()] - } else { - vec![sandbox_group_sid_str.clone(), cap_sid_str.clone()] - }; + for (root, root_cap_sid_str) in grant_tasks { + let sid_strings = vec![sandbox_group_sid_str.clone(), root_cap_sid_str]; let tx = tx.clone(); scope.spawn(move || { // Convert SID strings to psids locally in this thread. @@ -823,27 +838,36 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( .with_context(|| format!("failed to create deny-write path {}", path.display()))?; } - let canonical_path = canonicalize_path(path); - let deny_psid = if canonical_path.starts_with(&canonical_command_cwd) { - workspace_psid - } else { - cap_psid - }; + let deny_sid_strs = workspace_write_cap_sids_for_path( + &payload.codex_home, + &payload.command_cwd, + &payload.write_roots, + path, + )?; + for deny_sid_str in deny_sid_strs { + let deny_psid = unsafe { + convert_string_sid_to_sid(&deny_sid_str) + .ok_or_else(|| anyhow::anyhow!("convert deny capability SID failed"))? + }; - match unsafe { add_deny_write_ace(path, deny_psid) } { - Ok(true) => { - log_line( - log, - &format!("applied deny ACE to protect {}", path.display()), - )?; + match unsafe { add_deny_write_ace(path, deny_psid) } { + Ok(true) => { + log_line( + log, + &format!("applied deny ACE to protect {}", path.display()), + )?; + } + Ok(false) => {} + Err(err) => { + refresh_errors.push(format!("deny ACE failed on {}: {err}", path.display())); + log_line( + log, + &format!("deny ACE failed on {}: {err}", path.display()), + )?; + } } - Ok(false) => {} - Err(err) => { - refresh_errors.push(format!("deny ACE failed on {}: {err}", path.display())); - log_line( - log, - &format!("deny ACE failed on {}: {err}", path.display()), - )?; + unsafe { + LocalFree(deny_psid as HLOCAL); } } } @@ -924,12 +948,6 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( if !sandbox_group_psid.is_null() { LocalFree(sandbox_group_psid as HLOCAL); } - if !cap_psid.is_null() { - LocalFree(cap_psid as HLOCAL); - } - if !workspace_psid.is_null() { - LocalFree(workspace_psid as HLOCAL); - } } if refresh_only && !refresh_errors.is_empty() { log_line( @@ -946,9 +964,13 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( mod tests { use super::Payload; use super::SETUP_VERSION; + use super::workspace_write_cap_sids_for_path; use codex_otel::StatsigMetricsSettings; + use codex_windows_sandbox::load_or_create_cap_sids; + use codex_windows_sandbox::workspace_write_cap_sid_for_root; use pretty_assertions::assert_eq; use serde_json::json; + use std::fs; fn payload_json() -> serde_json::Value { json!({ @@ -986,4 +1008,104 @@ mod tests { }) ); } + + #[test] + fn deny_path_under_active_root_uses_only_matching_root_sid() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + let deny_path = active_root.join("protected"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&active_root).expect("create active root"); + fs::create_dir_all(&stale_root).expect("create stale root"); + fs::create_dir_all(&deny_path).expect("create deny path"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), active_root.clone()], + &deny_path, + ) + .expect("deny sids"); + + assert_eq!(deny_sids, vec![active_sid]); + assert!(!deny_sids.contains(&workspace_sid)); + assert!(!deny_sids.contains(&stale_sid)); + assert!(!deny_sids.contains(&caps.workspace)); + } + + #[test] + fn deny_path_outside_active_roots_falls_back_to_all_active_root_sids() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + let deny_path = temp.path().join("outside-deny"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&active_root).expect("create active root"); + fs::create_dir_all(&stale_root).expect("create stale root"); + fs::create_dir_all(&deny_path).expect("create deny path"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), active_root.clone()], + &deny_path, + ) + .expect("deny sids"); + + assert_eq!(deny_sids.len(), 2); + assert!(deny_sids.contains(&workspace_sid)); + assert!(deny_sids.contains(&active_sid)); + assert!(!deny_sids.contains(&stale_sid)); + assert!(!deny_sids.contains(&caps.workspace)); + } + + #[test] + fn deny_path_includes_nested_active_root_sid() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let protected_dir = workspace.join(".codex"); + let nested_root = protected_dir.join("nested-root"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&nested_root).expect("create nested root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let nested_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &nested_root) + .expect("nested sid"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), nested_root], + &protected_dir, + ) + .expect("deny sids"); + + assert_eq!(deny_sids, vec![workspace_sid, nested_sid]); + } } diff --git a/codex-rs/windows-sandbox-rs/src/cap.rs b/codex-rs/windows-sandbox-rs/src/cap.rs index 85f7e2c9d6..41b33b63fd 100644 --- a/codex-rs/windows-sandbox-rs/src/cap.rs +++ b/codex-rs/windows-sandbox-rs/src/cap.rs @@ -1,4 +1,5 @@ use crate::path_normalization::canonical_path_key; +use crate::path_normalization::canonicalize_path; use anyhow::Context; use anyhow::Result; use rand::RngCore; @@ -22,6 +23,13 @@ pub struct CapSids { /// without permanently affecting other workspaces. #[serde(default)] pub workspace_by_cwd: HashMap, + /// Per-write-root capability SIDs keyed by canonicalized write-root path. + /// + /// These are included in a workspace-write token only when the root is + /// currently allowed, so stale ACLs from earlier extra roots do not expand + /// later workspace sandboxes. + #[serde(default)] + pub writable_root_by_path: HashMap, } pub fn cap_sid_file(codex_home: &Path) -> PathBuf { @@ -61,6 +69,7 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { workspace: t.to_string(), readonly: make_random_cap_sid_string(), workspace_by_cwd: HashMap::new(), + writable_root_by_path: HashMap::new(), }; persist_caps(&path, &caps)?; return Ok(caps); @@ -70,6 +79,7 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { workspace: make_random_cap_sid_string(), readonly: make_random_cap_sid_string(), workspace_by_cwd: HashMap::new(), + writable_root_by_path: HashMap::new(), }; persist_caps(&path, &caps)?; Ok(caps) @@ -89,10 +99,50 @@ pub fn workspace_cap_sid_for_cwd(codex_home: &Path, cwd: &Path) -> Result Result { + let path = cap_sid_file(codex_home); + let mut caps = load_or_create_cap_sids(codex_home)?; + let key = canonical_path_key(root); + if let Some(sid) = caps.writable_root_by_path.get(&key) { + return Ok(sid.clone()); + } + let sid = make_random_cap_sid_string(); + caps.writable_root_by_path.insert(key, sid.clone()); + persist_caps(&path, &caps)?; + Ok(sid) +} + +pub fn workspace_write_cap_sid_for_root( + codex_home: &Path, + cwd: &Path, + root: &Path, +) -> Result { + if canonical_path_key(root) == canonical_path_key(cwd) { + workspace_cap_sid_for_cwd(codex_home, cwd) + } else { + writable_root_cap_sid_for_path(codex_home, root) + } +} + +pub fn workspace_write_root_contains_path(root: &Path, path: &Path) -> bool { + canonicalize_path(path).starts_with(canonicalize_path(root)) +} + +pub fn workspace_write_root_overlaps_path(root: &Path, path: &Path) -> bool { + workspace_write_root_contains_path(root, path) || workspace_write_root_contains_path(path, root) +} + +pub fn workspace_write_root_specificity(root: &Path) -> usize { + canonicalize_path(root).components().count() +} + #[cfg(test)] mod tests { use super::load_or_create_cap_sids; use super::workspace_cap_sid_for_cwd; + use super::workspace_write_cap_sid_for_root; + use super::writable_root_cap_sid_for_path; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -123,4 +173,31 @@ mod tests { let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); assert_eq!(caps.workspace_by_cwd.len(), 1); } + + #[test] + fn write_roots_get_path_scoped_sids() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + + let workspace = temp.path().join("workspace"); + let extra_root = temp.path().join("extra-root"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&extra_root).expect("create extra root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let extra_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &extra_root) + .expect("extra root sid"); + + assert_ne!(workspace_sid, extra_sid); + assert_eq!( + extra_sid, + writable_root_cap_sid_for_path(&codex_home, &extra_root).expect("extra root sid again") + ); + + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + assert_eq!(caps.workspace_by_cwd.len(), 1); + assert_eq!(caps.writable_root_by_path.len(), 1); + } } diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index e1ce8c1db7..f43715c122 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -24,6 +24,7 @@ mod windows_impl { use super::ElevatedSandboxCaptureRequest; use crate::acl::allow_null_device; use crate::cap::load_or_create_cap_sids; + use crate::cap::workspace_write_cap_sid_for_root; use crate::env::ensure_non_interactive_pager; use crate::env::inherit_path_env; use crate::env::normalize_null_device_env; @@ -41,7 +42,8 @@ mod windows_impl { use crate::runner_client::spawn_runner_transport; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; - use crate::token::convert_string_sid_to_sid; + use crate::setup::effective_write_roots_for_setup; + use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -109,22 +111,28 @@ mod windows_impl { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; - let (psid_to_use, cap_sids) = match &policy { + let (sid_for_null, cap_sids) = match &policy { SandboxPolicy::ReadOnly { .. } => { - #[allow(clippy::unwrap_used)] - let psid = unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() }; - (psid, vec![caps.readonly]) + let sid = LocalSid::from_string(&caps.readonly)?; + (sid, vec![caps.readonly]) } SandboxPolicy::WorkspaceWrite { .. } => { - #[allow(clippy::unwrap_used)] - let psid = unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }; - ( - psid, - vec![ - caps.workspace, - crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?, - ], - ) + let write_roots = effective_write_roots_for_setup( + &policy, + sandbox_policy_cwd, + cwd, + &env_map, + codex_home, + write_roots_override, + ); + let cap_sids = write_roots + .iter() + .map(|root| workspace_write_cap_sid_for_root(codex_home, cwd, root)) + .collect::>>()?; + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") @@ -132,7 +140,7 @@ mod windows_impl { }; unsafe { - allow_null_device(psid_to_use); + allow_null_device(sid_for_null.as_ptr()); } (|| -> Result { diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 78081b5e1f..db958c9975 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -110,6 +110,12 @@ pub use cap::load_or_create_cap_sids; #[cfg(target_os = "windows")] pub use cap::workspace_cap_sid_for_cwd; #[cfg(target_os = "windows")] +pub use cap::workspace_write_cap_sid_for_root; +#[cfg(target_os = "windows")] +pub use cap::workspace_write_root_contains_path; +#[cfg(target_os = "windows")] +pub use cap::workspace_write_root_overlaps_path; +#[cfg(target_os = "windows")] pub use conpty::ConptyInstance; #[cfg(target_os = "windows")] pub use conpty::spawn_conpty_process_as_user; @@ -222,7 +228,7 @@ pub use setup_error::setup_error_path; pub use setup_error::write_setup_error_report; #[cfg(target_os = "windows")] #[doc(hidden)] -pub use spawn_prep::LocalSid; +pub use token::LocalSid; #[cfg(target_os = "windows")] pub use token::convert_string_sid_to_sid; #[cfg(target_os = "windows")] @@ -273,34 +279,25 @@ pub use stub::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] mod windows_impl { - use super::acl::add_allow_ace; - use super::acl::add_deny_write_ace; - use super::acl::allow_null_device; use super::acl::revoke_ace; - use super::allow::AllowDenyPaths; - use super::allow::compute_allow_paths; - use super::cap::load_or_create_cap_sids; - use super::cap::workspace_cap_sid_for_cwd; - use super::deny_read_acl::apply_deny_read_acls; - use super::deny_read_state::sync_persistent_deny_read_acls; use super::logging::log_failure; use super::logging::log_success; - use super::path_normalization::canonicalize_path; use super::policy::SandboxPolicy; use super::process::create_process_as_user; use super::sandbox_utils::ensure_codex_home_exists; + use super::spawn_prep::LegacyAclSids; + use super::spawn_prep::allow_null_device_for_workspace_write; + use super::spawn_prep::apply_legacy_session_acl_rules; + use super::spawn_prep::legacy_session_capability_roots; + use super::spawn_prep::prepare_legacy_session_security; use super::spawn_prep::prepare_legacy_spawn_context; - use super::token::convert_string_sid_to_sid; - use super::token::create_workspace_write_token_with_caps_from; - use super::workspace_acl::is_command_cwd_root; - use anyhow::Context; + use super::spawn_prep::root_capability_sids; + use super::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; - use std::ffi::c_void; use std::io; use std::path::Path; - use std::path::PathBuf; use std::ptr; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -418,130 +415,36 @@ mod windows_impl { if !additional_deny_read_paths.is_empty() { anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend"); } - let caps = load_or_create_cap_sids(codex_home)?; - let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe { - match &policy { - SandboxPolicy::ReadOnly { .. } => { - #[allow(clippy::expect_used)] - let psid = - convert_string_sid_to_sid(&caps.readonly).expect("valid readonly SID"); - let (h, _) = super::token::create_readonly_token_with_cap(psid)?; - (h, psid, None) - } - SandboxPolicy::WorkspaceWrite { .. } => { - #[allow(clippy::expect_used)] - let psid_generic = - convert_string_sid_to_sid(&caps.workspace).expect("valid workspace SID"); - let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - #[allow(clippy::expect_used)] - let psid_workspace = - convert_string_sid_to_sid(&ws_sid).expect("valid workspace SID"); - let base = super::token::get_current_token_for_restriction()?; - let h_res = create_workspace_write_token_with_caps_from( - base, - &[psid_generic, psid_workspace], - ); - windows_sys::Win32::Foundation::CloseHandle(base); - let h = h_res?; - (h, psid_generic, Some(psid_workspace)) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!("DangerFullAccess handled above") - } - } - }; - - unsafe { - if is_workspace_write - && let Ok(base) = super::token::get_current_token_for_restriction() - { - if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { - let mut tmp = bytes; - let psid2 = tmp.as_mut_ptr() as *mut c_void; - allow_null_device(psid2); - } - windows_sys::Win32::Foundation::CloseHandle(base); - } - } - + let capability_roots = legacy_session_capability_roots( + &policy, + sandbox_policy_cwd, + ¤t_dir, + &env_map, + codex_home, + ); + let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; + allow_null_device_for_workspace_write(is_workspace_write); let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, mut deny } = - compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); - for path in additional_deny_write_paths { - // Explicit deny-write carveouts must already exist when the process - // starts, otherwise it could create a missing path under a writable - // parent before the deny-write ACE exists. - if !path.exists() { - std::fs::create_dir_all(&path) - .with_context(|| format!("create deny-write path {}", path.display()))?; - } - deny.insert(path.clone()); - } - let canonical_cwd = canonicalize_path(¤t_dir); - let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); - unsafe { - for p in &allow { - let psid = if is_workspace_write && is_command_cwd_root(p, &canonical_cwd) { - psid_workspace.unwrap_or(psid_generic) - } else { - psid_generic - }; - if let Ok(added) = add_allow_ace(p, psid) - && added - { - if persist_aces { - if p.is_dir() { - // best-effort seeding omitted intentionally - } - } else { - guards.push((p.clone(), psid)); - } - } - } - for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_generic) - && added - && !persist_aces - { - guards.push((p.clone(), psid_generic)); - } - } - // Read denies are layered after allow/deny-write setup so they can - // override broad read grants for the sandbox principal without - // changing the existing write policy computation. - let applied_deny_read_paths = match if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - &caps.workspace, - &additional_deny_read_paths, - psid_generic, - ) - } else { - apply_deny_read_acls(&additional_deny_read_paths, psid_generic) - } { - Ok(paths) => paths, - Err(err) => { - if !persist_aces { - cleanup_acl_guards(&mut guards); - } - return Err(err); - } - }; - if !persist_aces { - for path in applied_deny_read_paths { - guards.push((path, psid_generic)); - } - } - allow_null_device(psid_generic); - if let Some(psid) = psid_workspace { - allow_null_device(psid); - } - } + let guards = apply_legacy_session_acl_rules( + &policy, + sandbox_policy_cwd, + codex_home, + ¤t_dir, + &env_map, + &additional_deny_read_paths, + &additional_deny_write_paths, + LegacyAclSids { + readonly_sid: security.readonly_sid.as_ref(), + readonly_sid_str: security.readonly_sid_str.as_deref(), + write_root_sids: &security.write_root_sids, + }, + persist_aces, + )?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); let spawn_res = unsafe { create_process_as_user( - h_token, + security.h_token, &command, cwd, &env_map, @@ -553,7 +456,6 @@ mod windows_impl { let created = match spawn_res { Ok(v) => v, Err(err) => { - cleanup_acl_guards(&mut guards); unsafe { CloseHandle(in_r); CloseHandle(in_w); @@ -561,7 +463,14 @@ mod windows_impl { CloseHandle(out_w); CloseHandle(err_r); CloseHandle(err_w); - CloseHandle(h_token); + if !persist_aces { + for (p, sid_str) in &guards { + if let Ok(sid) = LocalSid::from_string(sid_str) { + revoke_ace(p, sid.as_ptr()); + } + } + } + CloseHandle(security.h_token); } return Err(err); } @@ -643,7 +552,7 @@ mod windows_impl { if pi.hProcess != 0 { CloseHandle(pi.hProcess); } - CloseHandle(h_token); + CloseHandle(security.h_token); } let _ = t_out.join(); let _ = t_err.join(); @@ -662,7 +571,13 @@ mod windows_impl { } if !persist_aces { - cleanup_acl_guards(&mut guards); + unsafe { + for (p, sid_str) in guards { + if let Ok(sid) = LocalSid::from_string(&sid_str) { + revoke_ace(&p, sid.as_ptr()); + } + } + } } Ok(CaptureResult { exit_code, @@ -672,14 +587,6 @@ mod windows_impl { }) } - fn cleanup_acl_guards(guards: &mut Vec<(PathBuf, *mut c_void)>) { - unsafe { - for (p, sid) in guards.drain(..) { - revoke_ace(&p, sid); - } - } - } - pub fn run_windows_sandbox_legacy_preflight( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, @@ -693,33 +600,30 @@ mod windows_impl { } ensure_codex_home_exists(codex_home)?; - let caps = load_or_create_cap_sids(codex_home)?; - #[allow(clippy::expect_used)] - let psid_generic = - unsafe { convert_string_sid_to_sid(&caps.workspace) }.expect("valid workspace SID"); - let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - #[allow(clippy::expect_used)] - let psid_workspace = - unsafe { convert_string_sid_to_sid(&ws_sid) }.expect("valid workspace SID"); let current_dir = cwd.to_path_buf(); - let AllowDenyPaths { allow, deny } = - compute_allow_paths(sandbox_policy, sandbox_policy_cwd, ¤t_dir, env_map); - let canonical_cwd = canonicalize_path(¤t_dir); - unsafe { - for p in &allow { - let psid = if is_command_cwd_root(p, &canonical_cwd) { - psid_workspace - } else { - psid_generic - }; - let _ = add_allow_ace(p, psid); - } - for p in &deny { - let _ = add_deny_write_ace(p, psid_generic); - } - allow_null_device(psid_generic); - allow_null_device(psid_workspace); - } + let capability_roots = legacy_session_capability_roots( + sandbox_policy, + sandbox_policy_cwd, + ¤t_dir, + env_map, + codex_home, + ); + let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; + let _guards = apply_legacy_session_acl_rules( + sandbox_policy, + sandbox_policy_cwd, + codex_home, + ¤t_dir, + env_map, + &[], + &[], + LegacyAclSids { + readonly_sid: None, + readonly_sid_str: None, + write_root_sids: &write_root_sids, + }, + /*persist_aces*/ true, + )?; Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 3b6e47086e..0de2c6e029 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -413,6 +413,26 @@ pub(crate) fn gather_write_roots( out } +pub(crate) fn effective_write_roots_for_setup( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + write_roots_override: Option<&[PathBuf]>, +) -> Vec { + let write_roots = if let Some(roots) = write_roots_override { + canonical_existing(roots) + } else { + gather_write_roots(policy, policy_cwd, command_cwd, env_map) + }; + let write_roots = expand_user_profile_root(write_roots); + let write_roots = filter_user_profile_root(write_roots); + let write_roots = filter_user_profile_root_exclusions(write_roots); + let write_roots = filter_ssh_config_dependency_roots(write_roots); + filter_sensitive_write_roots(write_roots, codex_home) +} + #[derive(Serialize)] struct ElevationPayload { version: u32, @@ -761,21 +781,14 @@ fn build_payload_roots( request: &SandboxSetupRequest<'_>, overrides: &SetupRootOverrides, ) -> (Vec, Vec) { - let write_roots = if let Some(roots) = overrides.write_roots.as_deref() { - canonical_existing(roots) - } else { - gather_write_roots( - request.policy, - request.policy_cwd, - request.command_cwd, - request.env_map, - ) - }; - let write_roots = expand_user_profile_root(write_roots); - let write_roots = filter_user_profile_root(write_roots); - let write_roots = filter_user_profile_root_exclusions(write_roots); - let write_roots = filter_ssh_config_dependency_roots(write_roots); - let write_roots = filter_sensitive_write_roots(write_roots, request.codex_home); + let write_roots = effective_write_roots_for_setup( + request.policy, + request.policy_cwd, + request.command_cwd, + request.env_map, + request.codex_home, + overrides.write_roots.as_deref(), + ); let mut read_roots = if let Some(roots) = overrides.read_roots.as_deref() { // An explicit override is the split policy's complete readable set. Keep only the // helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots. @@ -1405,6 +1418,66 @@ mod tests { ); } + #[test] + fn effective_write_roots_match_payload_filtering_for_overrides() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let command_cwd = tmp.path().join("workspace"); + let extra_root = tmp.path().join("extra-root"); + let sandbox_root = super::sandbox_dir(&codex_home); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&command_cwd).expect("create workspace"); + fs::create_dir_all(&extra_root).expect("create extra root"); + fs::create_dir_all(&sandbox_root).expect("create sandbox root"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let override_roots = vec![ + command_cwd.clone(), + extra_root.clone(), + codex_home.clone(), + sandbox_root.clone(), + ]; + let request = super::SandboxSetupRequest { + policy: &policy, + policy_cwd: &command_cwd, + command_cwd: &command_cwd, + env_map: &HashMap::new(), + codex_home: &codex_home, + proxy_enforced: false, + }; + let overrides = super::SetupRootOverrides { + read_roots: None, + read_roots_include_platform_defaults: false, + write_roots: Some(override_roots.clone()), + deny_read_paths: None, + deny_write_paths: None, + }; + + let effective_write_roots = super::effective_write_roots_for_setup( + &policy, + &command_cwd, + &command_cwd, + &HashMap::new(), + &codex_home, + Some(&override_roots), + ); + let (_read_roots, payload_write_roots) = build_payload_roots(&request, &overrides); + + let expected_workspace = dunce::canonicalize(&command_cwd).expect("canonical workspace"); + let expected_extra = dunce::canonicalize(&extra_root).expect("canonical extra root"); + let forbidden_codex_home = dunce::canonicalize(&codex_home).expect("canonical codex home"); + let forbidden_sandbox = dunce::canonicalize(&sandbox_root).expect("canonical sandbox root"); + assert_eq!(effective_write_roots, payload_write_roots); + assert!(effective_write_roots.contains(&expected_workspace)); + assert!(effective_write_roots.contains(&expected_extra)); + assert!(!effective_write_roots.contains(&forbidden_codex_home)); + assert!(!effective_write_roots.contains(&forbidden_sandbox)); + } + #[test] fn payload_deny_write_paths_merge_explicit_and_protected_children() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index b0cf0f3075..331df4f438 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -4,7 +4,10 @@ use crate::acl::allow_null_device; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths; use crate::cap::load_or_create_cap_sids; -use crate::cap::workspace_cap_sid_for_cwd; +use crate::cap::workspace_write_cap_sid_for_root; +use crate::cap::workspace_write_root_contains_path; +use crate::cap::workspace_write_root_overlaps_path; +use crate::cap::workspace_write_root_specificity; use crate::deny_read_acl::apply_deny_read_acls; use crate::deny_read_state::sync_persistent_deny_read_acls; use crate::env::apply_no_network_to_env; @@ -19,7 +22,8 @@ use crate::policy::SandboxPolicy; use crate::policy::parse_policy; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; -use crate::token::convert_string_sid_to_sid; +use crate::setup::effective_write_roots_for_setup; +use crate::token::LocalSid; use crate::token::create_readonly_token_with_cap; use crate::token::create_workspace_write_token_with_caps_from; use crate::token::get_current_token_for_restriction; @@ -35,8 +39,6 @@ use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::HANDLE; -use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::LocalFree; pub(crate) struct SpawnContext { pub(crate) policy: SandboxPolicy, @@ -54,36 +56,21 @@ pub(crate) struct ElevatedSpawnContext { pub(crate) struct LegacySessionSecurity { pub(crate) h_token: HANDLE, - pub(crate) psid_generic: LocalSid, - pub(crate) psid_workspace: Option, - pub(crate) cap_sid_str: String, + pub(crate) readonly_sid: Option, + pub(crate) readonly_sid_str: Option, + pub(crate) write_root_sids: Vec, } -/// Owns a SID allocated by `ConvertStringSidToSidW` and releases it with `LocalFree`. -pub struct LocalSid { - psid: *mut c_void, +pub(crate) struct RootCapabilitySid { + pub(crate) root: PathBuf, + pub(crate) sid: LocalSid, + pub(crate) sid_str: String, } -impl LocalSid { - pub fn from_string(sid: &str) -> Result { - let psid = unsafe { convert_string_sid_to_sid(sid) } - .ok_or_else(|| anyhow::anyhow!("invalid SID string: {sid}"))?; - Ok(Self { psid }) - } - - pub fn as_ptr(&self) -> *mut c_void { - self.psid - } -} - -impl Drop for LocalSid { - fn drop(&mut self) { - if !self.psid.is_null() { - unsafe { - LocalFree(self.psid as HLOCAL); - } - } - } +pub(crate) struct LegacyAclSids<'a> { + pub(crate) readonly_sid: Option<&'a LocalSid>, + pub(crate) readonly_sid_str: Option<&'a str>, + pub(crate) write_root_sids: &'a [RootCapabilitySid], } pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool { @@ -161,27 +148,31 @@ pub(crate) fn prepare_legacy_session_security( policy: &SandboxPolicy, codex_home: &Path, cwd: &Path, + capability_roots: impl IntoIterator, ) -> Result { let caps = load_or_create_cap_sids(codex_home)?; - let (h_token, psid_generic, psid_workspace, cap_sid_str) = unsafe { + let (h_token, readonly_sid, readonly_sid_str, write_root_sids) = unsafe { match policy { SandboxPolicy::ReadOnly { .. } => { let psid = LocalSid::from_string(&caps.readonly)?; let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; - (h_token, psid, None, caps.readonly) + (h_token, Some(psid), Some(caps.readonly), Vec::new()) } SandboxPolicy::WorkspaceWrite { .. } => { - let psid_generic = LocalSid::from_string(&caps.workspace)?; - let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - let psid_workspace = LocalSid::from_string(&workspace_sid)?; + let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; + if write_root_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } let base = get_current_token_for_restriction()?; - let h_token = create_workspace_write_token_with_caps_from( - base, - &[psid_generic.as_ptr(), psid_workspace.as_ptr()], - ); + let cap_ptrs: Vec<*mut c_void> = write_root_sids + .iter() + .map(|root| root.sid.as_ptr()) + .collect(); + let h_token = + create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); CloseHandle(base); let h_token = h_token?; - (h_token, psid_generic, Some(psid_workspace), caps.workspace) + (h_token, None, None, write_root_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("dangerous policies rejected before legacy session prep") @@ -191,12 +182,80 @@ pub(crate) fn prepare_legacy_session_security( Ok(LegacySessionSecurity { h_token, - psid_generic, - psid_workspace, - cap_sid_str, + readonly_sid, + readonly_sid_str, + write_root_sids, }) } +pub(crate) fn legacy_session_capability_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + current_dir: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> Vec { + let allow_paths = compute_allow_paths(policy, policy_cwd, current_dir, env_map) + .allow + .into_iter() + .collect::>(); + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + effective_write_roots_for_setup( + policy, + policy_cwd, + current_dir, + env_map, + codex_home, + Some(allow_paths.as_slice()), + ) + } else { + allow_paths + } +} + +pub(crate) fn root_capability_sids( + codex_home: &Path, + cwd: &Path, + allow_paths: impl IntoIterator, +) -> Result> { + let mut roots: Vec = allow_paths.into_iter().collect(); + roots.sort_by_key(|root| canonicalize_path(root.as_path())); + roots.dedup_by(|a, b| canonicalize_path(a.as_path()) == canonicalize_path(b.as_path())); + + let mut out = Vec::with_capacity(roots.len()); + for root in roots { + let sid_str = workspace_write_cap_sid_for_root(codex_home, cwd, &root)?; + let sid = LocalSid::from_string(&sid_str)?; + out.push(RootCapabilitySid { root, sid, sid_str }); + } + Ok(out) +} + +fn matching_root_capability<'a>( + path: &Path, + root_sids: &'a [RootCapabilitySid], +) -> Option<&'a RootCapabilitySid> { + root_sids + .iter() + .filter(|root_sid| workspace_write_root_contains_path(&root_sid.root, path)) + .max_by_key(|root_sid| workspace_write_root_specificity(&root_sid.root)) +} + +fn deny_root_capabilities_for_path<'a>( + path: &Path, + root_sids: &'a [RootCapabilitySid], +) -> Vec<&'a RootCapabilitySid> { + let matching_root_sids = root_sids + .iter() + .filter(|root_sid| workspace_write_root_overlaps_path(&root_sid.root, path)) + .collect::>(); + if matching_root_sids.is_empty() { + root_sids.iter().collect() + } else { + matching_root_sids + } +} + pub(crate) fn allow_null_device_for_workspace_write(is_workspace_write: bool) { if !is_workspace_write { return; @@ -221,17 +280,14 @@ pub(crate) fn apply_legacy_session_acl_rules( codex_home: &Path, current_dir: &Path, env_map: &HashMap, - psid_generic: &LocalSid, - psid_workspace: Option<&LocalSid>, - cap_sid_str: &str, additional_deny_read_paths: &[PathBuf], additional_deny_write_paths: &[PathBuf], + acl_sids: LegacyAclSids<'_>, persist_aces: bool, -) -> Result> { +) -> Result> { let AllowDenyPaths { allow, mut deny } = compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map); - let mut guards: Vec = Vec::new(); - let canonical_cwd = canonicalize_path(current_dir); + let mut guards: Vec<(PathBuf, String)> = Vec::new(); unsafe { for path in additional_deny_write_paths { // Explicit carveouts must exist before the command starts so the @@ -242,45 +298,94 @@ pub(crate) fn apply_legacy_session_acl_rules( } deny.insert(path.clone()); } - for p in &allow { - let psid = if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) - && is_command_cwd_root(p, &canonical_cwd) - { - psid_workspace.unwrap_or(psid_generic).as_ptr() - } else { - psid_generic.as_ptr() - }; - if matches!(add_allow_ace(p, psid), Ok(true)) && !persist_aces { - guards.push(p.clone()); + if let Some(readonly_sid) = acl_sids.readonly_sid { + for p in &allow { + if matches!(add_allow_ace(p, readonly_sid.as_ptr()), Ok(true)) + && !persist_aces + && let Some(readonly_sid_str) = acl_sids.readonly_sid_str + { + guards.push((p.clone(), readonly_sid_str.to_string())); + } + } + } else { + for p in &allow { + let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { + continue; + }; + if matches!(add_allow_ace(p, root_sid.sid.as_ptr()), Ok(true)) && !persist_aces { + guards.push((p.clone(), root_sid.sid_str.clone())); + } } } for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_generic.as_ptr()) - && added - && !persist_aces - { - guards.push(p.clone()); + for root_sid in deny_root_capabilities_for_path(p, acl_sids.write_root_sids) { + if let Ok(added) = add_deny_write_ace(p, root_sid.sid.as_ptr()) + && added + && !persist_aces + { + guards.push((p.clone(), root_sid.sid_str.clone())); + } } } - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - cap_sid_str, - additional_deny_read_paths, - psid_generic.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, psid_generic.as_ptr())? - }; - if !persist_aces { - guards.extend(applied_deny_read_paths); + if !additional_deny_read_paths.is_empty() { + if let Some(readonly_sid) = acl_sids.readonly_sid { + let Some(readonly_sid_str) = acl_sids.readonly_sid_str else { + anyhow::bail!("readonly capability SID string missing"); + }; + let applied_deny_read_paths = if persist_aces { + sync_persistent_deny_read_acls( + codex_home, + readonly_sid_str, + additional_deny_read_paths, + readonly_sid.as_ptr(), + )? + } else { + apply_deny_read_acls(additional_deny_read_paths, readonly_sid.as_ptr())? + }; + if !persist_aces { + guards.extend( + applied_deny_read_paths + .into_iter() + .map(|path| (path, readonly_sid_str.to_string())), + ); + } + } else { + for root_sid in acl_sids.write_root_sids { + let applied_deny_read_paths = if persist_aces { + sync_persistent_deny_read_acls( + codex_home, + &root_sid.sid_str, + additional_deny_read_paths, + root_sid.sid.as_ptr(), + )? + } else { + apply_deny_read_acls(additional_deny_read_paths, root_sid.sid.as_ptr())? + }; + if !persist_aces { + guards.extend( + applied_deny_read_paths + .into_iter() + .map(|path| (path, root_sid.sid_str.clone())), + ); + } + } + } } - allow_null_device(psid_generic.as_ptr()); - if let Some(psid_workspace) = psid_workspace { - allow_null_device(psid_workspace.as_ptr()); - if persist_aces && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { - let _ = protect_workspace_codex_dir(current_dir, psid_workspace.as_ptr()); - let _ = protect_workspace_agents_dir(current_dir, psid_workspace.as_ptr()); + for root_sid in acl_sids.write_root_sids { + allow_null_device(root_sid.sid.as_ptr()); + } + if let Some(readonly_sid) = acl_sids.readonly_sid { + allow_null_device(readonly_sid.as_ptr()); + } + if persist_aces + && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + && let Some(workspace_sid) = + matching_root_capability(current_dir, acl_sids.write_root_sids) + { + let canonical_cwd = canonicalize_path(current_dir); + if is_command_cwd_root(&workspace_sid.root, &canonical_cwd) { + let _ = protect_workspace_codex_dir(current_dir, workspace_sid.sid.as_ptr()); + let _ = protect_workspace_agents_dir(current_dir, workspace_sid.sid.as_ptr()); } } } @@ -324,6 +429,24 @@ pub(crate) fn prepare_elevated_spawn_context( } else { None }; + let write_roots_for_setup = write_roots_override.or(computed_write_roots_override); + let effective_write_roots = if common.is_workspace_write { + effective_write_roots_for_setup( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + env_map, + codex_home, + write_roots_for_setup, + ) + } else { + Vec::new() + }; + let setup_write_roots_override = if common.is_workspace_write { + Some(effective_write_roots.as_slice()) + } else { + write_roots_override + }; let sandbox_creds = require_logon_sandbox_creds( &common.policy, sandbox_policy_cwd, @@ -332,7 +455,7 @@ pub(crate) fn prepare_elevated_spawn_context( codex_home, read_roots_override, read_roots_include_platform_defaults, - write_roots_override.or(computed_write_roots_override), + setup_write_roots_override, deny_read_paths_override, if deny_write_paths_override.is_empty() { &deny_write_paths @@ -348,11 +471,14 @@ pub(crate) fn prepare_elevated_spawn_context( vec![caps.readonly.clone()], ), SandboxPolicy::WorkspaceWrite { .. } => { - let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - ( - LocalSid::from_string(&caps.workspace)?, - vec![caps.workspace.clone(), cap_sid], - ) + let cap_sids = root_capability_sids(codex_home, cwd, effective_write_roots)? + .into_iter() + .map(|root_sid| root_sid.sid_str) + .collect::>(); + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("dangerous policies rejected before elevated session prep") @@ -373,9 +499,15 @@ pub(crate) fn prepare_elevated_spawn_context( #[cfg(test)] mod tests { use super::SandboxPolicy; + use super::deny_root_capabilities_for_path; + use super::legacy_session_capability_roots; use super::prepare_legacy_spawn_context; use super::prepare_spawn_context_common; + use super::root_capability_sids; use super::should_apply_network_block; + use crate::cap::load_or_create_cap_sids; + use crate::cap::workspace_write_cap_sid_for_root; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -450,4 +582,114 @@ mod tests { Some(&"http://user.proxy:8080".to_string()) ); } + + #[test] + fn root_capability_sids_only_include_active_roots() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&active_root).expect("create active root"); + std::fs::create_dir_all(&stale_root).expect("create stale root"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let sid_strs = root_capability_sids( + &codex_home, + &workspace, + vec![workspace.clone(), active_root], + ) + .expect("root capabilities") + .into_iter() + .map(|root_sid| root_sid.sid_str) + .collect::>(); + + assert_eq!(sid_strs.len(), 2); + assert!(sid_strs.contains(&workspace_sid)); + assert!(sid_strs.contains(&active_sid)); + assert!(!sid_strs.contains(&stale_sid)); + assert!(!sid_strs.contains(&caps.workspace)); + } + + #[test] + fn legacy_deny_path_includes_nested_active_root_sid() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let protected_dir = workspace.join(".codex"); + let nested_root = protected_dir.join("nested-root"); + let unrelated_root = temp.path().join("unrelated-root"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&nested_root).expect("create nested root"); + std::fs::create_dir_all(&unrelated_root).expect("create unrelated root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let nested_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &nested_root) + .expect("nested sid"); + let unrelated_sid = + workspace_write_cap_sid_for_root(&codex_home, &workspace, &unrelated_root) + .expect("unrelated sid"); + let root_sids = root_capability_sids( + &codex_home, + &workspace, + vec![workspace.clone(), nested_root, unrelated_root], + ) + .expect("root capabilities"); + + let deny_sid_strs = deny_root_capabilities_for_path(&protected_dir, &root_sids) + .into_iter() + .map(|root_sid| root_sid.sid_str.clone()) + .collect::>(); + + assert_eq!(deny_sid_strs, vec![workspace_sid, nested_sid]); + assert!(!deny_sid_strs.contains(&unrelated_sid)); + } + + #[test] + fn legacy_capability_roots_use_effective_write_roots() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let sandbox_root = codex_home.join(".sandbox"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&active_root).expect("create active root"); + std::fs::create_dir_all(&sandbox_root).expect("create sandbox root"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(active_root.as_path()).expect("active root"), + AbsolutePathBuf::try_from(codex_home.as_path()).expect("codex home"), + AbsolutePathBuf::try_from(sandbox_root.as_path()).expect("sandbox root"), + ], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let roots = legacy_session_capability_roots( + &policy, + &workspace, + &workspace, + &HashMap::new(), + &codex_home, + ); + + assert!(roots.contains(&dunce::canonicalize(&workspace).expect("workspace"))); + assert!(roots.contains(&dunce::canonicalize(&active_root).expect("active root"))); + assert!(!roots.contains(&dunce::canonicalize(&codex_home).expect("codex home"))); + assert!(!roots.contains(&dunce::canonicalize(&sandbox_root).expect("sandbox root"))); + } } diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index aabf77469f..2c7a746474 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -139,6 +139,33 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { if ok != 0 { Some(psid) } else { None } } +/// Owns a SID allocated by `ConvertStringSidToSidW` and releases it with `LocalFree`. +pub struct LocalSid { + psid: *mut c_void, +} + +impl LocalSid { + pub fn from_string(sid: &str) -> Result { + let psid = unsafe { convert_string_sid_to_sid(sid) } + .ok_or_else(|| anyhow!("invalid SID string: {sid}"))?; + Ok(Self { psid }) + } + + pub fn as_ptr(&self) -> *mut c_void { + self.psid + } +} + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.psid.is_null() { + unsafe { + LocalFree(self.psid as HLOCAL); + } + } + } +} + /// # Safety /// Caller must close the returned token handle. pub unsafe fn get_current_token_for_restriction() -> Result { diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 81096e8c62..2f76147ce9 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -10,11 +10,13 @@ use crate::process::StderrMode; use crate::process::StdinMode; use crate::process::read_handle_loop; use crate::process::spawn_process_with_pipes; -use crate::spawn_prep::LocalSid; +use crate::spawn_prep::LegacyAclSids; use crate::spawn_prep::allow_null_device_for_workspace_write; use crate::spawn_prep::apply_legacy_session_acl_rules; +use crate::spawn_prep::legacy_session_capability_roots; use crate::spawn_prep::prepare_legacy_session_security; use crate::spawn_prep::prepare_legacy_spawn_context; +use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; @@ -204,8 +206,7 @@ fn finalize_exit( process_handle: Arc>>, thread_handle: HANDLE, output_join: std::thread::JoinHandle<()>, - guards: Vec, - cap_sid: Option, + guards: Vec<(PathBuf, String)>, logs_base_dir: Option<&Path>, command: Vec, ) { @@ -242,11 +243,9 @@ fn finalize_exit( log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - if let Some(cap_sid) = cap_sid - && let Ok(sid) = LocalSid::from_string(&cap_sid) - { - unsafe { - for path in guards { + unsafe { + for (path, cap_sid) in guards { + if let Ok(sid) = LocalSid::from_string(&cap_sid) { revoke_ace(&path, sid.as_ptr()); } } @@ -311,15 +310,19 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( if !additional_deny_read_paths.is_empty() { anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend"); } - let additional_deny_read_paths = additional_deny_read_paths - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>(); let additional_deny_write_paths = additional_deny_write_paths .iter() .map(AbsolutePathBuf::to_path_buf) .collect::>(); - let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?; + let capability_roots = legacy_session_capability_roots( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + &env_map, + codex_home, + ); + let security = + prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(common.is_workspace_write); let persist_aces = common.is_workspace_write; @@ -329,11 +332,13 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( codex_home, &common.current_dir, &env_map, - &security.psid_generic, - security.psid_workspace.as_ref(), - &security.cap_sid_str, - &additional_deny_read_paths, + &[], &additional_deny_write_paths, + LegacyAclSids { + readonly_sid: security.readonly_sid.as_ref(), + readonly_sid_str: security.readonly_sid_str.as_deref(), + write_root_sids: &security.write_root_sids, + }, persist_aces, )?; @@ -370,12 +375,11 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( Ok(handles) => handles, Err(err) => { unsafe { - if !persist_aces - && !guards.is_empty() - && let Ok(sid) = LocalSid::from_string(&security.cap_sid_str) - { - for path in &guards { - revoke_ace(path, sid.as_ptr()); + if !persist_aces { + for (path, cap_sid) in &guards { + if let Ok(sid) = LocalSid::from_string(cap_sid) { + revoke_ace(path, sid.as_ptr()); + } } } CloseHandle(security.h_token); @@ -389,11 +393,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( let wait_handle = Arc::clone(&process_handle); let command_for_wait = command.clone(); let guards_for_wait = if persist_aces { Vec::new() } else { guards }; - let cap_sid_for_wait = if guards_for_wait.is_empty() { - None - } else { - Some(security.cap_sid_str) - }; let hpc_for_wait = hpc_handle.clone(); std::thread::spawn(move || { let _desktop = desktop; @@ -425,7 +424,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( pi.hThread, output_join, guards_for_wait, - cap_sid_for_wait, common.logs_base_dir.as_deref(), command_for_wait, ); From c9edb26755427bd01f82fd7b4e8677f32ad221a4 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 13 May 2026 11:43:14 -0700 Subject: [PATCH 12/52] windows-sandbox: fail elevated setup when firewall policy is ineffective (#22353) ## Why Elevated Windows sandbox setup currently assumes that the firewall rules it writes will take effect. On managed Windows hosts, local firewall policy changes can be ignored or only partially apply across the active profiles, which means setup can appear to succeed without providing the expected network isolation. ## What changed - Query `INetFwPolicy2::LocalPolicyModifyState` before configuring the elevated sandbox firewall rules. - Fail setup when Windows reports that local firewall policy edits are ineffective or only apply to some current profiles. - Surface that condition with a dedicated `helper_firewall_policy_ineffective` setup error code so support and IT-facing diagnostics can distinguish it from COM access failures. - Add focused coverage for effective policy, group-policy override, and partial-profile coverage cases. ## Testing - `cargo test -p codex-windows-sandbox --bin codex-windows-sandbox-setup` --- .../src/bin/setup_main/win/firewall.rs | 88 +++++++++++++++++++ .../windows-sandbox-rs/src/setup_error.rs | 3 + 2 files changed, 91 insertions(+) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index caa1780437..0a83950853 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::fs::File; use std::io::Write; +use windows::Win32::Foundation::S_OK; use windows::Win32::Foundation::VARIANT_TRUE; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3; @@ -10,6 +11,8 @@ use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_TCP; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_UDP; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE_OK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT; use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; @@ -75,6 +78,7 @@ pub fn ensure_offline_proxy_allowlist( format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"), )) })?; + ensure_local_policy_rules_take_effect(&policy)?; let rules = policy.Rules().map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallPolicyAccessFailed, @@ -170,6 +174,7 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"), )) })?; + ensure_local_policy_rules_take_effect(&policy)?; let rules = policy.Rules().map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallPolicyAccessFailed, @@ -215,6 +220,52 @@ fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut Fi Ok(()) } +fn ensure_local_policy_rules_take_effect(policy: &INetFwPolicy2) -> Result<()> { + let mut modify_state = NET_FW_MODIFY_STATE::default(); + let result = unsafe { + (Interface::vtable(policy).LocalPolicyModifyState)( + Interface::as_raw(policy), + &mut modify_state, + ) + }; + validate_local_policy_modify_result(result, modify_state) +} + +fn validate_local_policy_modify_result( + result: windows::core::HRESULT, + modify_state: NET_FW_MODIFY_STATE, +) -> Result<()> { + if result.is_err() { + // The COM query itself failed, so Windows never gave us a policy answer. + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyAccessFailed, + format!("INetFwPolicy2::LocalPolicyModifyState failed: {result:?}"), + ))); + } + + if result != S_OK { + // S_FALSE means the answer only holds for some active profiles, not all of them. + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyIneffective, + format!( + "local firewall policy modifications do not apply to every current profile: LocalPolicyModifyState result={result:?}" + ), + ))); + } + + if modify_state == NET_FW_MODIFY_STATE_OK { + return Ok(()); + } + + // Windows answered uniformly, and that answer says local rule edits are ineffective. + Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyIneffective, + format!( + "local firewall policy modifications will not take effect: LocalPolicyModifyState={modify_state:?}" + ), + ))) +} + fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { @@ -410,6 +461,10 @@ fn log_line(log: &mut File, msg: &str) -> Result<()> { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use windows::Win32::Foundation::S_FALSE; + use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE_GP_OVERRIDE; + use super::*; #[test] @@ -507,4 +562,37 @@ mod tests { ); } } + + #[test] + fn local_policy_modify_state_accepts_effective_policy() { + assert!(validate_local_policy_modify_result(S_OK, NET_FW_MODIFY_STATE_OK).is_ok()); + } + + #[test] + fn local_policy_modify_state_rejects_ineffective_policy() { + let err = validate_local_policy_modify_result(S_OK, NET_FW_MODIFY_STATE_GP_OVERRIDE) + .expect_err("group-policy override should fail sandbox firewall setup"); + let failure = err + .downcast_ref::() + .expect("expected setup failure"); + + assert_eq!( + failure.code, + SetupErrorCode::HelperFirewallPolicyIneffective + ); + } + + #[test] + fn local_policy_modify_state_rejects_partial_profile_coverage() { + let err = validate_local_policy_modify_result(S_FALSE, NET_FW_MODIFY_STATE_OK) + .expect_err("partial profile coverage should fail sandbox firewall setup"); + let failure = err + .downcast_ref::() + .expect("expected setup failure"); + + assert_eq!( + failure.code, + SetupErrorCode::HelperFirewallPolicyIneffective + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 64e618ace1..efaaa531e2 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -56,6 +56,8 @@ pub enum SetupErrorCode { HelperFirewallComInitFailed, /// Helper failed to access firewall policy or rule collections. HelperFirewallPolicyAccessFailed, + /// Helper detected that local firewall policy changes will not fully take effect. + HelperFirewallPolicyIneffective, /// Helper failed to create, update, or add the firewall rule. HelperFirewallRuleCreateOrAddFailed, /// Helper failed to verify the configured firewall rule scope. @@ -91,6 +93,7 @@ impl SetupErrorCode { Self::HelperCapabilitySidFailed => "helper_capability_sid_failed", Self::HelperFirewallComInitFailed => "helper_firewall_com_init_failed", Self::HelperFirewallPolicyAccessFailed => "helper_firewall_policy_access_failed", + Self::HelperFirewallPolicyIneffective => "helper_firewall_policy_ineffective", Self::HelperFirewallRuleCreateOrAddFailed => { "helper_firewall_rule_create_or_add_failed" } From a98b57d0659cff6f9518e3db511e5622669cf48c Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Wed, 13 May 2026 15:18:35 -0400 Subject: [PATCH 13/52] [codex] Reuse Apps MCP path override for plugin-service rollout (#22527) ## Summary - reuse `apps_mcp_path_override` for the plugin-service rollout, defaulting enabled boolean overrides to `/ps/mcp` while preserving explicit configured paths ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-mcp` - `cargo test -p codex-core apps_mcp_path_override` - `cargo test -p codex-core to_mcp_config_preserves_apps_feature_from_config` - `cargo test -p codex-features` --- codex-rs/core/src/config/config_tests.rs | 52 ++++++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 1 + 2 files changed, 53 insertions(+) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9fc8c2643f..664730805a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8769,6 +8769,58 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features] +apps_mcp_path_override = true +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp")); + Ok(()) +} + +#[tokio::test] +async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features.apps_mcp_path_override] +enabled = true +path = "/custom/mcp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b4e674446..2071e1ed25 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2615,6 +2615,7 @@ impl Config { .and_then(|config| config.path.as_ref()) .or_else(|| base.and_then(|config| config.path.as_ref())) .cloned() + .or_else(|| Some("/ps/mcp".to_string())) } else { None }; From d18a7c982e4abad5bf549cda6f4b61a18c10702e Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 13 May 2026 12:33:36 -0700 Subject: [PATCH 14/52] chore(config) rm Feature::CodexGitCommit (#22412) ## Summary Removes the unused Feature::CodexGitCommit ## Testing - [x] tests pass --- codex-rs/Cargo.lock | 11 -- codex-rs/Cargo.toml | 2 - codex-rs/app-server/Cargo.toml | 1 - codex-rs/app-server/src/extensions.rs | 1 - codex-rs/config/src/config_toml.rs | 8 -- codex-rs/core/config.schema.json | 4 - codex-rs/core/src/config/config_tests.rs | 4 - codex-rs/core/src/config/mod.rs | 12 -- codex-rs/core/src/session/tests.rs | 32 ++--- codex-rs/ext/git-attribution/BUILD.bazel | 6 - codex-rs/ext/git-attribution/Cargo.toml | 21 ---- codex-rs/ext/git-attribution/src/lib.rs | 134 --------------------- codex-rs/features/src/lib.rs | 5 +- codex-rs/thread-manager-sample/src/main.rs | 1 - 14 files changed, 18 insertions(+), 224 deletions(-) delete mode 100644 codex-rs/ext/git-attribution/BUILD.bazel delete mode 100644 codex-rs/ext/git-attribution/Cargo.toml delete mode 100644 codex-rs/ext/git-attribution/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 25827331b2..a0a49e4399 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1902,7 +1902,6 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-file-watcher", - "codex-git-attribution", "codex-git-utils", "codex-guardian", "codex-hooks", @@ -2912,16 +2911,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "codex-git-attribution" -version = "0.0.0" -dependencies = [ - "codex-core", - "codex-extension-api", - "codex-features", - "pretty_assertions", -] - [[package]] name = "codex-git-utils" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f5cfda7296..14b1264a81 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -46,7 +46,6 @@ members = [ "execpolicy-legacy", "ext/extension-api", "ext/guardian", - "ext/git-attribution", "ext/memories", "external-agent-migration", "external-agent-sessions", @@ -164,7 +163,6 @@ codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-guardian = { path = "ext/guardian" } -codex-git-attribution = { path = "ext/git-attribution" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ecc8d1c25a..c75461ecf7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -42,7 +42,6 @@ codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } codex-guardian = { workspace = true } -codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 411685e7d8..a293daf4f2 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -17,7 +17,6 @@ where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::new(); - codex_git_attribution::install(&mut builder); codex_guardian::install(&mut builder, guardian_agent_spawner); Arc::new(builder.build()) } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 06993e046a..fdca03de1a 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -177,14 +177,6 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// When enabled and unset, Codex uses `Codex `. - /// Set to an empty string to disable automatic commit attribution. - pub commit_attribution: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index faf7ec6817..847cd48399 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4026,10 +4026,6 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, - "commit_attribution": { - "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", - "type": "string" - }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 664730805a..d4db387631 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7357,7 +7357,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7805,7 +7804,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7967,7 +7965,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -8114,7 +8111,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2071e1ed25..4aa3230332 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -478,15 +478,6 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// - `None`: use default attribution (`Codex `) - /// - `Some("")` or whitespace-only: disable commit attribution - /// - `Some("...")`: use the provided attribution text verbatim - pub commit_attribution: Option, - /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -2809,8 +2800,6 @@ impl Config { } }); - let commit_attribution = cfg.commit_attribution; - // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -3052,7 +3041,6 @@ impl Config { personality, developer_instructions, compact_prompt, - commit_attribution, include_permissions_instructions, include_apps_instructions, include_collaboration_mode_instructions, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 577b2fa5ec..36e4e866d9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6493,10 +6493,10 @@ async fn make_multi_agent_v2_usage_hint_test_session( (session, turn_context) } -struct GitAttributionTestContributor; -struct GitAttributionTestState; +struct PromptExtensionTestContributor; +struct PromptExtensionTestState; -impl codex_extension_api::ContextContributor for GitAttributionTestContributor { +impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { fn contribute<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, @@ -6506,11 +6506,11 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { > { Box::pin(async move { thread_store - .get::() + .get::() .is_some() .then(|| { codex_extension_api::PromptFragment::developer_policy( - "git attribution extension enabled", + "prompt extension enabled", ) }) .into_iter() @@ -6519,21 +6519,21 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { } } -fn git_attribution_test_registry() +fn prompt_extension_test_registry() -> Arc> { let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); - builder.prompt_contributor(Arc::new(GitAttributionTestContributor)); + builder.prompt_contributor(Arc::new(PromptExtensionTestContributor)); Arc::new(builder.build()) } #[tokio::test] -async fn build_initial_context_includes_git_attribution_from_extensions() { +async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); session .services .thread_extension_data - .insert(GitAttributionTestState); + .insert(PromptExtensionTestState); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6542,15 +6542,15 @@ async fn build_initial_context_includes_git_attribution_from_extensions() { developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "expected git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "expected prompt extension developer text, got {developer_messages:?}" ); } #[tokio::test] -async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() { +async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6559,8 +6559,8 @@ async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() !developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "did not expect git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "did not expect prompt extension developer text, got {developer_messages:?}" ); } diff --git a/codex-rs/ext/git-attribution/BUILD.bazel b/codex-rs/ext/git-attribution/BUILD.bazel deleted file mode 100644 index 0cb1ab5764..0000000000 --- a/codex-rs/ext/git-attribution/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "git-attribution", - crate_name = "codex_git_attribution", -) diff --git a/codex-rs/ext/git-attribution/Cargo.toml b/codex-rs/ext/git-attribution/Cargo.toml deleted file mode 100644 index edf8c07196..0000000000 --- a/codex-rs/ext/git-attribution/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-git-attribution" -version.workspace = true - -[lib] -name = "codex_git_attribution" -path = "src/lib.rs" -doctest = false - -[lints] -workspace = true - -[dependencies] -codex-core = { workspace = true } -codex-extension-api = { workspace = true } -codex-features = { workspace = true } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/git-attribution/src/lib.rs b/codex-rs/ext/git-attribution/src/lib.rs deleted file mode 100644 index 03655296da..0000000000 --- a/codex-rs/ext/git-attribution/src/lib.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::sync::Arc; - -use codex_core::config::Config; -use codex_extension_api::ContextContributor; -use codex_extension_api::ExtensionData; -use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::PromptFragment; -use codex_extension_api::ThreadLifecycleContributor; -use codex_extension_api::ThreadStartInput; -use codex_features::Feature; - -const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; - -/// Contributes the configured git-attribution instruction. -#[derive(Clone, Copy, Debug, Default)] -pub struct GitAttributionExtension; - -impl ContextContributor for GitAttributionExtension { - fn contribute<'a>( - &'a self, - _session_store: &'a ExtensionData, - thread_store: &'a ExtensionData, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - let Some(config_store) = thread_store.get::() else { - return Vec::new(); - }; - if !config_store.enabled { - return Vec::new(); - } - build_instruction(config_store.prompt.as_deref()) - .map(PromptFragment::developer_policy) - .into_iter() - .collect() - }) - } -} - -#[derive(Clone, Debug, Default)] -struct GitAttributionConfig { - enabled: bool, - prompt: Option, -} - -impl ThreadLifecycleContributor for GitAttributionExtension { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { - input.thread_store.insert(GitAttributionConfig { - enabled: input.config.features.enabled(Feature::CodexGitCommit), - prompt: input.config.commit_attribution.clone(), - }); - } -} - -/// Installs the git-attribution contributors into the extension registry. -pub fn install(registry: &mut ExtensionRegistryBuilder) { - let extension = Arc::new(GitAttributionExtension); - registry.thread_lifecycle_contributor(extension.clone()); - registry.prompt_contributor(extension); -} - -fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { - let value = resolve_attribution_value(config_attribution)?; - Some(format!("Co-authored-by: {value}")) -} - -fn build_instruction(config_attribution: Option<&str>) -> Option { - let trailer = build_commit_message_trailer(config_attribution)?; - Some(format!( - "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." - )) -} - -fn resolve_attribution_value(config_attribution: Option<&str>) -> Option { - match config_attribution { - Some(value) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - } - None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::build_commit_message_trailer; - use super::build_instruction; - use super::resolve_attribution_value; - - #[test] - fn blank_attribution_disables_trailer_prompt() { - assert_eq!(build_commit_message_trailer(Some("")), None); - assert_eq!(build_instruction(Some(" ")), None); - } - - #[test] - fn default_attribution_uses_codex_trailer() { - assert_eq!( - build_commit_message_trailer(/*config_attribution*/ None).as_deref(), - Some("Co-authored-by: Codex ") - ); - } - - #[test] - fn resolve_value_handles_default_custom_and_blank() { - assert_eq!( - resolve_attribution_value(/*config_attribution*/ None), - Some("Codex ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent ")), - Some("MyAgent ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent")), - Some("MyAgent".to_string()) - ); - assert_eq!(resolve_attribution_value(Some(" ")), None); - } - - #[test] - fn instruction_mentions_trailer_and_omits_generated_with() { - let instruction = - build_instruction(Some("AgentX ")).expect("instruction expected"); - assert!(instruction.contains("Co-authored-by: AgentX ")); - assert!(instruction.contains("exactly once")); - assert!(!instruction.contains("Generated-with")); - } -} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 75535cbaaa..c0a7a8a54d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -130,7 +130,7 @@ pub enum Feature { RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, - /// Enable git commit attribution guidance via model instructions. + /// Removed legacy git commit attribution guidance flag. CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, @@ -784,11 +784,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - // Experimental program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::CodexGitCommit, key: "codex_git_commit", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 5f9fa813e0..fff914eff3 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -196,7 +196,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R include_skill_instructions: false, include_environment_context: false, compact_prompt: None, - commit_attribution: None, notify: None, tui_notifications: TuiNotificationSettings::default(), animations: true, From 9c691b74d682bd9cc37d6f294336b3210f429424 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 13 May 2026 12:35:37 -0700 Subject: [PATCH 15/52] chore(config) rm tools.view_image (#22501) ## Summary It appears this config flag has been broken/a noop for quite some time: since https://github.com/openai/codex/pull/8850. Let's simplify and get rid of this. ## Testing - [x] Updated unit tests --- .../json/codex_app_server_protocol.schemas.json | 6 ------ .../codex_app_server_protocol.v2.schemas.json | 6 ------ .../schema/json/v2/ConfigReadResponse.json | 6 ------ .../schema/typescript/v2/ToolsV2.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v1.rs | 1 - .../src/protocol/v2/config.rs | 1 - codex-rs/app-server/tests/suite/v2/config_rpc.rs | 11 ----------- codex-rs/config/src/config_toml.rs | 5 ----- codex-rs/config/src/profile_toml.rs | 1 - codex-rs/core/config.schema.json | 8 -------- codex-rs/core/src/config/config_tests.rs | 16 ++-------------- 11 files changed, 3 insertions(+), 60 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 667607d43e..6da572813f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -17693,12 +17693,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c1a99eddda..e338f4151f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -15517,12 +15517,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 87a826e5af..2e0a05725c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -748,12 +748,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts index 784991f017..13dc06e915 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WebSearchToolConfig } from "../WebSearchToolConfig"; -export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, }; +export type ToolsV2 = { web_search: WebSearchToolConfig | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 95ab710a6b..f8e9afdd12 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -228,7 +228,6 @@ pub struct Profile { #[serde(rename_all = "camelCase")] pub struct Tools { pub web_search: Option, - pub view_image: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 2c0e2df8e5..28a2ad01f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -120,7 +120,6 @@ pub struct SandboxWorkspaceWrite { #[ts(export_to = "v2/")] pub struct ToolsV2 { pub web_search: Option, - pub view_image: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index b5f795740c..406dc9ef6c 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -101,9 +101,6 @@ model = "gpt-user" [tools.web_search] context_size = "low" allowed_domains = ["example.com"] - -[tools] -view_image = false "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -138,7 +135,6 @@ view_image = false allowed_domains: Some(vec!["example.com".to_string()]), location: None, }), - view_image: Some(false), } ); assert_eq!( @@ -159,13 +155,6 @@ view_image = false file: user_file.clone(), } ); - assert_eq!( - origins.get("tools.view_image").expect("origin").name, - ConfigLayerSource::User { - file: user_file.clone(), - } - ); - let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index fdca03de1a..b627a83726 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -598,10 +598,6 @@ pub struct ToolsToml { deserialize_with = "deserialize_optional_web_search_tool_config" )] pub web_search: Option, - - /// Enable the `view_image` tool that lets the agent attach local images. - #[serde(default)] - pub view_image: Option, } #[derive(Deserialize)] @@ -679,7 +675,6 @@ impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { web_search: tools_toml.web_search.is_some().then_some(true), - view_image: tools_toml.view_image, } } } diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 11d3a6a403..ed036d1b4e 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -61,7 +61,6 @@ pub struct ConfigProfile { pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - pub tools_view_image: Option, pub tools: Option, pub web_search: Option, pub analytics: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 847cd48399..82fde87e78 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -685,9 +685,6 @@ "tools": { "$ref": "#/definitions/ToolsToml" }, - "tools_view_image": { - "type": "boolean" - }, "tui": { "allOf": [ { @@ -2504,11 +2501,6 @@ "ToolsToml": { "additionalProperties": false, "properties": { - "view_image": { - "default": null, - "description": "Enable the `view_image` tool that lets the agent attach local images.", - "type": "boolean" - }, "web_search": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d4db387631..f6d6eff968 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -376,13 +376,7 @@ web_search = true ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] @@ -395,13 +389,7 @@ web_search = false ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] From fb7cfc813a4fa09a3fe479a33ca071c4c4f97fc3 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 13 May 2026 12:38:34 -0700 Subject: [PATCH 16/52] fix: prevent codex-backend from stealing originator (#22533) ## Why Remote control starts by letting `codex-backend` initialize against the app-server as an infrastructure health/proxy client before the real remote client connects. App-server initialization also sets the process-wide `originator` from `client_info.name`, so `codex-backend` could become the sticky originator for later model/API requests even after the real client initialized. ## What changed - Treat `codex-backend` as a non-originating initialize client, alongside the existing `codex_app_server_daemon` probe client. - Preserve normal per-connection initialize behavior, including session metadata and initialize analytics. - Add regression coverage that verifies `codex-backend` initialize does not replace the default originator. ## Testing - `cargo test -p codex-app-server --test all initialize_codex_backend_does_not_override_originator` --- .../initialize_processor.rs | 4 +-- .../app-server/tests/suite/v2/initialize.rs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index c33af189cf..a40007db11 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -13,7 +13,7 @@ use super::*; use crate::message_processor::ConnectionSessionState; use crate::message_processor::InitializedConnectionSessionState; -const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon"; +const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"]; #[derive(Clone)] pub(crate) struct InitializeRequestProcessor { @@ -92,7 +92,7 @@ impl InitializeRequestProcessor { } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); - let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME; + let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str()); let codex_home = self.config.codex_home.clone(); if session .initialize(InitializedConnectionSessionState { diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 3d3a473fab..47cacdb20e 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -89,6 +89,33 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { Ok(()) } +#[tokio::test] +async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex-backend".to_string(), + title: Some("Codex Backend".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { user_agent, .. } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_cli_rs/")); + Ok(()) +} + #[tokio::test] async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); From d666238b4058e5f77c5b8925c25929e3ceae55a6 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Wed, 13 May 2026 12:46:51 -0700 Subject: [PATCH 17/52] Shard Bazel Windows tests across jobs (#22408) ## Summary - split the single PR-blocking Bazel Windows test leg into four Windows shard jobs - preserve the existing required Windows Bazel check name with a lightweight aggregate gate - keep Linux/macOS Bazel test jobs and the separate Windows clippy/release jobs unchanged ## Why The ordinary PR Windows Bazel test leg was one GitHub Actions job, so Bazel only had in-job parallelism. This gives that lane real job-level fanout across separate Windows hosts while keeping the target set disjoint via stable label hashing. ## Evidence - final pre-rebase green run: `25774733562` - Windows shard target counts: `61/212`, `48/212`, `52/212`, `51/212` - Windows test fanout completed in about 7m29s versus a recent monolithic median around 22m26s ## Notes - this is scoped to the Bazel Windows test leg only - each shard keeps the existing Windows cross-compile/RBE path and restores the former monolithic Windows test cache - shard jobs do not upload duplicate repository caches after test work, keeping cache cleanup off the PR-blocking shard path - no local validation run; relying on GitHub Actions for the workflow-shaped check Co-authored-by: Codex --- .github/workflows/bazel.yml | 133 +++++++++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index cc3968d306..11c0988ceb 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,10 +17,10 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: test: - # PRs use a fast Windows cross-compiled test leg for pre-merge signal. - # Post-merge pushes to main also run the native Windows test job below for - # broader Windows signal without putting PR latency back on the critical - # path. Cargo CI owns V8/code-mode test coverage for now. + # PRs use the sharded Windows cross-compiled test jobs below. Post-merge + # pushes to main also run the native Windows test job for broader Windows + # signal without putting PR latency back on the critical path. Cargo CI + # owns V8/code-mode test coverage for now. timeout-minutes: 30 strategy: fail-fast: false @@ -44,12 +44,6 @@ jobs: # - os: ubuntu-24.04-arm # target: aarch64-unknown-linux-gnu - # Windows fast path: build the windows-gnullvm binaries with Linux - # RBE, then run the resulting Windows tests on the Windows runner. - # Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad - # non-code-mode signal. - - os: windows-latest - target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} # Configure a human readable name for each job @@ -108,13 +102,6 @@ jobs: --test_verbose_timeout_warnings --build_metadata=COMMIT_SHA=${GITHUB_SHA} ) - if [[ "${RUNNER_OS}" == "Windows" ]]; then - bazel_wrapper_args+=( - --windows-cross-compile - --remote-download-toplevel - ) - fi - ./.github/scripts/run-bazel-ci.sh \ "${bazel_wrapper_args[@]}" \ -- \ @@ -141,6 +128,118 @@ jobs: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} + test-windows-shard: + # Split the Windows Bazel test leg across separate Windows + # hosts. Each shard still uses Linux RBE for build actions, but the test + # execution itself happens on its own Windows runner. + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: + - 1 + - 2 + - 3 + - 4 + runs-on: windows-latest + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Prepare Bazel CI + id: prepare_bazel + uses: ./.github/actions/prepare-bazel-ci + with: + target: x86_64-pc-windows-gnullvm + # Reuse the former monolithic Windows test cache for restores. Do + # not save it from every shard below; duplicate uploads would sit on + # the PR-blocking critical path after the useful test work is done. + cache-scope: bazel-test + install-test-prereqs: "true" + + - name: bazel test shard + env: + BAZEL_TEST_SHARD: ${{ matrix.shard }} + BAZEL_TEST_SHARD_COUNT: 4 + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + set -euo pipefail + + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + mapfile -t bazel_targets < <( + MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + | LC_ALL=C sort + ) + + selected_targets=() + for bazel_target in "${bazel_targets[@]}"; do + target_bucket="$( + printf '%s\n' "${bazel_target}" \ + | cksum \ + | awk -v shard_count="${BAZEL_TEST_SHARD_COUNT}" '{ print ($1 % shard_count) + 1 }' + )" + if [[ "${target_bucket}" == "${BAZEL_TEST_SHARD}" ]]; then + selected_targets+=("${bazel_target}") + fi + done + + if [[ ${#selected_targets[@]} -eq 0 ]]; then + echo "No Bazel test targets selected for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." >&2 + exit 1 + fi + + echo "Selected ${#selected_targets[@]} of ${#bazel_targets[@]} Bazel test targets for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." + + bazel_test_args=( + test + --skip_incompatible_explicit_targets + --test_tag_filters=-argument-comment-lint + --test_verbose_timeout_warnings + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + --build_metadata=TAG_windows_test_shard=${BAZEL_TEST_SHARD} + ) + + ./.github/scripts/run-bazel-ci.sh \ + --print-failed-action-summary \ + --print-failed-test-logs \ + --windows-cross-compile \ + --remote-download-toplevel \ + -- \ + "${bazel_test_args[@]}" \ + -- \ + "${selected_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bazel-execution-logs-test-x86_64-pc-windows-gnullvm-shard-${{ matrix.shard }} + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + test-windows: + # Preserve the existing required-check surface while the real work happens + # in the sharded Windows jobs above. + if: always() + needs: test-windows-shard + runs-on: ubuntu-24.04 + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm + + steps: + - name: Confirm Windows Bazel test shards passed + shell: bash + run: | + if [[ "${{ needs.test-windows-shard.result }}" != "success" ]]; then + echo "Windows Bazel test shards finished with result: ${{ needs.test-windows-shard.result }}" >&2 + exit 1 + fi + test-windows-native-main: # Native Windows Bazel tests are slower and frequently approach the # 30-minute PR budget. Run this only for post-merge commits to main and give From 3ac1d155987b40a2a4564428b44460f8fe746473 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 13 May 2026 13:18:56 -0700 Subject: [PATCH 18/52] Use selected environment cwd for filesystem helpers (#22542) ## Why `TurnContext::cwd` is deprecated in favor of resolving paths from the selected turn environment cwd. A few filesystem-oriented paths were still constructing sandbox context from the legacy cwd and then mutating it afterward, or resolving local file paths through the deprecated helper. ## What changed - Make `TurnContext::file_system_sandbox_context` take the trusted cwd explicitly. - Pass the selected turn environment cwd directly from `apply_patch` and `view_image` call sites. - Restrict `spawn_agents_on_csv` to exactly one local environment and resolve input/output CSV paths from that local environment cwd. - Remove a redundant test setup assignment that only synchronized deprecated `TurnContext::cwd` with a replaced config. ## Validation - `cargo test -p codex-core view_image` - `cargo test -p codex-core maybe_persist_mcp_tool_approval_writes_project_config_for_project_server` - `cargo test -p codex-core parse_csv_supports_quotes_and_commas` - `git diff --check` --- codex-rs/core/src/mcp_tool_call_tests.rs | 4 --- codex-rs/core/src/session/turn_context.rs | 4 +-- .../agent_jobs/spawn_agents_on_csv.rs | 26 ++++++++++++++----- .../core/src/tools/handlers/apply_patch.rs | 6 ++--- .../core/src/tools/handlers/view_image.rs | 8 ++---- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8b4f1ba223..714b0b99e6 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2233,10 +2233,6 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve .build() .await .expect("load project config"); - #[allow(deprecated)] - { - turn_context.cwd = config.cwd.clone(); - } turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8caec7aaea..71e238a540 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -301,6 +301,7 @@ impl TurnContext { pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, + cwd: &AbsolutePathBuf, ) -> FileSystemSandboxContext { let (base_file_system_sandbox_policy, base_network_sandbox_policy) = self.permission_profile.to_runtime_permissions(); @@ -319,8 +320,7 @@ impl TurnContext { ); FileSystemSandboxContext { permissions, - #[allow(deprecated)] - cwd: Some(self.cwd.clone()), + cwd: Some(cwd.clone()), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index e1a37be6b6..6ad0515e81 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -7,6 +7,7 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; use super::*; @@ -67,9 +68,9 @@ pub async fn handle( )); } + let cwd = single_local_environment_cwd(&turn)?; let db = required_state_db(&session)?; - #[allow(deprecated)] - let input_path = turn.resolve_path(Some(args.csv_path)); + let input_path = cwd.join(args.csv_path); let input_path_display = input_path.display().to_string(); let csv_content = tokio::fs::read_to_string(&input_path) .await @@ -142,10 +143,7 @@ pub async fn handle( let job_id = Uuid::new_v4().to_string(); let output_csv_path = args.output_csv_path.map_or_else( || default_output_csv_path(&input_path, job_id.as_str()), - |path| { - #[allow(deprecated)] - turn.resolve_path(Some(path)) - }, + |path| cwd.join(path), ); let job_suffix = &job_id[..8]; let job_name = format!("agent-job-{job_suffix}"); @@ -290,3 +288,19 @@ pub async fn handle( })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } + +fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { + let [turn_environment] = turn.environments.turn_environments.as_slice() else { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv requires exactly one local environment".to_string(), + )); + }; + + if turn_environment.environment.is_remote() { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv is not supported for remote environments".to_string(), + )); + } + + Ok(&turn_environment.cwd) +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 32edd21da1..5a709b287f 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -345,8 +345,7 @@ impl ToolExecutor for ApplyPatchHandler { }; let cwd = turn_environment.cwd.clone(); let fs = turn_environment.environment.get_filesystem(); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await { @@ -499,8 +498,7 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index bb74adb86d..1587e90ddb 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -134,8 +134,7 @@ impl ToolExecutor for ViewImageHandler { }; let cwd = turn_environment.cwd.clone(); let abs_path = cwd.join(path); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); let fs = turn_environment.environment.get_filesystem(); let metadata = fs @@ -282,10 +281,7 @@ mod tests { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - #[allow(deprecated)] - { - turn.cwd = image_cwd.clone(); - } + turn.environments .turn_environments .first_mut() From 0a2d751fc2704773432751312f171066357ebdad Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Wed, 13 May 2026 13:26:04 -0700 Subject: [PATCH 19/52] Add callback ids to local MCP OAuth redirects (#20237) ## Summary - Add a deterministic callback-id path segment to local MCP OAuth redirect URIs before starting authorization. - Derive the callback id from the normalized MCP server URL and encode it as a 12-character URL-safe hash. - Reuse the existing exact callback-path validation so OAuth completion only succeeds on the callback path that was sent in the redirect URI. ## Context Slack thread: https://openai.slack.com/archives/C087WB3AGCR/p1777480566571699 That thread calls out the OAuth mix-up class of issue for MCP servers. The connector/App Connect flow already has a callback_id concept that binds the OAuth callback URL to the MCP app/server identity. Codex desktop's local MCP OAuth flow was still using a generic local callback path like `/callback`, so this PR adds the same shape to the shared local MCP OAuth helper. ## Behavior Before this change, local MCP OAuth used: - default local callback URL: `http://127.0.0.1:/callback` - configured callback URL: `` unchanged After this change, Codex appends a deterministic callback-id segment: - default local callback URL: `http://127.0.0.1:/callback/` - configured callback URL: `/` The local callback server already compares the incoming request path against the path from the redirect URI. By appending the callback id before both authorization and callback validation, callbacks that arrive on the old generic path or a mismatched callback-id path are rejected. The callback id is bound to the MCP endpoint URL, including path and query, so path-based multi-tenant MCP deployments on the same origin do not share a callback path. URL fragments are ignored because they are not sent to the server. The change lives in `codex-rmcp-client`, so it covers both the normal desktop MCP OAuth login path and silent/plugin-triggered MCP OAuth login paths that use the same `perform_oauth_login_*` helpers. ## Scope and non-goals - This does not change the app-server protocol or desktop webview request shape. - This does not implement RFC 9207 `iss` validation; issuer validation is still useful when providers return `iss`. - This does not make arbitrary untrusted MCP servers safe to use. It specifically adds callback URL binding for the local MCP OAuth flow. ## Validation - `cargo fmt --all` - `cargo test -p codex-rmcp-client perform_oauth_login` --- codex-rs/Cargo.lock | 1 + codex-rs/rmcp-client/Cargo.toml | 1 + .../rmcp-client/src/perform_oauth_login.rs | 95 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a0a49e4399..564c1a9c78 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3471,6 +3471,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "bytes", "codex-api", "codex-client", diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 9be90e277a..0efd8a4178 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -13,6 +13,7 @@ axum = { workspace = true, default-features = false, features = [ "http1", "tokio", ] } +base64 = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 5bdb315381..f42786c652 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -7,9 +7,13 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use reqwest::ClientBuilder; use reqwest::Url; use rmcp::transport::auth::OAuthState; +use sha2::Digest; +use sha2::Sha256; use tiny_http::Response; use tiny_http::Server; use tokio::sync::oneshot; @@ -378,6 +382,31 @@ fn resolve_redirect_uri(server: &Server, callback_url: Option<&str>) -> Result Result { + let mut parsed = + Url::parse(server_url).with_context(|| format!("invalid MCP server URL `{server_url}`"))?; + parsed + .host_str() + .ok_or_else(|| anyhow!("MCP server URL `{server_url}` must include a host"))?; + parsed.set_fragment(None); + + let digest = Sha256::digest(parsed.as_str().as_bytes()); + Ok(URL_SAFE_NO_PAD.encode(&digest[..9])) +} + +fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) -> Result { + let mut parsed = Url::parse(redirect_uri) + .with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?; + let path = parsed.path(); + let new_path = if path.ends_with('/') { + format!("{path}{callback_id}") + } else { + format!("{path}/{callback_id}") + }; + parsed.set_path(&new_path); + Ok(parsed.to_string()) +} + fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result { let parsed = Url::parse(redirect_uri) .with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?; @@ -428,6 +457,8 @@ impl OauthLoginFlow { }; let redirect_uri = resolve_redirect_uri(&server, callback_url)?; + let callback_id = callback_id_from_server_url(server_url)?; + let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?; let callback_path = callback_path_from_redirect_uri(&redirect_uri)?; let (tx, rx) = oneshot::channel(); @@ -577,7 +608,9 @@ mod tests { use super::CallbackOutcome; use super::OAuthProviderError; + use super::append_callback_id_to_redirect_uri; use super::append_query_param; + use super::callback_id_from_server_url; use super::callback_path_from_redirect_uri; use super::parse_oauth_callback; @@ -593,6 +626,19 @@ mod tests { assert!(matches!(parsed, CallbackOutcome::Success(_))); } + #[test] + fn parse_oauth_callback_accepts_callback_id_path() { + let parsed = + parse_oauth_callback("/callback/abc123?code=abc&state=xyz", "/callback/abc123"); + assert!(matches!(parsed, CallbackOutcome::Success(_))); + } + + #[test] + fn parse_oauth_callback_rejects_missing_callback_id_path() { + let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/callback/abc123"); + assert!(matches!(parsed, CallbackOutcome::Invalid)); + } + #[test] fn parse_oauth_callback_rejects_wrong_path() { let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/oauth/callback"); @@ -622,6 +668,55 @@ mod tests { assert_eq!(path, "/oauth/callback"); } + #[test] + fn callback_id_is_bound_to_server_url() { + let callback_id = callback_id_from_server_url("https://mcp.example.com/mcp?tenant=one") + .expect("server URL should parse"); + let same_without_fragment = + callback_id_from_server_url("https://mcp.example.com/mcp?tenant=one#unused") + .expect("server URL should parse"); + let different_path = callback_id_from_server_url("https://mcp.example.com/sse?tenant=one") + .expect("server URL should parse"); + let different_query = callback_id_from_server_url("https://mcp.example.com/mcp?tenant=two") + .expect("server URL should parse"); + let different_origin = callback_id_from_server_url("https://mcp.example.com:8443/mcp") + .expect("server URL should parse"); + + assert_eq!(callback_id, same_without_fragment); + assert_ne!(callback_id, different_path); + assert_ne!(callback_id, different_query); + assert_ne!(callback_id, different_origin); + assert_eq!(callback_id.len(), 12); + assert!( + callback_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + ); + } + + #[test] + fn callback_id_is_appended_to_redirect_uri_path() { + let redirect_uri = + append_callback_id_to_redirect_uri("http://127.0.0.1:1234/callback", "abc123") + .expect("redirect URI should parse"); + + assert_eq!(redirect_uri, "http://127.0.0.1:1234/callback/abc123"); + } + + #[test] + fn callback_id_is_appended_before_redirect_uri_query() { + let redirect_uri = append_callback_id_to_redirect_uri( + "https://callbacks.example.com/oauth/callback?provider=github", + "abc123", + ) + .expect("redirect URI should parse"); + + assert_eq!( + redirect_uri, + "https://callbacks.example.com/oauth/callback/abc123?provider=github" + ); + } + #[test] fn append_query_param_adds_resource_to_absolute_url() { let url = append_query_param( From 14473c216ff7bae23951860840c7d68105507657 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 13 May 2026 14:10:28 -0700 Subject: [PATCH 20/52] Enable plugin hooks by default (#22549) # Why Plugin-bundled hooks are already wired through the plugin manager, session setup, and app-server hook listing paths. Keeping `plugin_hooks` disabled by default means users still need an explicit feature opt-in before that existing behavior participates in normal plugin loading. # What - mark `plugin_hooks` as stable and enable it by default - add feature-registry test coverage for the new default/stage pairing Validation: - `cargo test -p codex-features` - `just fmt` --- codex-rs/features/src/lib.rs | 4 ++-- codex-rs/features/src/tests.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index c0a7a8a54d..d8a0a598e7 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -975,8 +975,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::PluginHooks, key: "plugin_hooks", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::InAppBrowser, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 04e236f5ee..2b10f83362 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -183,6 +183,12 @@ fn tool_search_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSearch.default_enabled(), true); } +#[test] +fn plugin_hooks_are_stable_and_enabled_by_default() { + assert_eq!(Feature::PluginHooks.stage(), Stage::Stable); + assert_eq!(Feature::PluginHooks.default_enabled(), true); +} + #[test] fn browser_controls_are_stable_and_enabled_by_default() { assert_eq!(Feature::InAppBrowser.stage(), Stage::Stable); From 16592f593dbdc72cbf717489947483e7baa7d1cf Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 13 May 2026 14:11:10 -0700 Subject: [PATCH 21/52] Use plugin/list to get list of plugins for mentions (#22375) This switches TUI plugin mentions to use app-server `plugin/list` for plugin inventory and metadata instead of `PluginManager`, while keeping the same mention-eligibility filters as before. Same filters as before: - Only plugins in the current config / cwd scope. - Only installed and enabled plugins. - Only plugins that actually expose a capability, meaning at least one skill, MCP server, or app connector. - Uses `plugin/list` for the mention names/descriptions --- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/background_requests.rs | 54 ++++--- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/plugin_mentions.rs | 147 ++++++++++++++++++++ 4 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/app/plugin_mentions.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 62b29f5b3e..bab42be2f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -127,7 +127,6 @@ use codex_app_server_protocol::TurnStatus; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; -use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_model_provider::create_model_provider; @@ -196,6 +195,7 @@ mod loaded_threads; mod pending_interactive_replay; mod pets; mod platform_actions; +mod plugin_mentions; mod replay_filter; mod resize_reflow; mod session_lifecycle; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 57c82cb7f7..c323a2d5bf 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -4,6 +4,7 @@ //! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so //! the main event loop remains single-threaded. +use super::plugin_mentions::fetch_plugin_mentions; use super::*; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; @@ -358,8 +359,9 @@ impl App { }); } - pub(super) fn refresh_plugin_mentions(&mut self) { + pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { let config = self.config.clone(); + let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); if !config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); @@ -367,15 +369,16 @@ impl App { } tokio::spawn(async move { - let plugins_input = config.plugins_config_input(); - let plugins = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .to_vec(); - app_event_tx.send(AppEvent::PluginMentionsLoaded { - plugins: Some(plugins), - }); + match fetch_plugin_mentions(request_handle, config).await { + Ok(plugins) => { + app_event_tx.send(AppEvent::PluginMentionsLoaded { + plugins: Some(plugins), + }); + } + Err(err) => { + tracing::warn!(error = %err, "plugin/list failed while refreshing plugin mention candidates"); + } + } }); } @@ -635,18 +638,9 @@ pub(super) async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, ) -> Result { - let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; - let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); - let mut response = request_handle - .request_typed(ClientRequest::PluginList { - request_id, - params: PluginListParams { - cwds: Some(vec![cwd]), - marketplace_kinds: None, - }, - }) + let mut response = request_plugin_list(request_handle, cwd) .await - .wrap_err("plugin/list failed in TUI")?; + .wrap_err("plugin/list failed while loading the plugins menu")?; hide_cli_only_plugin_marketplaces(&mut response); Ok(response) } @@ -659,6 +653,24 @@ pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListRespons .retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str())); } +pub(super) async fn request_plugin_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; + let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginList { + request_id, + params: PluginListParams { + cwds: Some(vec![cwd]), + marketplace_kinds: None, + }, + }) + .await + .wrap_err("plugin/list failed in TUI") +} + pub(super) async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1d2df3db16..16d7daee72 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1253,7 +1253,7 @@ impl App { } } AppEvent::RefreshPluginMentions => { - self.refresh_plugin_mentions(); + self.refresh_plugin_mentions(app_server); } AppEvent::PluginMentionsLoaded { mut plugins } => { if !self.config.features.enabled(Feature::Plugins) { diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs new file mode 100644 index 0000000000..ef300896bc --- /dev/null +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -0,0 +1,147 @@ +//! Plugin mention capability enrichment for the TUI. +//! +//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses +//! the older local bulk capability summaries. That keeps the feature app-server-shaped without +//! paying for a `plugin/read` per plugin. + +use super::background_requests::request_plugin_list; +use super::*; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSummary; +use codex_core_plugins::PluginsManager; +use codex_plugin::PluginCapabilitySummary; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct PluginMentionEntry { + config_name: String, + display_name: String, + description: Option, +} + +impl PluginMentionEntry { + fn capability_summary( + self, + capabilities_by_config_name: &HashMap, + ) -> Option { + let capabilities = capabilities_by_config_name.get(&self.config_name)?; + Some(PluginCapabilitySummary { + config_name: self.config_name, + display_name: self.display_name, + description: self.description, + has_skills: capabilities.has_skills, + mcp_server_names: capabilities.mcp_server_names.clone(), + app_connector_ids: capabilities.app_connector_ids.clone(), + }) + } +} + +pub(super) async fn fetch_plugin_mentions( + request_handle: AppServerRequestHandle, + config: crate::legacy_core::config::Config, +) -> Result> { + let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?; + let mention_entries = plugin_mention_entries_from_list_response(response); + let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await; + + Ok(mention_entries + .into_iter() + .filter_map(|entry| entry.capability_summary(&capabilities_by_config_name)) + .collect()) +} + +async fn load_plugin_mention_capabilities( + config: &crate::legacy_core::config::Config, +) -> HashMap { + let plugins_input = config.plugins_config_input(); + PluginsManager::new(config.codex_home.to_path_buf()) + .plugins_for_config(&plugins_input) + .await + .capability_summaries() + .iter() + .cloned() + .map(|summary| (summary.config_name.clone(), summary)) + .collect() +} + +fn plugin_mention_entries_from_list_response( + response: PluginListResponse, +) -> Vec { + response + .marketplaces + .into_iter() + .flat_map(plugin_mention_entries_from_marketplace) + .collect() +} + +fn plugin_mention_entries_from_marketplace( + marketplace: PluginMarketplaceEntry, +) -> Vec { + let marketplace_name = marketplace.name; + marketplace + .plugins + .into_iter() + .filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin)) + .collect() +} + +fn plugin_mention_entry( + marketplace_name: &str, + plugin: PluginSummary, +) -> Option { + if !plugin_is_eligible_for_mentions(&plugin) { + return None; + } + + let config_name = plugin_mention_config_name(marketplace_name, &plugin)?; + Some(PluginMentionEntry { + config_name, + display_name: plugin_mention_display_name(&plugin), + description: plugin_mention_description(&plugin), + }) +} + +fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { + plugin.installed && plugin.enabled +} + +fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option { + codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string()) + .map(|plugin_id| plugin_id.as_key()) + .map_err(|err| { + tracing::warn!( + plugin_name = plugin.name, + marketplace_name, + error = %err, + "skipping plugin mention with invalid identity" + ); + }) + .ok() +} + +fn plugin_mention_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_mention_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} From 5d7e6a2503fc71f09cea71bfca9e193e0c3fd215 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 13 May 2026 14:19:05 -0700 Subject: [PATCH 22/52] [codex] Fix TUI wrapping for external borrowed slices (#21235) Fixes #20587, reported by @noeljackson. This prevents the TUI wrapping code from panicking when `textwrap` returns a borrowed slice that does not point into the original source text. The fix follows the direction proposed by @misrtjakub in the issue comment: validate the borrowed slice pointer range first, and fall back to the existing owned-line mapper when the slice is external. - Guards borrowed wrapped slices before converting pointer offsets into byte ranges. - Reuses the existing owned-line range recovery path for external borrowed slices. - Adds coverage for rejecting borrowed slices outside the source text. End-user testing steps: - Start Codex in TUI mode under a PTY wrapper that can inject stdin after startup. - Inject `\x1b[200~test message\x1b[201~\r` after the TUI is ready. - Confirm Codex does not panic and the pasted text is handled normally. Local validation: - `cargo test -p codex-tui wrapping::tests::` - `cargo test -p codex-tui -- --skip status::tests::status_permissions_full_disk_managed_with_network_is_danger_full_access --skip status::tests::status_permissions_full_disk_managed_without_network_is_external_sandbox` --- codex-rs/tui/src/wrapping.rs | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 61bd8147ab..bc66b18ad5 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -49,8 +49,16 @@ where for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { - let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; - let end = start + slice.len(); + let range = borrowed_slice_range(text, slice).unwrap_or_else(|| { + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix) + }); + let start = range.start; + let end = range.end; let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count(); lines.push(start..end + trailing_spaces + 1); cursor = end + trailing_spaces; @@ -84,10 +92,16 @@ where for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { - let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; - let end = start + slice.len(); - lines.push(start..end); - cursor = end; + let range = borrowed_slice_range(text, slice).unwrap_or_else(|| { + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix) + }); + cursor = range.end; + lines.push(range); } std::borrow::Cow::Owned(slice) => { let synthetic_prefix = if line_index == 0 { @@ -104,6 +118,19 @@ where lines } +fn borrowed_slice_range(text: &str, slice: &str) -> Option> { + let text_start = text.as_ptr() as usize; + let text_end = text_start.checked_add(text.len())?; + let slice_start = slice.as_ptr() as usize; + let slice_end = slice_start.checked_add(slice.len())?; + + if slice_start < text_start || slice_end > text_end { + return None; + } + + Some((slice_start - text_start)..(slice_end - text_start)) +} + /// Maps an owned (materialized) wrapped line back to a byte range in `text`. /// /// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty @@ -1485,6 +1512,17 @@ them."# assert_eq!(range, 0..5); } + #[test] + fn borrowed_slice_range_rejects_slices_outside_source_text() { + let text = "test message"; + let external = String::from("test"); + + assert_eq!(borrowed_slice_range(text, &external), None); + + let fallback = map_owned_wrapped_line_to_range(text, /*cursor*/ 0, &external, ""); + assert_eq!(fallback, 0..4); + } + #[test] fn map_owned_wrapped_line_to_range_indent_coincides_with_source() { // When the synthetic indent prefix starts with a character that also From 9798eb377a4bac2d76cf90e3b71025d377f7cfe4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 13 May 2026 18:23:19 -0300 Subject: [PATCH 23/52] feat(cli): add codex doctor diagnostics (#22336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Users and support need a single command that captures the local Codex runtime, configuration, auth, terminal, network, and state shape without asking the user to know which diagnostic depth to choose first. `codex doctor` now runs the useful checks by default and makes the detailed human output the default because the command is usually run when someone already needs context. The command also targets concrete support failure modes we have seen while iterating on the design: - update-target mismatches like #21956, where the installed package manager target can differ from the running executable - terminal and multiplexer issues that depend on `TERM`, tmux/zellij state, color handling, and TTY metadata - provider-specific HTTP/WebSocket connectivity, including ChatGPT WebSocket handshakes and API-key/provider endpoint reachability - local state/log SQLite integrity problems and large rollout directories - feedback reports that need an attached, redacted diagnostic snapshot without asking the user to run a second command ## What Changed - Adds `codex doctor` as a grouped CLI diagnostic report with default detailed output and `--summary` for the compact view. - Adds stable report sections for Environment, Configuration, Updates, Connectivity, and Background Server, plus a top Notes block that promotes anomalies such as available updates, large rollout directories, optional MCP issues, and mixed auth signals. - Adds runtime provenance, install consistency, bundled/system search readiness, terminal/multiplexer metadata, `config.toml` parse status, auth mode details, sandbox details, feature flag summaries, update cache/latest-version state, app-server daemon state, SQLite integrity checks, rollout statistics, and provider-aware network diagnostics. - Adds ChatGPT WebSocket diagnostics that report the negotiated HTTP upgrade as `HTTP 101 Switching Protocols` and include timeout, DNS, auth, and provider context in detailed output. - Makes reachability provider-aware: API-key OpenAI setups check the API endpoint, ChatGPT auth checks the ChatGPT path, and custom/AWS/local providers check configured HTTP endpoints when available. - Adds structured, redacted JSON output where `checks` is keyed by check id and `details` is a key/value object for support tooling. - Integrates doctor with feedback uploads by attaching a best-effort `codex-doctor-report.json` report and adding derived Sentry tags for overall status and failing/warning checks. - Updates the TUI feedback consent copy so users can see that the doctor report is included when logs/diagnostics are uploaded. - Updates the CLI bug issue template to ask reporters for `codex doctor --json` and render pasted reports as JSON. ## Example Output The examples below are sanitized from local smoke runs with `--no-color` so the structure is reviewable in plain text. ### `codex doctor` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build version 0.0.0 install method other commit unknown executable ~/code/codex.fcoury-doct…x-rs/target/debug/codex ✓ install consistent context other managed by npm: no · bun: no · package root — PATH entries (2) ~/.local/share/mise/installs/node/24/bin/codex ~/.local/share/mise/shims/codex ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color terminal Ghostty TERM_PROGRAM ghostty terminal version 1.3.2-main-+b0f827665 TERM xterm-256color multiplexer tmux 3.6a tmux extended-keys on tmux allow-passthrough on tmux set-clipboard on ✓ state databases healthy CODEX_HOME ~/.codex (dir) state DB ~/.codex/state_5.sqlite (file) · integrity ok log DB ~/.codex/logs_2.sqlite (file) · integrity ok active rollouts 1,526 files · 2.53 GB (avg 1.70 MB) archived rollouts 8 files · 3.84 MB (avg 491.11 KB) Configuration ✓ config loaded model gpt-5.5 · openai cwd ~/code/codex.fcoury-doctor/codex-rs config.toml ~/.codex/config.toml config.toml parse ok MCP servers 1 feature flags 36 enabled · 7 overridden (full list with --all) overrides code_mode, code_mode_only, memories, chronicle, goals, remote_control, prevent_idle_sleep ✓ auth auth is configured auth storage mode File auth file ~/.codex/auth.json auth env vars present OPENAI_API_KEY stored auth mode chatgpt stored API key false stored ChatGPT tokens true stored agent identity false ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. configured servers 1 disabled servers 0 streamable_http servers 1 optional reachability openaiDeveloperDocs: https://developers.openai.com/mcp (HEAD connect failed; GET connect failed) ✓ sandbox restricted fs + restricted network · approval OnRequest approval policy OnRequest filesystem sandbox restricted network sandbox restricted Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout model provider openai provider name OpenAI wire API responses supports websockets true connect timeout 15000 ms auth mode chatgpt endpoint wss://chatgpt.com/backend-api/ DNS 2 IPv4, 2 IPv6, first IPv6 handshake result HTTP 101 Switching Protocols ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. reachability mode API key auth openai API https://api.openai.com/v1 connect failed (required) Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report ``` ### `codex doctor --summary` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build ✓ install consistent ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color ✓ state databases healthy Configuration ✓ config loaded ✓ auth auth is configured ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. ✓ sandbox restricted fs + restricted network · approval OnRequest Updates ✓ updates update configuration is locally consistent Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report ``` ### `codex doctor --json` shape ```json { "schema_version": 1, "overall_status": "fail", "checks": { "runtime.provenance": { "id": "runtime.provenance", "category": "Environment", "status": "ok", "summary": "local debug build", "details": { "version": "0.0.0", "install method": "other", "commit": "unknown" } }, "sandbox.helpers": { "id": "sandbox.helpers", "category": "Configuration", "status": "ok", "summary": "restricted fs + restricted network · approval OnRequest", "details": { "approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted" } } } } ``` ### `/feedback` new sentry attachment CleanShot 2026-05-13 at 15 36 14 ### New section in CLI issue template CleanShot 2026-05-13 at 15 47 24 ## How to Test 1. Run `cargo run --bin codex -- doctor --no-color`. 2. Confirm the detailed report is the default and includes promoted Notes, grouped sections, terminal details, state DB integrity, rollout stats, provider reachability, WebSocket diagnostics, and app-server status. 3. Run `cargo run --bin codex -- doctor --summary --no-color`. 4. Confirm the compact view keeps the same sections and summary counts but omits detailed key/value rows. 5. Run `cargo run --bin codex -- doctor --json`. 6. Confirm the output is redacted JSON, `checks` is an object keyed by check id, and each check's `details` is a key/value object. 7. Preview the CLI bug issue template and confirm the `Codex doctor report` field appears after the terminal field, asks for `codex doctor --json`, and renders pasted output as JSON. 8. Start a feedback flow that includes logs. 9. Confirm the upload consent copy lists `codex-doctor-report.json` alongside the log attachments. Targeted tests: - `cargo test -p codex-cli doctor` - `cargo test -p codex-app-server doctor_report_tags_summarize_status_counts` - `cargo test -p codex-feedback` - `cargo test -p codex-tui feedback_view` - `just argument-comment-lint` - `git diff --check` --- .github/ISSUE_TEMPLATE/3-cli.yml | 12 + codex-cli/bin/codex.js | 3 +- codex-rs/Cargo.lock | 6 + codex-rs/app-server/src/request_processors.rs | 1 + .../feedback_doctor_report.rs | 212 + .../request_processors/feedback_processor.rs | 16 +- codex-rs/cli/Cargo.toml | 8 + codex-rs/cli/src/doctor.rs | 3844 +++++++++++++++++ codex-rs/cli/src/doctor/background.rs | 150 + codex-rs/cli/src/doctor/output.rs | 1555 +++++++ codex-rs/cli/src/doctor/output/detail.rs | 648 +++ codex-rs/cli/src/doctor/progress.rs | 139 + codex-rs/cli/src/doctor/runtime.rs | 141 + codex-rs/cli/src/doctor/updates.rs | 227 + codex-rs/cli/src/main.rs | 22 +- codex-rs/codex-api/src/endpoint/mod.rs | 2 + .../src/endpoint/responses_websocket.rs | 92 +- codex-rs/codex-api/src/lib.rs | 2 + codex-rs/feedback/src/lib.rs | 53 +- codex-rs/state/src/lib.rs | 1 + codex-rs/state/src/runtime.rs | 47 + codex-rs/terminal-detection/src/lib.rs | 47 +- .../terminal-detection/src/terminal_tests.rs | 67 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 36 +- ...ck_upload_consent_lists_doctor_report.snap | 12 + ...ack_view__tests__feedback_view_render.snap | 4 +- ...s__feedback_good_result_consent_popup.snap | 5 +- ..._tests__feedback_upload_consent_popup.snap | 5 +- codex-rs/tui/src/pets/image_protocol.rs | 4 +- 29 files changed, 7339 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/feedback_doctor_report.rs create mode 100644 codex-rs/cli/src/doctor.rs create mode 100644 codex-rs/cli/src/doctor/background.rs create mode 100644 codex-rs/cli/src/doctor/output.rs create mode 100644 codex-rs/cli/src/doctor/output/detail.rs create mode 100644 codex-rs/cli/src/doctor/progress.rs create mode 100644 codex-rs/cli/src/doctor/runtime.rs create mode 100644 codex-rs/cli/src/doctor/updates.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap diff --git a/.github/ISSUE_TEMPLATE/3-cli.yml b/.github/ISSUE_TEMPLATE/3-cli.yml index 37229c7f2b..cfd368c0ba 100644 --- a/.github/ISSUE_TEMPLATE/3-cli.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -11,6 +11,8 @@ body: Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed. + If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues. + - type: input id: version attributes: @@ -43,6 +45,16 @@ body: description: | Also note any multiplexer in use (screen / tmux / zellij). E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) + - type: textarea + id: doctor + attributes: + label: Codex doctor report + description: | + If available, run `codex doctor --json` and paste the full output here. + + The report is designed to redact secrets, but please review it before submitting. + If your Codex version does not support `doctor`, write `not available`. + render: json - type: textarea id: actual attributes: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 67ab3e2d95..475239549a 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -2,7 +2,7 @@ // Unified entry point for the Codex CLI. import { spawn } from "node:child_process"; -import { existsSync } from "fs"; +import { existsSync, realpathSync } from "fs"; import { createRequire } from "node:module"; import path from "path"; import { fileURLToPath } from "url"; @@ -171,6 +171,7 @@ const packageManagerEnvVar = ? "CODEX_MANAGED_BY_BUN" : "CODEX_MANAGED_BY_NPM"; env[packageManagerEnvVar] = "1"; +env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, "..")); const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 564c1a9c78..ec43bd6f38 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2220,6 +2220,7 @@ dependencies = [ "assert_matches", "clap", "clap_complete", + "codex-api", "codex-app-server", "codex-app-server-daemon", "codex-app-server-protocol", @@ -2234,10 +2235,12 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-install-context", "codex-login", "codex-mcp", "codex-mcp-server", "codex-memories-write", + "codex-model-provider", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", @@ -2253,11 +2256,14 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "crossterm", + "http 1.4.0", "libc", "owo-colors", "predicates", "pretty_assertions", "regex-lite", + "serde", "serde_json", "sqlx", "supports-color 3.0.2", diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cf68f638ba..d2b681d063 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -439,6 +439,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod feedback_doctor_report; mod feedback_processor; mod fs_processor; mod git_processor; diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs new file mode 100644 index 0000000000..3bd9c9fd9b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -0,0 +1,212 @@ +//! Builds a redacted doctor report attachment for feedback uploads. +//! +//! Feedback upload should never depend on doctor succeeding. This module runs +//! the configured Codex executable as a subprocess, accepts only valid JSON from +//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise +//! skips the attachment with a warning. Keeping the report generation out of the +//! app-server process avoids sharing doctor internals across crates while still +//! attaching exactly the same JSON a user could copy from the CLI. + +use std::collections::BTreeMap; +use std::time::Duration; + +use codex_core::config::Config; +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; +use codex_feedback::FeedbackAttachment; +use serde_json::Value; +use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; + +const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25); +const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256; + +/// Redacted doctor report data that can be merged into a feedback upload. +pub(crate) struct DoctorFeedbackReport { + /// JSON support report to upload as `codex-doctor-report.json`. + pub(crate) attachment: FeedbackAttachment, + /// Low-cardinality Sentry tags derived from the report status and check ids. + pub(crate) tags: BTreeMap, +} + +/// Runs `codex doctor --json` and returns a best-effort feedback attachment. +/// +/// Failure to spawn Codex, finish before the timeout, or parse JSON means the +/// feedback upload proceeds without the doctor report. Callers should merge the +/// returned tags without overriding explicit client-provided tags. +pub(crate) async fn doctor_feedback_report(config: &Config) -> Option { + let executable = config + .codex_self_exe + .clone() + .or_else(|| std::env::current_exe().ok())?; + + let mut command = Command::new(&executable); + command.arg("doctor").arg("--json"); + command.kill_on_drop(/*kill_on_drop*/ true); + let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(err)) => { + warn!( + executable = %executable.display(), + error = %err, + "failed to run doctor report for feedback; skipping attachment" + ); + return None; + } + Err(_) => { + warn!( + executable = %executable.display(), + "timed out running doctor report for feedback; skipping attachment" + ); + return None; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(json_start) = stdout.find('{') else { + warn!( + executable = %executable.display(), + status = %output.status, + stderr = %String::from_utf8_lossy(&output.stderr), + "doctor report for feedback did not produce JSON; skipping attachment" + ); + return None; + }; + let json = stdout[json_start..].trim(); + let report: Value = match serde_json::from_str(json) { + Ok(report) => report, + Err(err) => { + warn!( + executable = %executable.display(), + status = %output.status, + error = %err, + "doctor report for feedback was not valid JSON; skipping attachment" + ); + return None; + } + }; + + let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec()); + Some(DoctorFeedbackReport { + tags: doctor_report_tags(&report), + attachment: FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: pretty, + }, + }) +} + +fn doctor_report_tags(report: &Value) -> BTreeMap { + let mut tags = BTreeMap::new(); + if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) { + tags.insert( + "doctor_overall_status".to_string(), + truncate_tag_value(overall_status), + ); + } + + let mut ok_count = 0usize; + let mut warning_count = 0usize; + let mut fail_count = 0usize; + let mut failed_checks = Vec::new(); + let mut warning_checks = Vec::new(); + if let Some(checks) = report.get("checks") { + for check in check_values(checks) { + let status = check.get("status").and_then(Value::as_str); + let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown"); + match status { + Some("ok") => ok_count += 1, + Some("warning") => { + warning_count += 1; + warning_checks.push(id.to_string()); + } + Some("fail") => { + fail_count += 1; + failed_checks.push(id.to_string()); + } + _ => {} + } + } + } + tags.insert("doctor_ok_count".to_string(), ok_count.to_string()); + tags.insert( + "doctor_warning_count".to_string(), + warning_count.to_string(), + ); + tags.insert("doctor_fail_count".to_string(), fail_count.to_string()); + if !failed_checks.is_empty() { + tags.insert( + "doctor_failed_checks".to_string(), + truncate_tag_value(&failed_checks.join(",")), + ); + } + if !warning_checks.is_empty() { + tags.insert( + "doctor_warning_checks".to_string(), + truncate_tag_value(&warning_checks.join(",")), + ); + } + + tags +} + +/// Iterates checks from both the current keyed JSON shape and older array reports. +fn check_values(checks: &Value) -> Box + '_> { + match checks { + Value::Array(values) => Box::new(values.iter()), + Value::Object(values) => Box::new(values.values()), + _ => Box::new(std::iter::empty()), + } +} + +fn truncate_tag_value(value: &str) -> String { + if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN { + return value.to_string(); + } + let prefix = value + .chars() + .take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3)) + .collect::(); + format!("{prefix}...") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn doctor_report_tags_summarize_status_counts() { + let report = json!({ + "overallStatus": "fail", + "checks": { + "runtime.provenance": {"id": "runtime.provenance", "status": "ok"}, + "websocket.reachability": { + "id": "websocket.reachability", + "status": "warning" + }, + "auth.credentials": {"id": "auth.credentials", "status": "fail"} + } + }); + + let tags = doctor_report_tags(&report); + + let expected = BTreeMap::from([ + ("doctor_fail_count".to_string(), "1".to_string()), + ( + "doctor_failed_checks".to_string(), + "auth.credentials".to_string(), + ), + ("doctor_ok_count".to_string(), "1".to_string()), + ("doctor_overall_status".to_string(), "fail".to_string()), + ( + "doctor_warning_checks".to_string(), + "websocket.reachability".to_string(), + ), + ("doctor_warning_count".to_string(), "1".to_string()), + ]); + assert_eq!(tags, expected); + } +} diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 5b9039b57d..18f519d3c0 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -56,6 +56,7 @@ impl FeedbackRequestProcessor { extra_log_files, tags, } = params; + let mut upload_tags = tags.unwrap_or_default(); let conversation_id = match thread_id.as_deref() { Some(thread_id) => match ThreadId::from_string(thread_id) { @@ -197,14 +198,27 @@ impl FeedbackRequestProcessor { } } + let mut extra_attachments = Vec::new(); + if include_logs + && let Some(doctor_report) = + super::feedback_doctor_report::doctor_feedback_report(&self.config).await + { + extra_attachments.push(doctor_report.attachment); + for (key, value) in doctor_report.tags { + upload_tags.entry(key).or_insert(value); + } + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { + let tags = (!upload_tags.is_empty()).then_some(&upload_tags); snapshot.upload_feedback(FeedbackUploadOptions { classification: &classification, reason: reason.as_deref(), - tags: tags.as_ref(), + tags, include_logs, + extra_attachments: &extra_attachments, extra_attachment_paths: &attachment_paths, session_source: Some(session_source), logs_override: sqlite_feedback_logs, diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 120d2e497a..0831163934 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } @@ -36,10 +37,12 @@ codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-install-context = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } +codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -52,18 +55,23 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +crossterm = { workspace = true } +http = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", + "net", "process", "rt-multi-thread", "signal", + "time", ] } toml = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs new file mode 100644 index 0000000000..a3742baa72 --- /dev/null +++ b/codex-rs/cli/src/doctor.rs @@ -0,0 +1,3844 @@ +//! Implements the `codex doctor` diagnostic report. +//! +//! Doctor is intentionally read-mostly: checks inspect the current installation, +//! configuration, authentication, terminal, state paths, and bounded reachability +//! probes without attempting repair or starting long-lived services. Each check +//! returns a redacted, serializable row so the same data can back the human +//! summary and `--json` support report. +//! +//! A failing check should describe the problem and remediation, but it should not +//! mutate user state. That keeps the command safe to run before filing a support +//! issue or while diagnosing a broken local installation. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::env; +use std::ffi::OsStr; +use std::future::Future; +use std::io::IsTerminal; +use std::io::Read; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use clap::Parser; +use codex_api::ApiError; +use codex_api::ResponsesWebsocketClient; +use codex_api::is_azure_responses_provider; +use codex_arg0::Arg0DispatchPaths; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_features::FEATURES; +use codex_install_context::InstallContext; +use codex_install_context::StandalonePlatform; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; +use codex_login::CODEX_API_KEY_ENV_VAR; +use codex_login::CodexAuth; +use codex_login::OPENAI_API_KEY_ENV_VAR; +use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::default_headers; +use codex_login::load_auth_dot_json; +use codex_model_provider::create_model_provider; +use codex_protocol::protocol::AskForApproval; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; +use codex_tui::Cli as TuiCli; +use codex_utils_cli::CliConfigOverrides; +use http::HeaderMap; +use http::HeaderValue; +use serde::Serialize; +use supports_color::Stream; + +mod background; +mod output; +mod progress; +mod runtime; +mod updates; + +use background::background_server_check; +use output::HumanOutputOptions; +use output::redact_detail; +use output::render_human_report; +use progress::DoctorProgress; +use progress::doctor_progress; +use runtime::runtime_check; +use runtime::search_check; +use updates::updates_check; + +const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const WEBSOCKET_IMMEDIATE_CLOSE_GRACE: Duration = Duration::from_millis(250); +const SLOW_CHECK_PROGRESS_THRESHOLD: Duration = Duration::from_secs(2); +const SLOW_CHECK_PROGRESS_INTERVAL: Duration = Duration::from_secs(1); +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +]; +const COLOR_ENV_VARS: &[&str] = &[ + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "FORCE_COLOR", + "COLORFGBG", +]; +const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"]; +const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"]; +const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"]; +const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ + "SSH_TTY", + "SSH_CONNECTION", + "SSH_CLIENT", + "MOSH_IP", + "WSL_DISTRO_NAME", + "WSL_INTEROP", + "VSCODE_INJECTION", + "VSCODE_IPC_HOOK_CLI", + "WAYLAND_DISPLAY", + "DISPLAY", + "WT_SESSION", +]; +const TMUX_OPTION_NAMES: &[&str] = &[ + "extended-keys", + "xterm-keys", + "allow-passthrough", + "set-clipboard", + "focus-events", +]; +const NARROW_TERMINAL_COLUMNS: u16 = 80; +const NARROW_TERMINAL_ROWS: u16 = 24; + +/// Options for building a local Codex diagnostic report. +/// +/// The command always runs the full bounded diagnostic set. Human output includes +/// detailed diagnostics by default; --summary keeps the terminal output compact. +#[derive(Debug, Parser)] +pub struct DoctorCommand { + /// Emit a redacted machine-readable report. + #[arg(long, default_value_t = false)] + json: bool, + + /// Only show grouped check rows and the final count summary. + #[arg(long, default_value_t = false)] + summary: bool, + + /// Expand long lists in detailed human output. + #[arg(long, default_value_t = false)] + all: bool, + + /// Disable ANSI color in human output. + #[arg(long, default_value_t = false)] + no_color: bool, + + /// Use ASCII status labels and separators in human output. + #[arg(long, default_value_t = false)] + ascii: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +enum CheckStatus { + Ok, + Warning, + Fail, +} + +/// Machine-readable doctor output shared by human and JSON renderers. +/// +/// The schema is intentionally flat: each check carries its own category, +/// status, details, remediation, and duration so support tooling can filter or +/// redact individual rows without understanding the renderer's section layout. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: Vec, +} + +/// One diagnostic result in the doctor report. +/// +/// Summaries are safe for compact human output. Details may include local paths +/// or command output and are redacted before rendering or JSON serialization. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: Vec, + issues: Vec, + remediation: Option, + duration_ms: u64, +} + +/// Structured cause/remedy metadata for a non-ok doctor check. +/// +/// Human output uses issues to make warnings and failures self-explanatory: +/// the row headline says what is wrong, matching detail rows show measured vs. +/// expected values, and remedies are printed as explicit next actions. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +impl DoctorIssue { + fn new(severity: CheckStatus, cause: impl Into) -> Self { + Self { + severity, + cause: cause.into(), + measured: None, + expected: None, + remedy: None, + fields: Vec::new(), + } + } + + fn measured(mut self, measured: impl Into) -> Self { + self.measured = Some(measured.into()); + self + } + + fn expected(mut self, expected: impl Into) -> Self { + self.expected = Some(expected.into()); + self + } + + fn remedy(mut self, remedy: impl Into) -> Self { + self.remedy = Some(remedy.into()); + self + } + + fn field(mut self, field: impl Into) -> Self { + self.fields.push(field.into()); + self + } +} + +impl DoctorCheck { + fn new( + id: impl Into, + category: impl Into, + status: CheckStatus, + summary: impl Into, + ) -> Self { + Self { + id: id.into(), + category: category.into(), + status, + summary: summary.into(), + details: Vec::new(), + issues: Vec::new(), + remediation: None, + duration_ms: 0, + } + } + + fn detail(mut self, detail: impl Into) -> Self { + self.details.push(detail.into()); + self + } + + fn details(mut self, details: Vec) -> Self { + self.details.extend(details); + self + } + + fn remediation(mut self, remediation: impl Into) -> Self { + self.remediation = Some(remediation.into()); + self + } + + fn issue(mut self, issue: DoctorIssue) -> Self { + self.issues.push(issue); + self + } +} + +/// Builds, renders, and exits according to the current doctor report. +/// +/// This is the CLI entry point for codex doctor. It does not repair issues; +/// failures are represented in the report and cause a non-zero process exit so +/// scripts can distinguish a clean environment from one that needs attention. +pub async fn run_doctor( + command: DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result<()> { + let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await; + + if command.json { + println!( + "{}", + serde_json::to_string_pretty(&redacted_json_report(&report))? + ); + } else { + print!( + "{}", + render_human_report(&report, human_output_options(&command)) + ); + } + + if report.overall_status == CheckStatus::Fail { + std::process::exit(1); + } + + Ok(()) +} + +async fn build_report( + command: &DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> DoctorReport { + let progress = doctor_progress(command.json); + let mut checks = Vec::new(); + checks.push(run_sync_check("installation", progress.clone(), || { + installation_check(!command.summary) + })); + checks.push(run_sync_check("runtime", progress.clone(), runtime_check)); + checks.push(run_sync_check("search", progress.clone(), search_check)); + + progress.begin("config"); + let config_result = load_config(root_config_overrides, interactive, arg0_paths).await; + match &config_result { + Ok(config) => { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + let reachability_plan = provider_reachability_plan(config); + let ( + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ) = tokio::join!( + async { run_sync_check("config", progress.clone(), || config_check(config)) }, + async { run_sync_check("auth", progress.clone(), || auth_check(config)) }, + async { run_sync_check("updates", progress.clone(), || updates_check(config)) }, + async { run_sync_check("network", progress.clone(), network_check) }, + run_async_check( + "websocket", + progress.clone(), + websocket_reachability_check(config, Some(auth_manager)), + ), + run_async_check("MCP", progress.clone(), mcp_check(config)), + async { + run_sync_check("sandbox", progress.clone(), || { + sandbox_check(config, arg0_paths) + }) + }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + run_async_check("state", progress.clone(), state_check(config)), + async { + run_sync_check("app-server", progress.clone(), || { + background_server_check(config) + }) + }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ]); + } + Err(err) => { + let reachability_plan = default_reachability_plan(); + let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!( + async { + run_sync_check("config", progress.clone(), || { + DoctorCheck::new( + "config.load", + "config", + CheckStatus::Fail, + "config could not be loaded", + ) + .detail(err.to_string()) + .remediation("Fix the reported config error, then rerun codex doctor.") + }) + }, + async { run_sync_check("network", progress.clone(), network_check) }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + async { run_sync_check("state", progress.clone(), fallback_state_check) }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + network_check, + terminal_check, + state_check, + reachability_check, + ]); + } + } + + progress.settle(); + + let overall_status = overall_status(&checks); + DoctorReport { + schema_version: 1, + generated_at: generated_at(), + overall_status, + codex_version: env!("CARGO_PKG_VERSION").to_string(), + checks, + } +} + +async fn load_config( + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result { + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); + } + + let overrides = ConfigOverrides { + ephemeral: Some(true), + ..config_overrides_from_interactive(interactive, arg0_paths) + }; + + ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await + .context("failed to load Codex config") +} + +fn config_overrides_from_interactive( + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> ConfigOverrides { + let approval_policy = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(AskForApproval::Never) + } else { + interactive.approval_policy.map(Into::into) + }; + let sandbox_mode = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) + } else { + interactive.sandbox_mode.map(Into::into) + }; + ConfigOverrides { + model: interactive.model.clone(), + config_profile: interactive.config_profile.clone(), + approval_policy, + sandbox_mode, + cwd: interactive.cwd.clone(), + model_provider: interactive + .oss + .then(|| interactive.oss_provider.clone()) + .flatten(), + codex_self_exe: arg0_paths.codex_self_exe.clone(), + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), + show_raw_agent_reasoning: interactive.oss.then_some(true), + additional_writable_roots: interactive.add_dir.clone(), + ..Default::default() + } +} + +/// JSON support report emitted by `codex doctor --json`. +/// +/// The report is keyed by check id so support tooling can fetch paths like +/// `checks["terminal.metadata"]` without scanning arrays. Human rendering can +/// reorder or group rows independently, but this JSON shape should stay stable +/// across cosmetic output changes. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: BTreeMap, +} + +/// One redacted check in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + issues: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + notes: Vec, + remediation: Option, + duration_ms: u64, +} + +/// One redacted issue in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +/// JSON detail value that preserves repeated detail keys without inventing names. +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +enum JsonDetailValue { + One(String), + Many(Vec), +} + +impl JsonDetailValue { + fn push(&mut self, value: String) { + match self { + JsonDetailValue::One(previous) => { + *self = JsonDetailValue::Many(vec![std::mem::take(previous), value]); + } + JsonDetailValue::Many(values) => values.push(value), + } + } +} + +fn redacted_json_report(report: &DoctorReport) -> JsonDoctorReport { + let checks = report + .checks + .iter() + .map(|check| { + let json_check = redacted_json_check(check); + (check.id.clone(), json_check) + }) + .collect(); + JsonDoctorReport { + schema_version: report.schema_version, + generated_at: report.generated_at.clone(), + overall_status: report.overall_status, + codex_version: report.codex_version.clone(), + checks, + } +} + +fn redacted_json_check(check: &DoctorCheck) -> JsonDoctorCheck { + let (details, notes) = structured_json_details(&check.details); + JsonDoctorCheck { + id: check.id.clone(), + category: check.category.clone(), + status: check.status, + summary: check.summary.clone(), + details, + issues: check.issues.iter().map(redacted_json_issue).collect(), + notes, + remediation: check.remediation.as_deref().map(redact_detail), + duration_ms: check.duration_ms, + } +} + +fn redacted_json_issue(issue: &DoctorIssue) -> JsonDoctorIssue { + JsonDoctorIssue { + severity: issue.severity, + cause: redact_detail(&issue.cause), + measured: issue.measured.as_deref().map(redact_detail), + expected: issue.expected.as_deref().map(redact_detail), + remedy: issue.remedy.as_deref().map(redact_detail), + fields: issue + .fields + .iter() + .map(|field| redact_detail(field)) + .collect(), + } +} + +/// Converts redacted `label: value` detail strings into JSON key/value fields. +/// +/// Detail strings that do not follow the doctor detail convention are preserved +/// as notes instead of being dropped. Repeated labels become arrays so callers +/// can still retrieve the common scalar case directly while keeping all values. +fn structured_json_details(details: &[String]) -> (BTreeMap, Vec) { + let mut structured: BTreeMap = BTreeMap::new(); + let mut notes = Vec::new(); + for detail in details { + let redacted = redact_detail(detail); + let Some((key, value)) = redacted.split_once(": ") else { + notes.push(redacted); + continue; + }; + let key = key.trim(); + if key.is_empty() { + notes.push(redacted); + continue; + } + let value = value.to_string(); + match structured.get_mut(key) { + Some(existing) => existing.push(value), + None => { + structured.insert(key.to_string(), JsonDetailValue::One(value)); + } + } + } + (structured, notes) +} + +fn run_sync_check( + label: &'static str, + progress: Arc, + f: impl FnOnce() -> DoctorCheck, +) -> DoctorCheck { + progress.begin(label); + let start = Instant::now(); + let mut check = f(); + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + check +} + +async fn run_async_check( + label: &'static str, + progress: Arc, + future: Fut, +) -> DoctorCheck +where + Fut: Future, +{ + progress.begin(label); + let start = Instant::now(); + tokio::pin!(future); + let mut progress_interval = tokio::time::interval(SLOW_CHECK_PROGRESS_INTERVAL); + loop { + tokio::select! { + mut check = &mut future => { + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + return check; + } + _ = progress_interval.tick() => { + let elapsed = start.elapsed(); + if elapsed >= SLOW_CHECK_PROGRESS_THRESHOLD { + progress.heartbeat(label, elapsed); + } + } + } + } +} + +fn overall_status(checks: &[DoctorCheck]) -> CheckStatus { + if checks.iter().any(|check| check.status == CheckStatus::Fail) { + CheckStatus::Fail + } else if checks + .iter() + .any(|check| check.status == CheckStatus::Warning) + { + CheckStatus::Warning + } else { + CheckStatus::Ok + } +} + +fn generated_at() -> String { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => { + let seconds = duration.as_secs(); + format!("{seconds}s since unix epoch") + } + Err(_) => "unknown".to_string(), + } +} + +fn installation_check(show_details: bool) -> DoctorCheck { + let mut details = Vec::new(); + let current_exe = env::current_exe().ok(); + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + let inherited_managed_env = inherited_managed_env_for_cargo_binary(current_exe.as_deref()); + let install_context = doctor_install_context(current_exe.as_deref()); + details.push(format!( + "install context: {}", + describe_install_context(&install_context) + )); + if inherited_managed_env { + details.push( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + ); + } + details.push(format!( + "managed by npm: {}", + doctor_managed_by_npm(current_exe.as_deref()) + )); + details.push(format!( + "managed by bun: {}", + env::var_os("CODEX_MANAGED_BY_BUN").is_some() + )); + push_env_path_detail( + &mut details, + "managed package root", + "CODEX_MANAGED_PACKAGE_ROOT", + ); + + let path_entries = codex_path_entries(); + let mut status = CheckStatus::Ok; + let mut summary = "installation looks consistent".to_string(); + let mut remediation = None; + + if path_entries.len() > 1 { + details.push(format!("PATH codex entries: {}", path_entries.len())); + } + if show_details || path_entries.len() > 1 { + details.extend( + path_entries + .iter() + .enumerate() + .map(|(index, path)| format!("PATH codex #{}: {path}", index + 1)), + ); + } + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = + "npm install -g @openai/codex would update a different install".to_string(); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch is missing package-root provenance".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch could not inspect npm global root".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + let mut check = DoctorCheck::new("installation", "install", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext { + if inherited_managed_env_for_cargo_binary(current_exe) { + InstallContext::Other + } else { + InstallContext::current().clone() + } +} + +fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { + env::var_os("CODEX_MANAGED_BY_NPM").is_some() + && !inherited_managed_env_for_cargo_binary(current_exe) +} + +fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { + if env::var_os("CODEX_MANAGED_BY_NPM").is_none() + && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + { + return false; + } + + let Some(current_exe) = current_exe else { + return false; + }; + let components = current_exe + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>(); + components + .windows(2) + .any(|window| window[0] == "target" && matches!(window[1].as_ref(), "debug" | "release")) +} + +fn describe_install_context(context: &InstallContext) -> String { + match context { + InstallContext::Standalone { + release_dir, + resources_dir, + platform, + } => { + let platform = match platform { + StandalonePlatform::Unix => "unix", + StandalonePlatform::Windows => "windows", + }; + let resources = resources_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()); + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + InstallContext::Npm => "npm".to_string(), + InstallContext::Bun => "bun".to_string(), + InstallContext::Brew => "brew".to_string(), + InstallContext::Other => "other".to_string(), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum NpmRootCheck { + Match { + package_root: PathBuf, + }, + Mismatch { + running_package_root: PathBuf, + npm_package_root: PathBuf, + }, + MissingPackageRoot, + NpmUnavailable(String), +} + +fn npm_global_root_check() -> NpmRootCheck { + let Some(running_package_root) = env::var_os("CODEX_MANAGED_PACKAGE_ROOT").map(PathBuf::from) + else { + return NpmRootCheck::MissingPackageRoot; + }; + + let output = match run_command("npm", ["root", "-g"]) { + Ok(output) => output, + Err(err) => return NpmRootCheck::NpmUnavailable(err), + }; + let Some(npm_root) = output.lines().map(str::trim).find(|line| !line.is_empty()) else { + return NpmRootCheck::NpmUnavailable("empty output from npm root -g".to_string()); + }; + + compare_npm_package_roots(&running_package_root, &PathBuf::from(npm_root)) +} + +fn compare_npm_package_roots(running_package_root: &Path, npm_root: &Path) -> NpmRootCheck { + let npm_package_root = npm_root.join("@openai").join("codex"); + let running = normalize_path_for_compare(running_package_root); + let target = normalize_path_for_compare(&npm_package_root); + if running == target { + NpmRootCheck::Match { + package_root: npm_package_root, + } + } else { + NpmRootCheck::Mismatch { + running_package_root: running_package_root.to_path_buf(), + npm_package_root, + } + } +} + +fn normalize_path_for_compare(path: &Path) -> String { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let raw = canonical.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + raw.to_ascii_lowercase() + } else { + raw + } +} + +fn display_list>(items: &[T]) -> String { + if items.is_empty() { + "none".to_string() + } else { + items + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } +} + +fn codex_path_entries() -> Vec { + #[cfg(windows)] + let result = run_command("where", ["codex"]); + #[cfg(not(windows))] + let result = run_command("which", ["-a", "codex"]); + + result + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +fn run_command(program: &str, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(program) + .args(args) + .output() + .map_err(|err| err.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return Err(format!("exited with status {}", output.status)); + } + return Err(stderr); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn config_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!("CODEX_HOME: {}", config.codex_home.display())); + details.push(format!("cwd: {}", config.cwd.display())); + details.push(format!( + "model: {}", + config.model.as_deref().unwrap_or("") + )); + details.push(format!("model provider: {}", config.model_provider_id)); + details.push(format!("log dir: {}", config.log_dir.display())); + details.push(format!("sqlite home: {}", config.sqlite_home.display())); + details.push(format!("mcp servers: {}", config.mcp_servers.get().len())); + feature_flag_details(config, &mut details); + config_toml_details(config, &mut details); + + let status = if config.startup_warnings.is_empty() { + CheckStatus::Ok + } else { + details.extend( + config + .startup_warnings + .iter() + .map(|warning| format!("startup warning: {warning}")), + ); + CheckStatus::Warning + }; + + DoctorCheck::new("config.load", "config", status, "config loaded").details(details) +} + +fn feature_flag_details(config: &Config, details: &mut Vec) { + let features = config.features.get(); + let enabled_features = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id)) + .map(|spec| spec.key) + .collect::>(); + let overrides = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id) != spec.default_enabled) + .map(|spec| format!("{}={}", spec.key, features.enabled(spec.id))) + .collect::>(); + details.push(format!("feature flags enabled: {}", enabled_features.len())); + details.push(format!( + "enabled feature flags: {}", + display_list(&enabled_features) + )); + details.push(format!( + "feature flag overrides: {}", + display_list(&overrides) + )); + for usage in features.legacy_feature_usages() { + details.push(format!( + "legacy feature flag: {} -> {}", + usage.alias, + usage.feature.key() + )); + } +} + +fn config_toml_details(config: &Config, details: &mut Vec) { + let config_path = config.codex_home.join(codex_config::CONFIG_TOML_FILE); + details.push(format!("config.toml: {}", config_path.display())); + match std::fs::read_to_string(&config_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(_) => details.push("config.toml parse: ok".to_string()), + Err(err) => details.push(format!("config.toml parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("config.toml: missing".to_string()); + } + Err(err) => details.push(format!("config.toml read: {err}")), + } +} + +fn auth_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let auth_path = config.codex_home.join("auth.json"); + details.push(format!( + "auth storage mode: {:?}", + config.cli_auth_credentials_store_mode + )); + details.push(format!("auth file: {}", auth_path.display())); + + let env_auth_vars = [ + OPENAI_API_KEY_ENV_VAR, + CODEX_API_KEY_ENV_VAR, + CODEX_ACCESS_TOKEN_ENV_VAR, + ] + .into_iter() + .filter(|name| env_var_present(name)) + .collect::>(); + if !env_auth_vars.is_empty() { + details.push(format!( + "auth env vars present: {}", + env_auth_vars.join(", ") + )); + } + if let Some(check) = provider_specific_auth_check( + config.model_provider.requires_openai_auth, + config.model_provider.env_key.as_deref(), + config.model_provider.env_key_instructions.as_deref(), + details.clone(), + env_var_present, + ) { + return check; + } + + match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) { + Ok(Some(auth)) => { + details.push(format!("stored auth mode: {}", stored_auth_mode(&auth))); + details.push(format!("stored API key: {}", auth.openai_api_key.is_some())); + details.push(format!("stored ChatGPT tokens: {}", auth.tokens.is_some())); + details.push(format!( + "stored agent identity: {}", + auth.agent_identity.is_some() + )); + let auth_issues = stored_auth_issues(&auth, env_var_present); + details.extend( + auth_issues + .iter() + .map(|issue| format!("stored auth issue: {issue}")), + ); + let status = if !auth_issues.is_empty() && env_auth_vars.is_empty() { + CheckStatus::Fail + } else if !auth_issues.is_empty() || env_auth_vars.len() > 1 { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "auth is configured", + CheckStatus::Warning if !auth_issues.is_empty() => { + "auth is provided by environment, but stored credentials are incomplete" + } + CheckStatus::Warning => { + "auth is configured, but multiple auth env vars are present" + } + CheckStatus::Fail => "stored credentials are incomplete", + }; + let mut check = + DoctorCheck::new("auth.credentials", "auth", status, summary).details(details); + if status == CheckStatus::Fail { + check = + check.remediation("Run codex login again or provide a supported auth env var."); + } + check + } + Ok(None) if !env_auth_vars.is_empty() => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by environment", + ) + .details(details), + Ok(None) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "no Codex credentials were found", + ) + .details(details) + .remediation("Run codex login or provide an API key through a supported auth env var."), + Err(err) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "stored credentials could not be read", + ) + .detail(err.to_string()) + .remediation("Fix auth storage access or run codex login again."), + } +} + +fn provider_specific_auth_check( + requires_openai_auth: bool, + provider_env_key: Option<&str>, + provider_env_key_instructions: Option<&str>, + mut details: Vec, + env_var_present: impl Fn(&str) -> bool, +) -> Option { + details.push(format!( + "model provider requires OpenAI auth: {requires_openai_auth}" + )); + if requires_openai_auth { + return None; + } + + match provider_env_key { + Some(env_key) if env_var_present(env_key) => { + details.push(format!("provider auth env var: {env_key} (present)")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by the active model provider", + ) + .details(details), + ) + } + Some(env_key) => { + details.push(format!("provider auth env var: {env_key} (missing)")); + let remediation = provider_env_key_instructions + .map(str::to_string) + .unwrap_or_else(|| format!("Set {env_key} for the active model provider.")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "active model provider auth env var is missing", + ) + .details(details) + .remediation(remediation), + ) + } + None => Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "OpenAI auth is not required for the active model provider", + ) + .details(details), + ), + } +} + +fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::AuthMode { + if let Some(mode) = auth.auth_mode { + return mode; + } + if auth.openai_api_key.is_some() { + codex_app_server_protocol::AuthMode::ApiKey + } else { + codex_app_server_protocol::AuthMode::Chatgpt + } +} + +fn stored_auth_issues( + auth: &AuthDotJson, + env_var_present: impl Fn(&str) -> bool, +) -> Vec<&'static str> { + let mut issues = Vec::new(); + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => { + let stored_key_present = auth + .openai_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()); + let env_key_present = + env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR); + if !stored_key_present && !env_key_present { + issues.push("API key auth is missing an API key"); + } + } + codex_app_server_protocol::AuthMode::Chatgpt => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("ChatGPT auth is missing an access token"); + } + if tokens.refresh_token.trim().is_empty() { + issues.push("ChatGPT auth is missing a refresh token"); + } + } + None => issues.push("ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("external ChatGPT auth is missing an access token"); + } + if tokens.account_id.is_none() && tokens.id_token.chatgpt_account_id.is_none() { + issues.push("external ChatGPT auth is missing a ChatGPT account id"); + } + } + None => issues.push("external ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("external ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::AgentIdentity => { + if auth + .agent_identity + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("agent identity auth is missing an agent identity token"); + } + } + } + issues +} + +fn network_check() -> DoctorCheck { + let mut details = Vec::new(); + push_proxy_env_details(&mut details); + + let mut status = CheckStatus::Ok; + let mut summary = "network-related environment looks readable".to_string(); + for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] { + if let Some(raw) = env::var_os(name) { + let path = PathBuf::from(raw); + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => { + if let Err(err) = read_probe_file(&path) { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable file".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } else { + details.push(format!("{name}: readable file {}", path.display())); + } + } + Ok(_) => { + status = CheckStatus::Warning; + summary = "custom CA env var does not point at a file".to_string(); + details.push(format!("{name}: not a file {}", path.display())); + } + Err(err) => { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable path".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } + } + } + } + + DoctorCheck::new("network.env", "network", status, summary).details(details) +} + +fn push_proxy_env_details(details: &mut Vec) { + let present_proxy_vars = PROXY_ENV_VARS + .iter() + .copied() + .filter(|name| env_var_present(name)) + .collect::>(); + if present_proxy_vars.is_empty() { + details.push("proxy env vars: none".to_string()); + } else { + details.push(format!( + "proxy env vars present: {}", + present_proxy_vars.join(", ") + )); + } +} + +fn read_probe_file(path: &Path) -> std::io::Result<()> { + let mut file = std::fs::File::open(path)?; + let mut buffer = [0_u8; 1]; + let _ = file.read(&mut buffer)?; + Ok(()) +} + +async fn mcp_check(config: &Config) -> DoctorCheck { + mcp_check_from_servers(config.mcp_servers.get()).await +} + +async fn mcp_check_from_servers(servers: &HashMap) -> DoctorCheck { + if servers.is_empty() { + return DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Ok, + "no MCP servers configured", + ); + } + + let mut details = Vec::new(); + let mut transport_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut disabled = 0usize; + let mut missing_env = Vec::new(); + let mut unreachable_required_http = Vec::new(); + let mut unreachable_optional_http = Vec::new(); + + for (name, server) in servers { + let disabled_server = !server.enabled || server.disabled_reason.is_some(); + if disabled_server { + disabled += 1; + } + match &server.transport { + McpServerTransportConfig::Stdio { + command, + env, + env_vars, + cwd, + .. + } => { + *transport_counts.entry("stdio").or_default() += 1; + if disabled_server { + continue; + } + if let Some(cwd) = cwd + && !cwd.exists() + { + missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); + } + if command.trim().is_empty() { + missing_env.push(format!("{name}: stdio command is empty")); + } else if let Err(err) = + stdio_command_resolves(command, cwd.as_deref(), env.as_ref()) + { + missing_env.push(format!( + "{name}: stdio command {command:?} is not resolvable ({err})" + )); + } + if let Some(env) = env { + for key in env.keys().filter(|key| key.trim().is_empty()) { + missing_env.push(format!("{name}: empty env key {key}")); + } + } + for env_var in env_vars { + if env_var.is_remote_source() { + missing_env.push(format!( + "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + env_var.name() + )); + } else if !env_var_present(env_var.name()) { + missing_env.push(format!("{name}: env var {} is not set", env_var.name())); + } + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + env_http_headers, + .. + } => { + *transport_counts.entry("streamable_http").or_default() += 1; + if disabled_server { + continue; + } + if let Some(env_var) = bearer_token_env_var + && !env_var_present(env_var) + { + missing_env.push(format!("{name}: bearer token env var {env_var} is not set")); + } + if let Some(headers) = env_http_headers { + for env_var in headers.values() { + if !env_var_present(env_var) { + missing_env + .push(format!("{name}: header env var {env_var} is not set")); + } + } + } + if let Err(err) = mcp_http_probe_url(url).await { + let detail = format!("{name}: {url} ({err})"); + if server.required { + unreachable_required_http.push(detail); + } else { + unreachable_optional_http.push(detail); + } + } + } + } + } + + details.push(format!("configured servers: {}", servers.len())); + details.push(format!("disabled servers: {disabled}")); + for (transport, count) in transport_counts { + details.push(format!("{transport} servers: {count}")); + } + details.extend(missing_env.iter().cloned()); + details.extend( + unreachable_required_http + .iter() + .map(|detail| format!("required reachability failed: {detail}")), + ); + details.extend( + unreachable_optional_http + .iter() + .map(|detail| format!("optional reachability failed: {detail}")), + ); + + let required_missing = servers.iter().any(|(name, server)| { + server.required + && missing_env + .iter() + .any(|missing| missing.starts_with(&format!("{name}:"))) + }); + let status = if required_missing || !unreachable_required_http.is_empty() { + CheckStatus::Fail + } else if !missing_env.is_empty() || !unreachable_optional_http.is_empty() { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "MCP configuration is locally consistent", + CheckStatus::Warning => "MCP configuration has optional issues", + CheckStatus::Fail => "MCP configuration has failing required inputs or reachability", + }; + + let mut check = DoctorCheck::new("mcp.config", "mcp", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Set the missing MCP env vars or disable the affected server."); + } + check +} + +fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!( + "approval policy: {:?}", + config.permissions.approval_policy.value() + )); + let file_system_sandbox = config.permissions.file_system_sandbox_policy(); + details.push(format!("filesystem sandbox: {}", file_system_sandbox.kind)); + details.push(format!( + "network sandbox: {}", + config.permissions.network_sandbox_policy() + )); + push_path_detail( + &mut details, + "codex-linux-sandbox helper", + arg0_paths.codex_linux_sandbox_exe.as_deref(), + ); + push_path_detail( + &mut details, + "execve wrapper helper", + arg0_paths.main_execve_wrapper_exe.as_deref(), + ); + + let mut status = CheckStatus::Ok; + let mut summary = "sandbox configuration is readable".to_string(); + if let Some(helper) = arg0_paths.codex_linux_sandbox_exe.as_deref() + && !helper.exists() + { + status = CheckStatus::Warning; + summary = "Linux sandbox helper path does not exist".to_string(); + } + + DoctorCheck::new("sandbox.helpers", "sandbox", status, summary).details(details) +} + +#[derive(Clone, Debug)] +struct TerminalCheckInputs { + info: TerminalInfo, + env: BTreeMap, + present_env: BTreeSet, + no_color_flag: bool, + stdin_is_terminal: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, + stream_supports_color: bool, + terminal_size: Result<(u16, u16), String>, + tmux_details: Vec, +} + +impl TerminalCheckInputs { + fn detect(no_color_flag: bool) -> Self { + let names = terminal_env_names(); + let (env, present_env) = collect_env_snapshot(&names); + let terminal_size = crossterm::terminal::size().map_err(|err| err.to_string()); + let info = terminal_info(); + let tmux_details = if matches!(info.multiplexer, Some(Multiplexer::Tmux { .. })) { + tmux_diagnostic_details() + } else { + Vec::new() + }; + Self { + info, + env, + present_env, + no_color_flag, + stdin_is_terminal: std::io::stdin().is_terminal(), + stdout_is_terminal: std::io::stdout().is_terminal(), + stderr_is_terminal: std::io::stderr().is_terminal(), + stream_supports_color: supports_color::on(Stream::Stdout).is_some(), + terminal_size, + tmux_details, + } + } + + fn env_value(&self, name: &str) -> Option<&str> { + self.env.get(name).map(String::as_str) + } + + fn env_present(&self, name: &str) -> bool { + self.present_env.contains(name) + } +} + +fn terminal_check(no_color_flag: bool) -> DoctorCheck { + terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag)) +} + +fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck { + let info = &inputs.info; + let name = info.name; + let mut details = vec![format!("terminal: {}", terminal_name(info))]; + if let Some(term_program) = info.term_program.as_deref() { + details.push(format!("TERM_PROGRAM: {term_program}")); + } + if let Some(version) = info.version.as_deref() { + details.push(format!("terminal version: {version}")); + } + if let Some(term) = info.term.as_deref() { + details.push(format!("TERM: {term}")); + } + if let Some(multiplexer) = info.multiplexer.as_ref() { + details.push(format!("multiplexer: {}", multiplexer_name(multiplexer))); + } + details.push(format!("stdin is terminal: {}", inputs.stdin_is_terminal)); + details.push(format!("stdout is terminal: {}", inputs.stdout_is_terminal)); + details.push(format!("stderr is terminal: {}", inputs.stderr_is_terminal)); + match &inputs.terminal_size { + Ok((columns, rows)) => details.push(format!("terminal size: {columns}x{rows}")), + Err(err) => details.push(format!("terminal size: unavailable ({err})")), + } + push_terminal_env_values(&mut details, &inputs, TERMINAL_DIMENSION_ENV_VARS); + details.push(format!("color output: {}", color_output_summary(&inputs))); + push_terminal_env_values(&mut details, &inputs, COLOR_ENV_VARS); + let terminfo_warning = push_terminfo_details(&mut details, &inputs); + let locale = effective_locale(&inputs); + if let Some(locale) = locale.as_ref() { + details.push(format!("effective locale: {locale}")); + } + push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS); + details.extend(inputs.tmux_details.iter().cloned()); + + let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale); + let mut issues = Vec::new(); + if matches!(name, TerminalName::Dumb) { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERM=dumb - colors and cursor control are disabled", + ) + .measured("TERM=dumb") + .expected("TERM=xterm-256color or another real terminal type") + .remedy("set TERM to a real value, for example xterm-256color") + .field("TERM"), + ); + } + if locale_warning { + let measured = locale.unwrap_or_else(|| "unknown".to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + "locale is not UTF-8 - unicode glyphs may render incorrectly", + ) + .measured(measured) + .expected("UTF-8 locale, for example en_US.UTF-8") + .remedy("export LANG=en_US.UTF-8 or another UTF-8 locale") + .field("effective locale"), + ); + } + if terminfo_warning { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERMINFO unreadable - terminal capabilities are unknown", + ) + .expected("readable terminfo file or directory") + .remedy("check that $TERMINFO points to a readable directory") + .field("TERMINFO") + .field("TERMINFO_DIRS entry"), + ); + } + issues.extend(terminal_size_issues(&inputs)); + + let status = issues + .iter() + .map(|issue| issue.severity) + .max() + .unwrap_or(CheckStatus::Ok); + let summary = issues + .first() + .map(|issue| issue.cause.as_str()) + .unwrap_or("terminal metadata was detected"); + let mut check = DoctorCheck::new("terminal.env", "terminal", status, summary).details(details); + for issue in issues { + check = check.issue(issue); + } + check +} + +fn terminal_name(info: &TerminalInfo) -> &'static str { + match info.name { + TerminalName::AppleTerminal => "Apple Terminal", + TerminalName::Ghostty => "Ghostty", + TerminalName::Iterm2 => "iTerm2", + TerminalName::WarpTerminal => "Warp", + TerminalName::VsCode => "VS Code", + TerminalName::WezTerm => "WezTerm", + TerminalName::Kitty => "kitty", + TerminalName::Alacritty => "Alacritty", + TerminalName::Konsole => "Konsole", + TerminalName::GnomeTerminal => "GNOME Terminal", + TerminalName::Vte => "VTE", + TerminalName::WindowsTerminal => "Windows Terminal", + TerminalName::Dumb => "dumb", + TerminalName::Unknown => "unknown", + } +} + +fn multiplexer_name(multiplexer: &Multiplexer) -> String { + match multiplexer { + Multiplexer::Tmux { version } => match version { + Some(version) => format!("tmux {version}"), + None => "tmux".to_string(), + }, + Multiplexer::Zellij { version } => match version { + Some(version) => format!("zellij {version}"), + None => "zellij".to_string(), + }, + } +} + +fn terminal_env_names() -> BTreeSet<&'static str> { + let mut names = BTreeSet::from(["TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"]); + names.extend(COLOR_ENV_VARS.iter().copied()); + names.extend(TERMINAL_DIMENSION_ENV_VARS.iter().copied()); + names.extend(TERMINFO_ENV_VARS.iter().copied()); + names.extend(LOCALE_ENV_VARS.iter().copied()); + names.extend(REMOTE_TERMINAL_ENV_VARS.iter().copied()); + names +} + +fn collect_env_snapshot( + names: &BTreeSet<&'static str>, +) -> (BTreeMap, BTreeSet) { + let mut values = BTreeMap::new(); + let mut present = BTreeSet::new(); + for name in names { + if let Some(raw) = env::var_os(name) { + present.insert((*name).to_string()); + let value = raw.to_string_lossy().trim().to_string(); + if !value.is_empty() { + values.insert((*name).to_string(), value); + } + } + } + (values, present) +} + +fn push_terminal_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if let Some(value) = inputs.env_value(name) { + details.push(format!("{name}: {value}")); + } else if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn push_presence_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn color_output_summary(inputs: &TerminalCheckInputs) -> String { + if should_enable_color( + inputs.no_color_flag, + inputs.env_present("NO_COLOR"), + inputs.env_value("TERM"), + inputs.stdout_is_terminal, + inputs.stream_supports_color, + ) { + return "enabled".to_string(); + } + + let reason = if inputs.no_color_flag { + "--no-color" + } else if inputs.env_present("NO_COLOR") { + "NO_COLOR" + } else if inputs.env_value("TERM") == Some("dumb") { + "TERM=dumb" + } else if !inputs.stdout_is_terminal { + "stdout is not a terminal" + } else if !inputs.stream_supports_color { + "terminal color support not detected" + } else { + "disabled" + }; + format!("disabled ({reason})") +} + +fn push_terminfo_details(details: &mut Vec, inputs: &TerminalCheckInputs) -> bool { + let mut has_warning = false; + if let Some(raw) = inputs.env_value("TERMINFO") { + let path = PathBuf::from(raw); + let (status, warning) = terminal_path_readiness(&path); + details.push(format!("TERMINFO: {} ({status})", path.display())); + has_warning |= warning; + } + if let Some(raw) = inputs.env_value("TERMINFO_DIRS") { + for path in env::split_paths(raw).filter(|path| !path.as_os_str().is_empty()) { + let (status, warning) = terminal_path_readiness(&path); + details.push(format!( + "TERMINFO_DIRS entry: {} ({status})", + path.display() + )); + has_warning |= warning; + } + } else if inputs.env_present("TERMINFO_DIRS") { + details.push("TERMINFO_DIRS: present".to_string()); + } + has_warning +} + +fn terminal_path_readiness(path: &Path) -> (String, bool) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) { + Ok(_) => ("dir".to_string(), false), + Err(err) => (format!("dir unreadable: {err}"), true), + }, + Ok(metadata) if metadata.is_file() => match read_probe_file(path) { + Ok(_) => ("file".to_string(), false), + Err(err) => (format!("file unreadable: {err}"), true), + }, + Ok(_) => ("not a file or directory".to_string(), true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => ("missing".to_string(), true), + Err(err) => (err.to_string(), true), + } +} + +fn effective_locale(inputs: &TerminalCheckInputs) -> Option { + LOCALE_ENV_VARS + .iter() + .find_map(|name| inputs.env_value(name).map(ToString::to_string)) +} + +fn is_non_utf8_locale(locale: &str) -> bool { + let locale = locale.to_ascii_lowercase(); + !(locale.contains("utf-8") || locale.contains("utf8")) +} + +fn terminal_size_issues(inputs: &TerminalCheckInputs) -> Vec { + let mut issues = Vec::new(); + if let Ok((columns, rows)) = inputs.terminal_size { + if columns > 0 && columns < NARROW_TERMINAL_COLUMNS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("width {columns} cols - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ); + } + if rows > 0 && rows < NARROW_TERMINAL_ROWS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("height {rows} rows - content may scroll off (recommended >=24)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("terminal size"), + ); + } + } + + if let Some(columns) = inputs + .env_value("COLUMNS") + .and_then(|columns| columns.parse::().ok()) + && columns > 0 + && columns < NARROW_TERMINAL_COLUMNS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("COLUMNS={columns} - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} columns")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("COLUMNS"), + ); + } + if let Some(rows) = inputs + .env_value("LINES") + .and_then(|rows| rows.parse::().ok()) + && rows > 0 + && rows < NARROW_TERMINAL_ROWS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("LINES={rows} - content may scroll off (recommended >=24)"), + ) + .measured(format!("{rows} rows")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("LINES"), + ); + } + + issues +} + +fn tmux_diagnostic_details() -> Vec { + let mut details = Vec::new(); + push_tmux_display_detail(&mut details, "tmux client termtype", "#{client_termtype}"); + push_tmux_display_detail(&mut details, "tmux client termname", "#{client_termname}"); + for option in TMUX_OPTION_NAMES { + let value = tmux_option_value(option).unwrap_or_else(|| "unavailable".to_string()); + details.push(format!("tmux {option}: {value}")); + } + details +} + +fn push_tmux_display_detail(details: &mut Vec, label: &str, format: &str) { + if let Some(value) = tmux_display_message(format) { + details.push(format!("{label}: {value}")); + } +} + +fn tmux_option_value(option: &str) -> Option { + let output = Command::new("tmux") + .args(["show-options", "-gqv", option]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn tmux_display_message(format: &str) -> Option { + let output = Command::new("tmux") + .args(["display-message", "-p", format]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn non_empty_trimmed(value: String) -> Option { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } +} + +async fn state_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + path_readiness(&mut details, "CODEX_HOME", &config.codex_home); + path_readiness(&mut details, "log dir", &config.log_dir); + path_readiness(&mut details, "sqlite home", &config.sqlite_home); + let state_db = codex_state::state_db_path(&config.sqlite_home); + let log_db = codex_state::logs_db_path(&config.sqlite_home); + path_readiness(&mut details, "state DB", &state_db); + path_readiness(&mut details, "log DB", &log_db); + let mut integrity_failures = Vec::new(); + sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await; + sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).await; + rollout_stats_details(&mut details, &config.codex_home); + standalone_release_cache_details(&mut details); + + let status = if integrity_failures.is_empty() { + CheckStatus::Ok + } else { + CheckStatus::Fail + }; + let summary = if status == CheckStatus::Ok { + "state paths and databases are inspectable" + } else { + "state database integrity check failed" + }; + let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details); + if status == CheckStatus::Fail { + check = check + .remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database."); + } + check +} + +async fn sqlite_integrity_detail( + details: &mut Vec, + integrity_failures: &mut Vec, + label: &str, + path: &Path, +) { + if !path.is_file() { + details.push(format!("{label} integrity: skipped (missing)")); + return; + } + + match codex_state::sqlite_integrity_check(path).await { + Ok(rows) if rows.iter().all(|row| row == "ok") => { + details.push(format!("{label} integrity: ok")); + } + Ok(rows) => { + let message = format!("{label} integrity: {}", rows.join("; ")); + integrity_failures.push(message.clone()); + details.push(message); + } + Err(err) => { + let message = format!("{label} integrity: {err}"); + integrity_failures.push(message.clone()); + details.push(message); + } + } +} + +fn rollout_stats_details(details: &mut Vec, codex_home: &Path) { + let active = collect_rollout_stats(&codex_home.join("sessions")); + let archived = collect_rollout_stats(&codex_home.join("archived_sessions")); + push_rollout_stats_detail(details, "active rollout files", active); + push_rollout_stats_detail(details, "archived rollout files", archived); +} + +fn push_rollout_stats_detail(details: &mut Vec, label: &str, stats: RolloutStats) { + match stats.error { + Some(error) => details.push(format!("{label}: scan failed ({error})")), + None => details.push(format!( + "{label}: {} files, {} total bytes, {} average bytes", + stats.files, + stats.total_bytes, + stats.average_bytes() + )), + } +} + +#[derive(Default)] +struct RolloutStats { + files: u64, + total_bytes: u64, + error: Option, +} + +impl RolloutStats { + fn average_bytes(&self) -> u64 { + if self.files == 0 { + 0 + } else { + self.total_bytes / self.files + } + } +} + +fn collect_rollout_stats(root: &Path) -> RolloutStats { + let mut stats = RolloutStats::default(); + collect_rollout_stats_inner(root, &mut stats); + stats +} + +fn collect_rollout_stats_inner(path: &Path, stats: &mut RolloutStats) { + if stats.error.is_some() { + return; + } + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + let path = entry.path(); + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + if metadata.is_dir() { + collect_rollout_stats_inner(&path, stats); + } else if metadata.is_file() && is_rollout_file(&path) { + stats.files += 1; + stats.total_bytes = stats.total_bytes.saturating_add(metadata.len()); + } + } +} + +fn is_rollout_file(path: &Path) -> bool { + path.extension() == Some(OsStr::new("jsonl")) + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("rollout-")) +} + +async fn websocket_reachability_check( + config: &Config, + auth_manager: Option>, +) -> DoctorCheck { + let provider = &config.model_provider; + let mut details = vec![ + format!("model provider: {}", config.model_provider_id), + format!("provider name: {}", provider.name), + format!("wire API: {}", provider.wire_api), + format!("supports websockets: {}", provider.supports_websockets), + ]; + push_proxy_env_details(&mut details); + + if !provider.supports_websockets { + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket is not enabled for the active provider", + ) + .details(details); + } + + details.push(format!( + "connect timeout: {} ms", + provider.websocket_connect_timeout().as_millis() + )); + + let runtime_provider = create_model_provider(provider.clone(), auth_manager); + let auth = runtime_provider.auth().await; + details.push(format!( + "auth mode: {}", + auth.as_ref().map(auth_mode_name).unwrap_or("none") + )); + + let api_provider = match runtime_provider.api_provider().await { + Ok(api_provider) => api_provider, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket provider setup failed", + details, + format!("provider setup failed: {err}"), + ); + } + }; + match api_provider.websocket_url_for_path("responses") { + Ok(url) => { + details.push(format!("endpoint: {url}")); + if let Some(host) = url.host_str() + && let Some(port) = url.port_or_known_default() + { + details.extend(dns_address_family_details(host, port).await); + } + } + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket endpoint could not be built", + details, + format!("endpoint build failed: {err}"), + ); + } + } + + let api_auth = match runtime_provider.api_auth().await { + Ok(api_auth) => api_auth, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket auth could not be resolved", + details, + format!("auth resolution failed: {err}"), + ); + } + }; + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert( + OPENAI_BETA_HEADER, + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), + ); + let client = ResponsesWebsocketClient::new(api_provider, api_auth); + match tokio::time::timeout( + provider.websocket_connect_timeout(), + client.probe_handshake( + extra_headers, + default_headers(), + WEBSOCKET_IMMEDIATE_CLOSE_GRACE, + ), + ) + .await + { + Ok(Ok(probe)) => { + details.push(format!("handshake result: HTTP {}", probe.status)); + details.push(format!("reasoning header: {}", probe.reasoning_included)); + details.push(format!( + "models etag present: {}", + probe.models_etag_present + )); + details.push(format!( + "server model present: {}", + probe.server_model_present + )); + if let Some(close) = probe.immediate_close { + details.push(format!("immediate close code: {}", close.code)); + details.push(format!("immediate close reason: {}", close.reason)); + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + "Responses WebSocket closed immediately after handshake", + ) + .details(details) + .remediation( + "Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.", + ); + } + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .details(details) + } + Ok(Err(err)) => websocket_probe_warning( + "Responses WebSocket failed; HTTPS fallback may still work", + details, + websocket_error_detail(&err), + ), + Err(_) => websocket_probe_warning( + "Responses WebSocket timed out; HTTPS fallback may still work", + details, + "handshake timed out".to_string(), + ), + } +} + +fn websocket_probe_warning( + summary: &'static str, + mut details: Vec, + error_detail: String, +) -> DoctorCheck { + details.push(error_detail); + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + summary, + ) + .details(details) + .remediation("Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.") +} + +fn websocket_error_detail(err: &ApiError) -> String { + match err { + ApiError::Transport(transport) => format!("handshake transport error: {transport}"), + ApiError::Api { status, message } => { + format!("handshake API error: {status} {message}") + } + ApiError::Stream(message) => format!("handshake stream error: {message}"), + ApiError::ContextWindowExceeded + | ApiError::QuotaExceeded + | ApiError::UsageNotIncluded + | ApiError::Retryable { .. } + | ApiError::RateLimit(_) + | ApiError::InvalidRequest { .. } + | ApiError::CyberPolicy { .. } + | ApiError::ServerOverloaded => format!("handshake error: {err}"), + } +} + +fn auth_mode_name(auth: &CodexAuth) -> &'static str { + match auth.auth_mode() { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +async fn dns_address_family_details(host: &str, port: u16) -> Vec { + match tokio::net::lookup_host((host, port)).await { + Ok(addresses) => { + let addresses = addresses.collect::>(); + let ipv4_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V4(_))) + .count(); + let ipv6_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V6(_))) + .count(); + let first_family = addresses + .first() + .map(|address| match address.ip() { + IpAddr::V4(_) => "IPv4", + IpAddr::V6(_) => "IPv6", + }) + .unwrap_or("none"); + vec![format!( + "DNS: {ipv4_count} IPv4, {ipv6_count} IPv6, first {first_family}" + )] + } + Err(err) => vec![format!("DNS: lookup failed ({err})")], + } +} + +fn fallback_state_check() -> DoctorCheck { + let codex_home = find_codex_home(); + match codex_home { + Ok(path) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "CODEX_HOME was resolved without config", + ) + .detail(format!("CODEX_HOME: {}", path.display())), + Err(err) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Warning, + "CODEX_HOME could not be resolved", + ) + .detail(err.to_string()), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityPlan { + description: String, + endpoints: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityEndpoint { + label: String, + url: String, + required: bool, + route_probe_url: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProviderAuthReachabilityMode { + NotRequired, + ApiKey, + Chatgpt, +} + +impl ProviderAuthReachabilityMode { + fn description(self) -> &'static str { + match self { + Self::NotRequired => "provider auth", + Self::ApiKey => "API key auth", + Self::Chatgpt => "ChatGPT auth", + } + } +} + +fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { + let stored_auth = + load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) + .ok() + .flatten(); + let mode = provider_auth_reachability_mode_from_auth( + config.model_provider.requires_openai_auth, + env_var_present, + stored_auth.as_ref(), + ); + provider_reachability_plan_from_parts( + mode, + &config.model_provider_id, + &config.model_provider.name, + config.model_provider.base_url.as_deref(), + config.model_provider.query_params.as_ref(), + config.model_provider.is_amazon_bedrock(), + &config.chatgpt_base_url, + ) +} + +fn default_reachability_plan() -> ReachabilityPlan { + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::Chatgpt, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ) +} + +fn provider_auth_reachability_mode_from_auth( + requires_openai_auth: bool, + env_var_present: impl Fn(&str) -> bool, + stored_auth: Option<&AuthDotJson>, +) -> ProviderAuthReachabilityMode { + if !requires_openai_auth { + return ProviderAuthReachabilityMode::NotRequired; + } + if env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR) { + return ProviderAuthReachabilityMode::ApiKey; + } + if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) { + return ProviderAuthReachabilityMode::Chatgpt; + } + match stored_auth.map(stored_auth_mode_value) { + Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey, + Some( + codex_app_server_protocol::AuthMode::Chatgpt + | codex_app_server_protocol::AuthMode::ChatgptAuthTokens + | codex_app_server_protocol::AuthMode::AgentIdentity, + ) + | None => ProviderAuthReachabilityMode::Chatgpt, + } +} + +fn provider_reachability_plan_from_parts( + mode: ProviderAuthReachabilityMode, + provider_id: &str, + provider_name: &str, + provider_base_url: Option<&str>, + provider_query_params: Option<&HashMap>, + is_amazon_bedrock: bool, + chatgpt_base_url: &str, +) -> ReachabilityPlan { + let provider_route_probe_url = provider_base_url + .or_else(|| { + (mode == ProviderAuthReachabilityMode::ApiKey).then_some("https://api.openai.com/v1") + }) + .and_then(|url| { + should_probe_models_route(provider_name, url, is_amazon_bedrock) + .then(|| provider_url_for_path(url, "models", provider_query_params)) + }); + let endpoints = match mode { + ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: provider_base_url + .unwrap_or("https://api.openai.com/v1") + .to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }], + ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint { + label: "ChatGPT".to_string(), + url: chatgpt_base_url.to_string(), + required: true, + route_probe_url: None, + }], + ProviderAuthReachabilityMode::NotRequired => provider_base_url + .map(|url| { + vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: url.to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }] + }) + .unwrap_or_default(), + }; + ReachabilityPlan { + description: mode.description().to_string(), + endpoints, + } +} + +fn should_probe_models_route(provider_name: &str, base_url: &str, is_amazon_bedrock: bool) -> bool { + !is_amazon_bedrock && !is_azure_responses_provider(provider_name, Some(base_url)) +} + +fn provider_url_for_path( + base_url: &str, + path: &str, + query_params: Option<&HashMap>, +) -> String { + let base = base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let mut url = if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + }; + + if let Some(params) = query_params + && !params.is_empty() + { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str( + ¶ms + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&"), + ); + } + + url +} + +async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck { + let mut details = vec![format!("reachability mode: {}", plan.description)]; + if plan.endpoints.is_empty() { + details.push("active provider endpoint: none configured".to_string()); + return DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider has no HTTP endpoint to probe", + ) + .details(details); + } + + let mut failures = Vec::new(); + let mut optional_failures = Vec::new(); + let mut route_failures = Vec::new(); + let mut route_warnings = Vec::new(); + let mut issues = Vec::new(); + for endpoint in plan.endpoints { + match http_probe_url(&endpoint.url).await { + Ok(status) => details.push(format!( + "{} base URL: {} reachable ({status})", + endpoint.label, endpoint.url + )), + Err(err) => { + let requirement = if endpoint.required { + "required" + } else { + "optional" + }; + details.push(format!( + "{} base URL: {} {err} ({requirement})", + endpoint.label, endpoint.url + )); + if endpoint.required { + failures.push(endpoint.url); + } else { + optional_failures.push(endpoint.url); + } + continue; + } + } + + let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else { + continue; + }; + match provider_route_probe_url(route_probe_url).await { + RouteProbeOutcome::Ok(status) => { + details.push(format!( + "{} route probe: {route_probe_url} route exists ({status})", + endpoint.label, + )); + } + RouteProbeOutcome::Warning(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (warning)", + endpoint.label, + )); + route_warnings.push(route_probe_url.to_string()); + } + RouteProbeOutcome::Fail(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider base URL route returned 404 - verify the configured API prefix", + ) + .measured(format!("{route_probe_url} returned {status}")) + .expected("GET /models returns 2xx, 401, or 403") + .remedy("Set base_url to the provider API root, for example https://api.openai.com/v1") + .field("route probe"), + ); + } + RouteProbeOutcome::TransportError(err) => { + details.push(format!( + "{} route probe: {route_probe_url} {err} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider route probe could not connect - verify network access to the provider API", + ) + .measured(format!("{route_probe_url} {err}")) + .expected("GET /models completes") + .remedy("Check proxy, VPN, firewall, DNS, and custom CA configuration.") + .field("route probe"), + ); + } + } + } + + let (status, summary) = provider_reachability_outcome( + failures.len() + route_failures.len(), + optional_failures.len() + route_warnings.len(), + ); + let mut check = DoctorCheck::new( + "network.provider_reachability", + "reachability", + status, + summary, + ) + .details(details); + for issue in issues { + check = check.issue(issue); + } + if status != CheckStatus::Ok { + check = check.remediation("Check proxy, VPN, firewall, DNS, and custom CA configuration."); + } + check +} + +enum RouteProbeOutcome { + Ok(String), + Warning(String), + Fail(String), + TransportError(String), +} + +async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome { + match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await { + Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => { + RouteProbeOutcome::Ok(format!("HTTP {status}")) + } + Ok(404) => RouteProbeOutcome::Fail("HTTP 404".to_string()), + Ok(status) => RouteProbeOutcome::Warning(format!("HTTP {status}")), + Err(err) => RouteProbeOutcome::TransportError(err), + } +} + +fn provider_reachability_outcome( + required_failures: usize, + warnings: usize, +) -> (CheckStatus, &'static str) { + match (required_failures, warnings) { + (0, 0) => ( + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + (0, _) => ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ), + (_, _) => ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ), + } +} + +async fn http_probe_url(url: &str) -> Result { + http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url(url: &str) -> Result { + mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + match http_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(head_err) => match http_get_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(get_err) => Err(format!("HEAD {head_err}; GET {get_err}")), + }, + } +} + +async fn http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .head(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(format!("HTTP {}", response.status().as_u16())) +} + +async fn http_get_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + http_get_probe_status_with_timeout(url, timeout) + .await + .map(|status| format!("HTTP {status}")) +} + +async fn http_get_probe_status_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(response.status().as_u16()) +} + +fn stdio_command_resolves( + command: &str, + cwd: Option<&Path>, + server_env: Option<&HashMap>, +) -> Result<(), String> { + let command_path = Path::new(command); + if command_path.is_absolute() { + return executable_path_exists(command_path); + } + + if command_path.components().count() > 1 { + let base = cwd + .map(Path::to_path_buf) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + return executable_path_exists(&base.join(command_path)); + } + + let Some(path_env) = server_env + .and_then(|env| env.get("PATH").map(String::as_str)) + .map(std::ffi::OsString::from) + .or_else(|| env::var_os("PATH")) + else { + return Err("PATH is not set".to_string()); + }; + + for dir in env::split_paths(&path_env) { + let candidate = dir.join(command); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + #[cfg(windows)] + { + let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|extension| !extension.is_empty()) { + let candidate = dir.join(format!("{command}{extension}")); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + } + } + } + Err("not found on PATH".to_string()) +} + +fn executable_path_exists(path: &Path) -> Result<(), String> { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => executable_file_permission(path, &metadata), + Ok(_) => Err("path is not a file".to_string()), + Err(err) => Err(err.to_string()), + } +} + +#[cfg(unix)] +fn executable_file_permission(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o111 == 0 { + Err(format!("{} is not executable", path.display())) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn executable_file_permission(_path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> { + Ok(()) +} + +fn path_readiness(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let kind = if metadata.is_dir() { + "dir" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + details.push(format!("{label}: {} ({kind})", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn standalone_release_cache_details(details: &mut Vec) { + let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + return; + }; + let Some(releases_dir) = release_dir.parent() else { + return; + }; + let Ok(entries) = std::fs::read_dir(releases_dir) else { + return; + }; + let release_count = entries.filter_map(Result::ok).count(); + details.push(format!( + "standalone release cache: {release_count} entries in {}", + releases_dir.display() + )); +} + +fn push_path_detail(details: &mut Vec, label: &str, path: Option<&Path>) { + match path { + Some(path) => details.push(format!("{label}: {}", path.display())), + None => details.push(format!("{label}: none")), + } +} + +fn push_env_path_detail(details: &mut Vec, label: &str, name: &str) { + match env::var_os(name) { + Some(path) => details.push(format!("{label}: {}", PathBuf::from(path).display())), + None => details.push(format!("{label}: not set")), + } +} + +fn env_var_present(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn human_output_options(command: &DoctorCommand) -> HumanOutputOptions { + let term = env::var("TERM").ok(); + let color_enabled = should_enable_color( + command.no_color, + env::var_os("NO_COLOR").is_some(), + term.as_deref(), + std::io::stdout().is_terminal(), + supports_color::on(Stream::Stdout).is_some(), + ); + HumanOutputOptions { + show_details: !command.summary, + show_all: command.all, + ascii: command.ascii, + color_enabled, + } +} + +fn should_enable_color( + no_color_flag: bool, + no_color_env: bool, + term: Option<&str>, + stdout_is_tty: bool, + stream_supports_color: bool, +) -> bool { + !no_color_flag + && !no_color_env + && term != Some("dumb") + && stdout_is_tty + && stream_supports_color +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::io::Write; + use std::net::TcpListener; + use std::sync::Mutex; + + use clap::Parser; + use codex_protocol::config_types::SandboxMode; + use pretty_assertions::assert_eq; + + use super::*; + + #[derive(Default)] + struct RecordingProgress { + events: Mutex>, + } + + impl RecordingProgress { + fn events(&self) -> Vec { + self.events.lock().expect("events lock").clone() + } + } + + impl DoctorProgress for RecordingProgress { + fn begin(&self, label: &'static str) { + self.events + .lock() + .expect("events lock") + .push(format!("begin {label}")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.events + .lock() + .expect("events lock") + .push(format!("heartbeat {label} {}", elapsed.as_secs())); + } + + fn finish(&self, label: &'static str, status: CheckStatus) { + self.events + .lock() + .expect("events lock") + .push(format!("finish {label} {status:?}")); + } + + fn settle(&self) { + self.events + .lock() + .expect("events lock") + .push("settle".to_string()); + } + } + + fn respond_once(listener: &TcpListener, response: &[u8]) { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream.write_all(response).expect("write response"); + } + + #[test] + fn overall_status_prefers_fail() { + let checks = vec![ + DoctorCheck::new("a", "config", CheckStatus::Warning, "warning"), + DoctorCheck::new("b", "auth", CheckStatus::Fail, "fail"), + ]; + assert_eq!(overall_status(&checks), CheckStatus::Fail); + } + + #[test] + fn run_sync_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_sync_check("test", progress, || { + DoctorCheck::new("test", "test", CheckStatus::Ok, "ok") + }); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Ok".to_string()] + ); + } + + #[tokio::test] + async fn run_async_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_async_check("test", progress, async { + DoctorCheck::new("test", "test", CheckStatus::Warning, "warning") + }) + .await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Warning".to_string()] + ); + } + + #[test] + fn compare_npm_package_roots_detects_match() { + let running = PathBuf::from("/prefix/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/prefix/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Match { + package_root: npm_root.join("@openai").join("codex") + } + ); + } + + #[test] + fn compare_npm_package_roots_detects_mismatch() { + let running = PathBuf::from("/old/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/new/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Mismatch { + running_package_root: running, + npm_package_root: npm_root.join("@openai").join("codex"), + } + ); + } + + #[test] + fn config_overrides_from_interactive_preserves_global_options() { + let interactive = TuiCli::parse_from([ + "codex", + "--oss", + "--local-provider", + "ollama", + "--model", + "llama3.2", + "--cd", + "/tmp", + "--sandbox", + "danger-full-access", + "--ask-for-approval", + "never", + "--add-dir", + "/var/tmp", + ]); + let arg0_paths = Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/bin/codex")), + codex_linux_sandbox_exe: Some(PathBuf::from("/bin/codex-linux-sandbox")), + main_execve_wrapper_exe: Some(PathBuf::from("/bin/codex-execve-wrapper")), + }; + + let overrides = config_overrides_from_interactive(&interactive, &arg0_paths); + + assert_eq!(overrides.model.as_deref(), Some("llama3.2")); + assert_eq!(overrides.model_provider.as_deref(), Some("ollama")); + assert_eq!(overrides.cwd.as_deref(), Some(Path::new("/tmp"))); + assert_eq!(overrides.approval_policy, Some(AskForApproval::Never)); + assert_eq!(overrides.sandbox_mode, Some(SandboxMode::DangerFullAccess)); + assert_eq!(overrides.show_raw_agent_reasoning, Some(true)); + assert_eq!( + overrides.additional_writable_roots, + vec![PathBuf::from("/var/tmp")] + ); + assert_eq!(overrides.codex_self_exe, arg0_paths.codex_self_exe); + assert_eq!( + overrides.codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe + ); + assert_eq!( + overrides.main_execve_wrapper_exe, + arg0_paths.main_execve_wrapper_exe + ); + } + + #[test] + fn redacted_json_report_structures_and_sanitizes_details() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ) + .detail( + "optional reachability failed: remote: https://user:pass@example.com/mcp?x=abc (connect failed)", + ) + .detail("OPENAI_API_KEY: sk-live-secret") + .detail("duplicate: one") + .detail("duplicate: two") + .detail("freeform note") + .issue( + DoctorIssue::new( + CheckStatus::Warning, + "remote https://user:pass@example.com/mcp?x=abc is unreachable", + ) + .measured("https://user:pass@example.com/mcp?x=abc") + .expected("reachable MCP endpoint") + .remedy("Check https://user:pass@example.com/help?x=abc.") + .field("optional reachability failed"), + ) + .remediation("Open https://user:pass@example.com/help?x=abc."), + ], + }; + + let redacted_report = redacted_json_report(&report); + let redacted = serde_json::to_string(&redacted_report).expect("serialize report"); + let json = serde_json::to_value(redacted_report).expect("report should serialize"); + + assert!(!redacted.contains("user:pass")); + assert!(!redacted.contains("x=abc")); + assert!(!redacted.contains("sk-live-secret")); + assert!(redacted.contains("https://example.com/mcp")); + assert_eq!(json["checks"].is_object(), true); + assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config"); + assert_eq!( + json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"], + "" + ); + assert_eq!( + json["checks"]["mcp.config"]["details"]["duplicate"], + serde_json::json!(["one", "two"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["notes"], + serde_json::json!(["freeform note"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["measured"], + "https://example.com/mcp" + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["remedy"], + "Check https://example.com/help." + ); + } + + #[tokio::test] + async fn mcp_check_ignores_disabled_servers() { + let disabled_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + enabled = false + required = true + bearer_token_env_var = "CODEX_DOCTOR_DISABLED_MCP_TOKEN" + "#, + ) + .expect("should deserialize disabled MCP config"); + let servers = HashMap::from([("disabled".to_string(), disabled_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "MCP configuration is locally consistent"); + assert!(check.details.contains(&"disabled servers: 1".to_string())); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("CODEX_DOCTOR_DISABLED_MCP_TOKEN")) + ); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("reachability failed")) + ); + } + + #[tokio::test] + async fn mcp_check_warns_for_optional_http_reachability() { + let optional_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + "#, + ) + .expect("should deserialize optional MCP config"); + let servers = HashMap::from([("optional".to_string(), optional_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.summary, "MCP configuration has optional issues"); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("optional reachability failed: optional:")) + ); + } + + #[tokio::test] + async fn mcp_check_fails_required_remote_stdio_env_var() { + let command = toml::Value::String( + std::env::current_exe() + .expect("current exe") + .to_string_lossy() + .into_owned(), + ); + let required_server: McpServerConfig = toml::from_str(&format!( + r#" + command = {command} + required = true + env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}] + "#, + )) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: env_vars entry `REMOTE_ONLY_TOKEN` uses source `remote`, which requires remote MCP stdio", + ) + })); + } + + #[test] + fn provider_specific_auth_allows_non_openai_provider_without_env_key() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + /*provider_env_key*/ None, + /*provider_env_key_instructions*/ None, + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + check.summary, + "OpenAI auth is not required for the active model provider" + ); + } + + #[test] + fn provider_specific_auth_fails_when_provider_env_key_is_missing() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + Some("PROVIDER_API_KEY"), + Some("Set PROVIDER_API_KEY before running Codex."), + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "active model provider auth env var is missing" + ); + assert_eq!( + check.remediation, + Some("Set PROVIDER_API_KEY before running Codex.".to_string()) + ); + } + + #[test] + fn stored_auth_validation_rejects_missing_api_key() { + let auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["API key auth is missing an API key"] + ); + assert!(stored_auth_issues(&auth, |name| name == OPENAI_API_KEY_ENV_VAR).is_empty()); + } + + #[test] + fn stored_auth_validation_rejects_missing_chatgpt_tokens() { + let auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec![ + "ChatGPT auth is missing token data", + "ChatGPT auth is missing refresh metadata", + ] + ); + } + + #[test] + fn provider_reachability_mode_uses_api_key_auth() { + let api_key_auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |_| false, + Some(&api_key_auth), + ), + ProviderAuthReachabilityMode::ApiKey + ); + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |name| name == OPENAI_API_KEY_ENV_VAR, + /*stored_auth*/ None, + ), + ProviderAuthReachabilityMode::ApiKey + ); + } + + #[test] + fn provider_reachability_uses_active_provider_endpoint() { + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "azure", + "azure", + Some("https://example.openai.azure.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "azure API".to_string(), + url: "https://example.openai.azure.com/openai/v1".to_string(), + required: true, + route_probe_url: None, + }], + } + ); + } + + #[test] + fn provider_reachability_adds_models_route_probe_for_openai_compatible_base_urls() { + let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]); + + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "custom", + "Custom", + Some("https://example.com/openai/v1/"), + Some(&query_params), + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "custom API".to_string(), + url: "https://example.com/openai/v1/".to_string(), + required: true, + route_probe_url: Some( + "https://example.com/openai/v1/models?api-version=2026-01-01".to_string() + ), + }], + } + ); + } + + #[test] + fn provider_reachability_skips_route_probe_for_bedrock() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "amazon-bedrock", + "Amazon Bedrock", + Some("https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ true, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!(plan.endpoints[0].route_probe_url, None); + } + + #[test] + fn provider_reachability_api_key_does_not_require_chatgpt() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!( + plan.endpoints, + vec![ReachabilityEndpoint { + label: "openai API".to_string(), + url: "https://api.openai.com/v1".to_string(), + required: true, + route_probe_url: Some("https://api.openai.com/v1/models".to_string()), + }] + ); + } + + #[test] + fn provider_reachability_outcome_reports_required_failures() { + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 0, /*warnings*/ 1,), + ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ) + ); + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 1, /*warnings*/ 0,), + ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ) + ); + } + + #[tokio::test] + async fn provider_reachability_route_404_fails_bad_base_url_path() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/xxxx")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Fail); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route probe:") && detail.contains("HTTP 404")) + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("Set base_url to the provider API root, for example https://api.openai.com/v1") + ); + } + + #[tokio::test] + async fn provider_reachability_route_401_keeps_reachability_ok() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/v1")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Ok); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route exists (HTTP 401)")) + ); + } + + #[test] + fn collect_rollout_stats_counts_nested_rollout_files() { + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp + .path() + .join("sessions") + .join("2026") + .join("05") + .join("13"); + std::fs::create_dir_all(&nested).expect("create nested rollout dir"); + std::fs::write( + nested.join("rollout-2026-05-13T00-00-00-test.jsonl"), + "12345", + ) + .expect("write rollout file"); + std::fs::write(nested.join("not-a-rollout.jsonl"), "ignored").expect("write ignored jsonl"); + + let stats = collect_rollout_stats(&temp.path().join("sessions")); + + assert_eq!(stats.files, 1); + assert_eq!(stats.total_bytes, 5); + assert_eq!(stats.average_bytes(), 5); + assert_eq!(stats.error, None); + } + + #[tokio::test] + async fn http_probe_treats_http_status_as_reachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + }); + + let status = http_probe_url(&format!("http://{addr}/mcp")).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_http_probe_falls_back_to_get_when_head_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut head_stream, _) = listener.accept().expect("accept HEAD probe request"); + let head = std::thread::spawn(move || { + let mut request = [0; 1024]; + let _ = head_stream.read(&mut request); + std::thread::sleep(Duration::from_millis(50)); + }); + + let (mut get_stream, _) = listener.accept().expect("accept GET probe request"); + let mut request = [0; 1024]; + let _ = get_stream.read(&mut request); + get_stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + head.join().expect("HEAD holder should finish"); + }); + + let status = mcp_http_probe_url_with_timeout( + &format!("http://{addr}/mcp"), + Duration::from_millis(10), + ) + .await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_check_fails_required_missing_stdio_command() { + let required_server: McpServerConfig = toml::from_str( + r#" + command = "definitely-missing-codex-doctor-mcp" + required = true + "#, + ) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "MCP configuration has failing required inputs or reachability" + ); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: stdio command \"definitely-missing-codex-doctor-mcp\" is not resolvable", + ) + })); + } + + #[cfg(unix)] + #[test] + fn read_probe_file_rejects_unreadable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "cert").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o000); + std::fs::set_permissions(file.path(), permissions).expect("remove read permissions"); + + let result = read_probe_file(file.path()); + + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("restore read permissions"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn executable_path_exists_rejects_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "#!/bin/sh\n").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("set non-executable mode"); + + let result = executable_path_exists(file.path()); + + assert!(result.is_err()); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(file.path(), permissions).expect("set executable mode"); + assert_eq!(executable_path_exists(file.path()), Ok(())); + } + + #[test] + fn should_enable_color_respects_terminal_inputs() { + assert!(should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ true, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ true, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("dumb"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ false, + /*stream_supports_color*/ true, + )); + } + + fn terminal_inputs() -> TerminalCheckInputs { + TerminalCheckInputs { + info: TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: Some("xterm-256color".to_string()), + multiplexer: None, + }, + env: BTreeMap::from([("TERM".to_string(), "xterm-256color".to_string())]), + present_env: BTreeSet::from(["TERM".to_string()]), + no_color_flag: false, + stdin_is_terminal: true, + stdout_is_terminal: true, + stderr_is_terminal: true, + stream_supports_color: true, + terminal_size: Ok((120, 40)), + tmux_details: Vec::new(), + } + } + + fn set_terminal_env(inputs: &mut TerminalCheckInputs, name: &str, value: &str) { + inputs.present_env.insert(name.to_string()); + if value.is_empty() { + inputs.env.remove(name); + } else { + inputs.env.insert(name.to_string(), value.to_string()); + } + } + + #[test] + fn terminal_check_warns_for_dumb_terminal() { + let mut inputs = terminal_inputs(); + inputs.info.name = TerminalName::Dumb; + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERM=dumb - colors and cursor control are disabled" + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("set TERM to a real value, for example xterm-256color") + ); + } + + #[test] + fn terminal_check_warns_for_narrow_terminal() { + let mut inputs = terminal_inputs(); + inputs.terminal_size = Ok((79, 24)); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "width 79 cols - output may wrap (recommended >=80)" + ); + assert_eq!(check.issues[0].expected.as_deref(), Some(">= 80 columns")); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("resize the window to at least 80 columns") + ); + } + + #[test] + fn terminal_check_warns_for_declared_narrow_terminal() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "COLUMNS", "60"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "COLUMNS=60 - output may wrap (recommended >=80)" + ); + assert!(check.details.contains(&"COLUMNS: 60".to_string())); + assert_eq!(check.issues[0].fields, vec!["COLUMNS".to_string()]); + } + + #[test] + fn terminal_check_warns_for_non_utf8_locale() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "LANG", "C"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "locale is not UTF-8 - unicode glyphs may render incorrectly" + ); + assert!(check.details.contains(&"effective locale: C".to_string())); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("export LANG=en_US.UTF-8 or another UTF-8 locale") + ); + } + + #[test] + fn terminal_check_warns_for_unreadable_terminfo_path() { + let tempdir = tempfile::tempdir().expect("create tempdir"); + let missing = tempdir.path().join("missing-terminfo"); + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "TERMINFO", &missing.to_string_lossy()); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERMINFO unreadable - terminal capabilities are unknown" + ); + assert!( + check + .details + .iter() + .any(|detail| detail.starts_with("TERMINFO: ") && detail.ends_with(" (missing)")) + ); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("check that $TERMINFO points to a readable directory") + ); + } + + #[test] + fn terminal_check_reports_remote_indicators_as_present_only() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "SSH_CONNECTION", "10.0.0.1 1 10.0.0.2 22"); + + let check = terminal_check_from_inputs(inputs); + + assert!( + check + .details + .contains(&"SSH_CONNECTION: present".to_string()) + ); + assert!( + !check + .details + .iter() + .any(|detail| detail.contains("10.0.0.1")) + ); + } + + #[test] + fn terminal_check_keeps_tmux_probe_failures_non_fatal() { + let mut inputs = terminal_inputs(); + inputs.info.multiplexer = Some(Multiplexer::Tmux { version: None }); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "terminal metadata was detected"); + } + + #[test] + fn color_output_summary_reports_disabled_reasons() { + let mut inputs = terminal_inputs(); + inputs.no_color_flag = true; + assert_eq!(color_output_summary(&inputs), "disabled (--no-color)"); + + inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "NO_COLOR", ""); + assert_eq!(color_output_summary(&inputs), "disabled (NO_COLOR)"); + + inputs = terminal_inputs(); + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + assert_eq!(color_output_summary(&inputs), "disabled (TERM=dumb)"); + + inputs = terminal_inputs(); + inputs.stdout_is_terminal = false; + assert_eq!( + color_output_summary(&inputs), + "disabled (stdout is not a terminal)" + ); + } +} diff --git a/codex-rs/cli/src/doctor/background.rs b/codex-rs/cli/src/doctor/background.rs new file mode 100644 index 0000000000..0ec5132cd3 --- /dev/null +++ b/codex-rs/cli/src/doctor/background.rs @@ -0,0 +1,150 @@ +//! Reports app-server daemon state without starting or stopping the daemon. +//! +//! The background-server check is deliberately passive. It reads the daemon +//! state directory, PID files, settings file, and control socket path, then +//! attempts only a local socket connection when a socket already exists. That +//! keeps doctor safe to run while the user is debugging startup or update-loop +//! issues. + +use std::path::Path; + +use codex_core::config::Config; + +use super::CheckStatus; +use super::DoctorCheck; + +const STATE_DIR_NAME: &str = "app-server-daemon"; +const SETTINGS_FILE_NAME: &str = "settings.json"; +const PID_FILE_NAME: &str = "app-server.pid"; +const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; + +/// Builds the app-server status row from existing daemon state. +/// +/// Missing files are expected for the ephemeral/not-running case and should not +/// be treated as failures. A stale socket is a warning because it can explain +/// client connection problems without proving the daemon itself is broken. +pub(super) fn background_server_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let state_dir = config.codex_home.join(STATE_DIR_NAME); + details.push(format!("daemon state dir: {}", state_dir.display())); + push_file_detail( + &mut details, + "settings", + &state_dir.join(SETTINGS_FILE_NAME), + ); + push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME)); + push_file_detail( + &mut details, + "update-loop pid file", + &state_dir.join(UPDATE_PID_FILE_NAME), + ); + + let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) { + Ok(socket_path) => socket_path, + Err(err) => { + return DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Warning, + "background server socket path could not be resolved", + ) + .details(details) + .detail(err.to_string()); + } + }; + + details.push(format!("control socket: {}", socket_path.display())); + let status = socket_status(socket_path.as_path()); + details.push(format!("status: {}", status.detail_label())); + details.push(format!("mode: {}", server_mode(&state_dir))); + + let mut check = DoctorCheck::new( + "app_server.status", + "app-server", + status.check_status(), + status.summary(), + ) + .details(details); + if status.check_status() == CheckStatus::Warning { + check = check.remediation("Run codex app-server daemon version for more details."); + } + check +} + +fn push_file_detail(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + details.push(format!("{label}: {} (file)", path.display())); + } + Ok(_) => { + details.push(format!("{label}: {} (not a file)", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn server_mode(state_dir: &Path) -> &'static str { + if state_dir.join(SETTINGS_FILE_NAME).is_file() { + "persistent" + } else { + "ephemeral" + } +} + +#[derive(Clone, Copy)] +enum SocketStatus { + NotRunning, + Running, + #[cfg(unix)] + StaleOrUnreachable, +} + +impl SocketStatus { + fn check_status(self) -> CheckStatus { + match self { + Self::NotRunning | Self::Running => CheckStatus::Ok, + #[cfg(unix)] + Self::StaleOrUnreachable => CheckStatus::Warning, + } + } + + fn summary(self) -> &'static str { + match self { + Self::NotRunning => "background server is not running", + Self::Running => "background server is running", + #[cfg(unix)] + Self::StaleOrUnreachable => "background server socket is stale or unreachable", + } + } + + fn detail_label(self) -> &'static str { + match self { + Self::NotRunning => "not running", + Self::Running => "running", + #[cfg(unix)] + Self::StaleOrUnreachable => "stale or unreachable", + } + } +} + +fn socket_status(socket_path: &Path) -> SocketStatus { + if !socket_path.exists() { + return SocketStatus::NotRunning; + } + + #[cfg(unix)] + { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(_) => SocketStatus::Running, + Err(_) => SocketStatus::StaleOrUnreachable, + } + } + + #[cfg(not(unix))] + { + SocketStatus::Running + } +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs new file mode 100644 index 0000000000..da5fd20ce8 --- /dev/null +++ b/codex-rs/cli/src/doctor/output.rs @@ -0,0 +1,1555 @@ +//! Renders doctor reports for terminal users. +//! +//! The renderer is intentionally separate from check construction so the JSON +//! report can stay stable while the human view optimizes for scanability. It +//! groups checks by concern, colors only status/actionable tokens, and redacts +//! sensitive detail lines before showing them in detailed output. + +mod detail; + +use std::fmt::Write as _; + +use detail::HumanDetail; +use detail::detail_lines; +use owo_colors::OwoColorize; +use owo_colors::XtermColors; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorReport; + +const NAME_WIDTH: usize = 12; +const DETAIL_LABEL_WIDTH: usize = 24; +const SEPARATOR_WIDTH: usize = 61; + +const GROUPS: &[OutputGroup] = &[ + OutputGroup { + title: "Environment", + keys: &["runtime", "install", "search", "terminal", "state"], + }, + OutputGroup { + title: "Configuration", + keys: &["config", "auth", "mcp", "sandbox"], + }, + OutputGroup { + title: "Updates", + keys: &["updates"], + }, + OutputGroup { + title: "Connectivity", + keys: &["network", "websocket", "reachability"], + }, + OutputGroup { + title: "Background Server", + keys: &["app-server"], + }, +]; + +struct OutputGroup { + title: &'static str, + keys: &'static [&'static str], +} + +/// Rendering controls for human doctor output. +/// +/// These options affect presentation only. They must not change which checks +/// run or which fields are present in the underlying JSON report. +#[derive(Clone, Copy, Debug)] +pub(super) struct HumanOutputOptions { + pub(super) show_details: bool, + pub(super) show_all: bool, + pub(super) ascii: bool, + pub(super) color_enabled: bool, +} + +/// Formats a doctor report into the grouped terminal layout. +/// +/// The renderer expects checks to carry stable categories, but it owns their +/// display order. Adding a new category without adding it to GROUPS keeps JSON +/// output intact but hides that row from the human view. +pub(super) fn render_human_report(report: &DoctorReport, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + bold("Codex Doctor", options), + dim(&header_suffix(report), options) + ); + out.push('\n'); + + let notes = notes_for_report(report); + if !notes.is_empty() { + let _ = writeln!(out, "{}", bold("Notes", options)); + for note in ¬es { + write_note_row(&mut out, note, options); + } + let _ = writeln!(out, "{}", dim(&separator(options), options)); + out.push('\n'); + } + + let mut wrote_group = false; + for group in GROUPS { + let group_checks = checks_for_group(report, group); + if group_checks.is_empty() { + continue; + } + + if wrote_group { + out.push('\n'); + } + wrote_group = true; + + let _ = writeln!(out, "{}", bold(group.title, options)); + for check in group_checks { + write_check_row(&mut out, check, options); + } + } + + out.push('\n'); + let _ = writeln!(out, "{}", dim(&separator(options), options)); + let _ = writeln!(out, "{}", summary_line(report, options)); + out.push('\n'); + write_footer(&mut out, options); + out +} + +fn checks_for_group<'a>(report: &'a DoctorReport, group: &OutputGroup) -> Vec<&'a DoctorCheck> { + group + .keys + .iter() + .flat_map(|key| { + report + .checks + .iter() + .filter(move |check| check.category == *key) + }) + .collect() +} + +fn write_check_row(out: &mut String, check: &DoctorCheck, options: HumanOutputOptions) { + let description = row_description(check, options); + let status = display_status(check); + let _ = writeln!( + out, + " {}{} {}", + status_marker_slot(status, options), + format_args!("{: { + let is_issue = expected.is_some(); + let label = format!("{label: { + let spacer = " ".repeat(DETAIL_LABEL_WIDTH); + let _ = writeln!( + out, + " {} {}", + detail_label(&spacer, options), + detail_value(&value, options) + ); + } + HumanDetail::Bullet(value) => { + let _ = writeln!( + out, + " {} {}", + very_dim(if options.ascii { "-" } else { "·" }, options), + dim(&highlight_actions(&value, options), options) + ); + } + HumanDetail::Remedy(value) => { + let marker = if options.ascii { "->" } else { "→" }; + let _ = writeln!( + out, + " {} {}", + orange(marker, options), + highlight_actions(&value, options) + ); + } + } +} + +fn row_description(check: &DoctorCheck, options: HumanOutputOptions) -> String { + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) && !check.issues.is_empty() + { + return issue_summary(check); + } + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) + && let Some(remediation) = &check.remediation + { + let dash = if options.ascii { " - " } else { " — " }; + let summary = &check.summary; + return format!("{summary}{dash}{remediation}"); + } + + display_summary(check, options) +} + +fn issue_summary(check: &DoctorCheck) -> String { + match check.issues.as_slice() { + [] => check.summary.clone(), + [issue] => issue.cause.clone(), + issues => format!( + "{} issues - {}", + issues.len(), + issues + .iter() + .take(2) + .map(|issue| issue.cause.as_str()) + .collect::>() + .join("; ") + ), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayStatus { + Ok, + Update, + Note, + Warning, + Fail, + Idle, +} + +struct DoctorNote { + status: DisplayStatus, + name: String, + summary: String, +} + +fn display_status(check: &DoctorCheck) -> DisplayStatus { + if check.category == "app-server" + && check.status == CheckStatus::Ok + && check + .details + .iter() + .any(|detail| detail == "status: not running") + { + return DisplayStatus::Idle; + } + + match check.status { + CheckStatus::Ok => DisplayStatus::Ok, + CheckStatus::Warning => DisplayStatus::Warning, + CheckStatus::Fail => DisplayStatus::Fail, + } +} + +fn status_marker(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = if options.ascii { + match status { + DisplayStatus::Ok => "[ok]", + DisplayStatus::Update => "[up]", + DisplayStatus::Note | DisplayStatus::Warning => "[!!]", + DisplayStatus::Fail => "[XX]", + DisplayStatus::Idle => "[--]", + } + } else { + match status { + DisplayStatus::Ok => "✓", + DisplayStatus::Update => "↑", + DisplayStatus::Note | DisplayStatus::Warning => "⚠", + DisplayStatus::Fail => "✗", + DisplayStatus::Idle => "○", + } + }; + + match status { + DisplayStatus::Ok => green(marker, options), + DisplayStatus::Update => amber(marker, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(marker, options), + DisplayStatus::Fail => red(marker, options), + DisplayStatus::Idle => dim(marker, options), + } +} + +fn status_marker_slot(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = status_marker(status, options); + format!("{marker} ") +} + +fn style_description( + description: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let highlighted = highlight_actions(description, options); + match status { + DisplayStatus::Ok | DisplayStatus::Idle => dim(&highlighted, options), + DisplayStatus::Update => amber(&highlighted, options), + DisplayStatus::Note | DisplayStatus::Warning | DisplayStatus::Fail => highlighted, + } +} + +fn detail_marker(is_issue: bool, options: HumanOutputOptions) -> String { + if !is_issue { + return " ".to_string(); + } + orange(if options.ascii { ">" } else { "▸" }, options) +} + +fn style_note_summary(note: &DoctorNote, options: HumanOutputOptions) -> String { + if note.status == DisplayStatus::Update { + return style_update_note_summary(¬e.summary, options); + } + style_description(¬e.summary, note.status, options) +} + +fn style_update_note_summary(summary: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return summary.to_string(); + } + + let Some((version, rest)) = summary.split_once(" available") else { + return amber(summary, options); + }; + let Some((action, parenthetical)) = rest.split_once(" (") else { + return format!( + "{}{}", + amber(&format!("{version} available"), options), + amber(rest, options) + ); + }; + format!( + "{}{} {}", + amber(&format!("{version} available"), options), + amber(action, options), + dim(&format!("({parenthetical}"), options) + ) +} + +fn summary_line(report: &DoctorReport, options: HumanOutputOptions) -> String { + let notes = notes_for_report(report); + let counts = StatusCounts::from_report(report, notes.len()); + let separator = dim(if options.ascii { " | " } else { " · " }, options); + let status = overall_status_label(report.overall_status); + let mut parts = vec![count_label(counts.ok, "ok", DisplayStatus::Ok, options)]; + if counts.idle > 0 { + parts.push(count_label( + counts.idle, + "idle", + DisplayStatus::Idle, + options, + )); + } + if counts.notes > 0 { + parts.push(count_label( + counts.notes, + "notes", + DisplayStatus::Note, + options, + )); + } + parts.push(count_label( + counts.warning, + "warn", + DisplayStatus::Warning, + options, + )); + parts.push(count_label( + counts.fail, + "fail", + DisplayStatus::Fail, + options, + )); + format!( + "{} {}", + parts.join(&separator), + styled_overall_status(status, report.overall_status, options) + ) +} + +fn count_label( + count: usize, + label: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let count = dim(&count.to_string(), options); + let label = match status { + DisplayStatus::Ok => green(label, options), + DisplayStatus::Update => amber(label, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(label, options), + DisplayStatus::Fail => red(label, options), + DisplayStatus::Idle => dim(label, options), + }; + format!("{count} {label}") +} + +fn overall_status_label(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Ok => "ok", + CheckStatus::Warning => "degraded", + CheckStatus::Fail => "failed", + } +} + +fn styled_overall_status(label: &str, status: CheckStatus, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return label.to_string(); + } + + match status { + CheckStatus::Ok => label.green().bold().to_string(), + CheckStatus::Warning => label.yellow().bold().to_string(), + CheckStatus::Fail => label.red().bold().to_string(), + } +} + +fn write_footer(out: &mut String, options: HumanOutputOptions) { + if options.show_details { + let _ = writeln!( + out, + "{} {:<24} {} {}", + cyan("--summary", options), + dim("compact output", options), + cyan("--all", options), + dim("expand truncated lists", options) + ); + } else { + let _ = writeln!( + out, + "{}", + dim( + "Run codex doctor without --summary for detailed diagnostics.", + options + ) + ); + let _ = writeln!( + out, + "{} {:<28} {} {}", + cyan("--all", options), + dim("expand truncated lists", options), + cyan("--json", options), + dim("redacted report", options) + ); + return; + } + let _ = writeln!( + out, + "{} {}", + cyan("--json", options), + dim("redacted report", options) + ); +} + +fn header_suffix(report: &DoctorReport) -> String { + let version = format!("v{}", report.codex_version); + report + .checks + .iter() + .find(|check| check.category == "runtime") + .and_then(|check| detail::detail_value(check, "platform")) + .map_or(version.clone(), |platform| { + format!("{version} · {platform}") + }) +} + +fn notes_for_report(report: &DoctorReport) -> Vec { + let mut notes = Vec::new(); + if let Some(check) = find_check(report, "updates") { + update_note(check, report) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "state") { + rollout_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "sandbox") { + sandbox_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + non_ok_notes(report) + .into_iter() + .for_each(|note| notes.push(note)); + auth_reachability_note(report) + .into_iter() + .for_each(|note| notes.push(note)); + notes +} + +fn find_check<'a>(report: &'a DoctorReport, category: &str) -> Option<&'a DoctorCheck> { + report + .checks + .iter() + .find(|check| check.category == category) +} + +fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option { + let status = detail::detail_value(check, "latest version status")?; + if !status.contains("newer version is available") { + return None; + } + let latest = detail::detail_value(check, "latest version") + .or_else(|| detail::detail_value(check, "cached latest version")) + .unwrap_or_else(|| "newer version".to_string()); + let dismissed = detail::detail_value(check, "dismissed version"); + let mut parenthetical = format!("current {}", report.codex_version); + if let Some(dismissed) = dismissed + && !detail::is_falsy(&dismissed) + { + parenthetical.push_str(&format!(", dismissed {dismissed}")); + } + Some(DoctorNote { + status: DisplayStatus::Update, + name: "updates".to_string(), + summary: format!("{latest} available ({parenthetical})"), + }) +} + +fn rollout_note(check: &DoctorCheck) -> Option { + let active = detail::detail_value(check, "active rollout files")?; + let (files, bytes) = detail::rollout_files_and_bytes(&active)?; + if files < 1000 && bytes < 1024 * 1024 * 1024 { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "rollouts".to_string(), + summary: format!( + "{} active files · {} on disk", + detail::format_count(files), + detail::format_bytes(bytes) + ), + }) +} + +fn sandbox_note(check: &DoctorCheck) -> Option { + let filesystem = detail::detail_value(check, "filesystem sandbox")?; + let network = detail::detail_value(check, "network sandbox")?; + if filesystem == "restricted" && network == "restricted" { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "sandbox".to_string(), + summary: format!("filesystem {filesystem} · network {network}"), + }) +} + +fn non_ok_notes(report: &DoctorReport) -> Vec { + report + .checks + .iter() + .filter(|check| matches!(check.status, CheckStatus::Warning | CheckStatus::Fail)) + .map(|check| DoctorNote { + status: display_status(check), + name: check.category.clone(), + summary: actionable_note_summary(check), + }) + .collect() +} + +fn actionable_note_summary(check: &DoctorCheck) -> String { + if !check.issues.is_empty() { + return issue_summary(check); + } + if let Some(remediation) = &check.remediation { + return format!("{} - {remediation}", check.summary); + } + check.summary.clone() +} + +fn auth_reachability_note(report: &DoctorReport) -> Option { + let websocket = find_check(report, "websocket")?; + let reachability = find_check(report, "reachability")?; + let auth_mode = detail::detail_value(websocket, "auth mode")?; + let reachability_mode = detail::detail_value(reachability, "reachability mode")?; + let auth_mode_lower = auth_mode.to_ascii_lowercase(); + let reachability_mode_lower = reachability_mode.to_ascii_lowercase(); + if auth_mode_lower.contains("chatgpt") && reachability_mode_lower.contains("api key") { + return Some(DoctorNote { + status: DisplayStatus::Warning, + name: "auth".to_string(), + summary: "mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode".to_string(), + }); + } + None +} + +fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String { + match check.category.as_str() { + "runtime" => runtime_summary(check), + "install" if check.status == CheckStatus::Ok => "consistent".to_string(), + "search" => search_summary(check), + "terminal" => terminal_summary(check), + "state" => state_summary(check), + "config" if check.status == CheckStatus::Ok => "loaded".to_string(), + "mcp" => mcp_summary(check), + "sandbox" => sandbox_summary(check), + "network" => network_summary(check), + "websocket" => websocket_summary(check), + "app-server" => app_server_summary(check), + _ => check.summary.clone(), + } +} + +fn runtime_summary(check: &DoctorCheck) -> String { + if detail::detail_value(check, "current executable") + .is_some_and(|path| path.contains("/target/debug/")) + { + return "local debug build".to_string(); + } + detail::detail_value(check, "install method").unwrap_or_else(|| check.summary.clone()) +} + +fn search_summary(check: &DoctorCheck) -> String { + let provider = detail::detail_value(check, "search provider"); + let command = detail::detail_value(check, "search command"); + let readiness = detail::detail_value(check, "search command readiness"); + match (readiness, provider, command) { + (Some(readiness), Some(provider), Some(command)) if check.status == CheckStatus::Ok => { + format!("{readiness} ({provider}, `{command}`)") + } + _ => check.summary.clone(), + } +} + +fn terminal_summary(check: &DoctorCheck) -> String { + let mut parts = Vec::new(); + if let Some(terminal) = detail::detail_value(check, "terminal") { + let version = detail::detail_value(check, "terminal version"); + parts.push(version.map_or(terminal.clone(), |version| format!("{terminal} {version}"))); + } + if let Some(multiplexer) = detail::detail_value(check, "multiplexer") { + parts.push(multiplexer); + } + if let Some(term) = detail::detail_value(check, "TERM") { + parts.push(format!("TERM={term}")); + } + if parts.is_empty() { + check.summary.clone() + } else { + parts.join(" · ") + } +} + +fn state_summary(check: &DoctorCheck) -> String { + let state_ok = + detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); + let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); + if state_ok && log_ok { + "databases healthy".to_string() + } else { + check.summary.clone() + } +} + +fn mcp_summary(check: &DoctorCheck) -> String { + let Some(count) = detail::detail_value(check, "configured servers") else { + return check.summary.clone(); + }; + let disabled = + detail::detail_value(check, "disabled servers").unwrap_or_else(|| "0".to_string()); + let transports = check + .details + .iter() + .filter_map(|detail| detail.split_once(" servers: ")) + .filter(|(transport, _)| *transport != "configured" && *transport != "disabled") + .map(|(transport, count)| format!("{count} {transport}")) + .collect::>(); + if transports.is_empty() { + format!("{count} servers · {disabled} disabled") + } else { + format!( + "{} server ({}) · {} disabled", + count, + transports.join(", "), + disabled + ) + } +} + +fn sandbox_summary(check: &DoctorCheck) -> String { + let approval = detail::detail_value(check, "approval policy"); + let filesystem = detail::detail_value(check, "filesystem sandbox"); + let network = detail::detail_value(check, "network sandbox"); + match (approval, filesystem, network) { + (Some(approval), Some(filesystem), Some(network)) => { + format!("{filesystem} fs + {network} network · approval {approval}") + } + _ => check.summary.clone(), + } +} + +fn network_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "proxy env vars") + .map(|value| { + if value == "none" { + "no proxy env vars".to_string() + } else { + "proxy env vars present".to_string() + } + }) + .unwrap_or_else(|| check.summary.clone()) +} + +fn websocket_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "handshake result") + .or_else(|| detail::detail_value(check, "handshake status")); + let timeout = detail::detail_value(check, "connect timeout") + .map(|value| value.replace("000 ms", "s").replace(" ms", "ms")); + match (status, timeout) { + (Some(status), Some(timeout)) => format!("connected ({status}) · {timeout} timeout"), + _ => check.summary.clone(), + } +} + +fn app_server_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "status"); + let mode = detail::detail_value(check, "mode"); + match (status, mode) { + (Some(status), Some(mode)) => format!("{status} ({mode} mode)"), + _ => check.summary.clone(), + } +} + +fn separator(options: HumanOutputOptions) -> String { + if options.ascii { + "-".repeat(SEPARATOR_WIDTH) + } else { + "─".repeat(SEPARATOR_WIDTH) + } +} + +fn highlight_actions(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&highlight_flags(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&highlight_flags(part, options)); + } + in_code = !in_code; + } + out +} + +fn highlight_flags(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + if bare.starts_with("--") { + let highlighted = cyan(bare, options); + format!("{highlighted}{punctuation}{suffix}") + } else { + token.to_string() + } + }) + .collect() +} + +pub(super) fn redact_detail(detail: &str) -> String { + let lower = detail.to_ascii_lowercase(); + let label = lower.split(':').next().unwrap_or_default(); + if label.contains("env var") { + return redact_urls(detail); + } + if detail + .split_once(": ") + .is_some_and(|(_, value)| is_safe_presence_value(value)) + { + return redact_urls(detail); + } + + let secret_keys = [ + "openai_api_key", + "codex_api_key", + "codex_access_token", + "authorization", + "bearer_token", + "token", + "secret", + ]; + if secret_keys.iter().any(|key| lower.contains(key)) { + let name = detail.split(':').next().unwrap_or(detail); + format!("{name}: ") + } else { + redact_urls(detail) + } +} + +fn is_safe_presence_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "false" | "yes" | "no" | "present" | "absent" | "missing" | "not set" + ) +} + +fn redact_urls(detail: &str) -> String { + detail + .split_inclusive(char::is_whitespace) + .map(redact_url_token) + .collect() +} + +fn redact_url_token(token: &str) -> String { + let Some(scheme_end) = token.find("://") else { + return token.to_string(); + }; + let mut suffix_start = token.len(); + while suffix_start > scheme_end + 3 + && matches!( + token.as_bytes()[suffix_start - 1], + b' ' | b'\t' | b'\n' | b'\r' | b'.' | b',' | b';' | b':' | b')' | b']' + ) + { + suffix_start -= 1; + } + + let (body, suffix) = token.split_at(suffix_start); + let scheme_prefix_end = scheme_end + 3; + let rest = &body[scheme_prefix_end..]; + let authority_end = rest + .find(['/', '?', '#']) + .map(|index| scheme_prefix_end + index) + .unwrap_or(body.len()); + let authority = &body[scheme_prefix_end..authority_end]; + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let path = &body[authority_end..]; + let path = path + .find(['?', '#']) + .map(|index| &path[..index]) + .unwrap_or(path); + let path = redact_url_path(path); + format!( + "{}{}{}{}", + &body[..scheme_prefix_end], + authority, + path, + suffix + ) +} + +fn redact_url_path(path: &str) -> String { + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let Some(first_segment) = segments.next() else { + return path.to_string(); + }; + if segments.next().is_some() { + format!("/{first_segment}/") + } else { + path.to_string() + } +} + +#[derive(Default)] +struct StatusCounts { + ok: usize, + idle: usize, + notes: usize, + warning: usize, + fail: usize, +} + +impl StatusCounts { + fn from_report(report: &DoctorReport, notes: usize) -> Self { + let mut counts = Self { + notes, + ..Self::default() + }; + for check in &report.checks { + match display_status(check) { + DisplayStatus::Ok => counts.ok += 1, + DisplayStatus::Idle => counts.idle += 1, + DisplayStatus::Warning => counts.warning += 1, + DisplayStatus::Fail => counts.fail += 1, + DisplayStatus::Update | DisplayStatus::Note => {} + } + } + counts + } +} + +fn bold(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.bold().to_string() + } else { + text.to_string() + } +} + +fn dim(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.dimmed().to_string() + } else { + text.to_string() + } +} + +fn very_dim(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 238, options) +} + +fn detail_label(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 240, options) +} + +fn detail_value(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + style_detail_text(text, options) +} + +fn style_detail_text(text: &str, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&style_detail_plain_text(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&style_detail_plain_text(part, options)); + } + in_code = !in_code; + } + out +} + +fn style_detail_plain_text(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| style_detail_token(token, options)) + .collect() +} + +fn style_detail_token(token: &str, options: HumanOutputOptions) -> String { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + let styled = style_detail_bare_token(bare, options); + format!("{styled}{punctuation}{suffix}") +} + +fn style_detail_bare_token(bare: &str, options: HumanOutputOptions) -> String { + if bare.is_empty() { + return String::new(); + } + if bare == "" { + return color256(&bare.italic().to_string(), /*code*/ 244, options); + } + if bare.contains("(missing)") || detail::is_falsy(bare) { + return color256(bare, /*code*/ 240, options); + } + if let Some((label, value)) = bare.split_once(':') + && detail::is_falsy(value) + { + return format!("{label}:{}", color256(value, /*code*/ 240, options)); + } + if bare == "ok" { + return green(bare, options); + } + if bare.starts_with("--") || looks_copyable(bare) { + return cyan(bare, options); + } + if matches!(bare, "B" | "KB" | "MB" | "GB" | "TB" | "files" | "file") { + return dim(bare, options); + } + bare.to_string() +} + +fn green(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 10, options) +} + +fn amber(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 220, options) +} + +fn orange(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 214, options) +} + +fn red(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 196, options) +} + +fn cyan(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 117, options) +} + +fn color256(text: &str, code: u8, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.color(XtermColors::from(code)).to_string() + } else { + text.to_string() + } +} + +fn looks_copyable(text: &str) -> bool { + text.starts_with("http://") + || text.starts_with("https://") + || text.starts_with("wss://") + || text.starts_with("~/") + || text.starts_with('/') + || text.starts_with("./") + || text.starts_with("../") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn detailed_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn summary_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn detailed_all_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: true, + ascii: false, + color_enabled: false, + } + } + + fn detailed_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: true, + } + } + + fn sample_report() -> DoctorReport { + let checks = vec![ + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + "running local build on darwin-arm64", + ), + DoctorCheck::new( + "installation", + "install", + CheckStatus::Ok, + "installation looks consistent", + ), + DoctorCheck::new( + "runtime.search", + "search", + CheckStatus::Ok, + "search is OK (bundled)", + ), + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "narrow terminal", + ), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ), + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "token expired", + ) + .detail("OPENAI_API_KEY: present") + .remediation("Run `codex login`."), + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ), + DoctorCheck::new( + "network.env", + "network", + CheckStatus::Ok, + "network environment readable", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + ]; + DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Fail, + codex_version: "0.0.0".to_string(), + checks, + } + } + + #[test] + fn render_human_report_includes_details_by_default_without_color() { + let rendered = render_human_report(&sample_report(), detailed_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + managed by npm: no · bun: no · package root — + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + OPENAI_API_KEY present + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +--summary compact output --all expand truncated lists +--json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_summary_output_without_color() { + let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_ascii_output() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: true, + color_enabled: false, + }, + ); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + [!!] terminal narrow terminal + [XX] auth token expired - Run `codex login`. +------------------------------------------------------------- + +Environment + [ok] runtime running local build on darwin-arm64 + [ok] install consistent + [ok] search search is OK (bundled) + [!!] terminal narrow terminal + [ok] state state paths inspectable + +Configuration + [XX] auth token expired - Run `codex login`. + +Updates + [ok] updates update configuration is locally consistent + +Connectivity + [ok] network network environment readable + [ok] websocket Responses WebSocket handshake succeeded + [ok] reachability active provider endpoints are reachable over HTTP + +Background Server + [ok] app-server background server is not running + +{} +9 ok | 2 notes | 1 warn | 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "-".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_includes_redacted_details() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + }, + ); + assert!(rendered.contains(" OPENAI_API_KEY present")); + } + + #[test] + fn render_human_report_explains_terminal_warning_issue() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .detail("terminal: Ghostty") + .detail("terminal version: 1.3.1") + .detail("terminal size: 79x26") + .issue( + super::super::DoctorIssue::new( + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .expected(">= 80 columns") + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ), + ], + }; + + let rendered = render_human_report(&report, detailed_no_color_unicode_options()); + + assert!( + rendered.contains("⚠ terminal width 79 cols - output may wrap (recommended >=80)") + ); + assert!(rendered.contains("▸ terminal size 79x26 (expected >= 80 columns)")); + assert!(rendered.contains("→ resize the window to at least 80 columns")); + assert!(!rendered.contains("⚠ terminal Ghostty 1.3.1")); + } + + #[test] + fn render_human_report_promotes_notes_without_changing_statuses() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ) + .detail("latest version status: newer version is available") + .detail("latest version: 0.130.0") + .detail("dismissed version: 0.128.0"), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ) + .detail("active rollout files: 1515 files, 2702146365 total bytes, 1783594 average bytes"), + DoctorCheck::new( + "sandbox.helpers", + "sandbox", + CheckStatus::Ok, + "sandbox configuration is readable", + ) + .detail("filesystem sandbox: danger-full-access") + .detail("network sandbox: restricted") + .detail("approval policy: Never"), + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .detail("auth mode: chatgpt"), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ) + .detail("reachability mode: API key auth"), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ) + .detail("status: not running") + .detail("mode: ephemeral"), + ], + }; + + let rendered = render_human_report(&report, summary_no_color_unicode_options()); + + assert!(rendered.contains("Notes\n ↑ updates")); + assert!(rendered.contains("0.130.0 available (current 0.0.0, dismissed 0.128.0)")); + assert!(rendered.contains("⚠ rollouts")); + assert!(rendered.contains("⚠ sandbox")); + assert!(rendered.contains("⚠ mcp")); + assert!(rendered.contains( + "⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode" + )); + assert!(rendered.contains("○ app-server not running (ephemeral mode)")); + assert!(rendered.contains("5 ok · 1 idle · 5 notes · 1 warn · 0 fail degraded")); + } + + #[test] + fn render_human_report_expands_feature_flags_with_all() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Ok, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new("config.load", "config", CheckStatus::Ok, "config loaded") + .detail("model: gpt-5.5") + .detail("model provider: openai") + .detail("feature flags enabled: 3") + .detail("enabled feature flags: shell_tool, memories, goals") + .detail("feature flag overrides: memories=true"), + ], + }; + + let compact = render_human_report(&report, detailed_no_color_unicode_options()); + let expanded = render_human_report(&report, detailed_all_no_color_unicode_options()); + + assert!(!compact.contains("enabled flags")); + assert!( + compact.contains( + "feature flags 3 enabled · 1 overridden (full list with --all)" + ) + ); + assert!(expanded.contains("enabled flags shell_tool, memories, goals")); + } + + #[test] + fn detail_value_colors_inline_statuses_and_low_signal_values() { + let rendered = detail_value( + "npm: no · commit unknown · integrity ok · ~/code/codex/target/debug/codex · ", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("npm: \u{1b}[38;5;240mno")); + assert!(rendered.contains("\u{1b}[38;5;240munknown")); + assert!(rendered.contains("\u{1b}[38;5;10mok")); + assert!(rendered.contains("\u{1b}[38;5;117m~/code/codex/target/debug/codex")); + assert!(rendered.contains("\u{1b}[38;5;244m")); + } + + #[test] + fn update_note_emphasizes_available_version_and_dims_context() { + let rendered = style_update_note_summary( + "0.130.0 available (current 0.0.0, dismissed 0.128.0)", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("\u{1b}[38;5;220m0.130.0 available")); + assert!(rendered.contains("\u{1b}[2m(current 0.0.0, dismissed 0.128.0)")); + } + + #[test] + fn redact_detail_sanitizes_urls() { + let redacted = redact_detail( + "reachability failed: https://user:pass@example.com/mcp?x=abc#frag (connect failed)", + ); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp (connect failed)" + ); + } + + #[test] + fn redact_detail_sanitizes_secret_url_path_segments() { + let redacted = redact_detail("reachability failed: https://example.com/mcp/abc123xyz"); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp/" + ); + } + + #[test] + fn redact_detail_preserves_env_var_names() { + assert_eq!( + redact_detail("auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"), + "auth env vars present: OPENAI_API_KEY, CODEX_API_KEY" + ); + } + + #[test] + fn redact_detail_preserves_secret_presence_booleans() { + assert_eq!( + redact_detail("stored ChatGPT tokens: true"), + "stored ChatGPT tokens: true" + ); + assert_eq!( + redact_detail("stored ChatGPT tokens: false"), + "stored ChatGPT tokens: false" + ); + } + + #[test] + fn render_human_report_can_emit_color() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: true, + }, + ); + assert!(rendered.contains("\u{1b}[")); + } +} diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs new file mode 100644 index 0000000000..4b5f49991b --- /dev/null +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -0,0 +1,648 @@ +//! Converts raw doctor detail strings into human-oriented rows. +//! +//! Checks intentionally store details as simple redacted `label: value` strings +//! so JSON serialization and human rendering share the same source data. This +//! module owns the presentation-only transformations: collapsing noisy booleans, +//! truncating long paths for terminal output, grouping repeated values, and +//! keeping the `--all` expansion behavior out of check construction. + +use std::collections::BTreeSet; +use std::env; + +use super::DoctorCheck; +use super::HumanOutputOptions; +use super::redact_detail; + +const LIST_LIMIT: usize = 7; +const PATH_LIMIT: usize = 48; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum HumanDetail { + Row { + label: String, + value: String, + expected: Option, + }, + Continuation(String), + Bullet(String), + Remedy(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedDetail { + label: String, + value: String, +} + +pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec { + let parsed = parsed_details(check); + let details = match check.category.as_str() { + "runtime" => runtime_details(&parsed), + "install" => install_details(&parsed, options), + "config" => config_details(&parsed, options), + "state" => state_details(&parsed), + _ => generic_details(&parsed), + }; + let mut details = details + .into_iter() + .map(|detail| attach_issue_metadata(detail, check)) + .map(|detail| humanize_detail(detail, options)) + .collect::>(); + details.extend(issue_remedies(check)); + details +} + +pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option { + parsed_details(check) + .into_iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value) +} + +pub(super) fn rollout_summary(value: &str) -> Option { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, rest) = rest.split_once(" total bytes, ")?; + let (average_bytes, _) = rest.split_once(" average bytes")?; + let files = files.trim().parse::().ok()?; + let total_bytes = total_bytes.trim().parse::().ok()?; + let average_bytes = average_bytes.trim().parse::().ok()?; + Some(format!( + "{} files · {} (avg {})", + format_count(files), + format_bytes(total_bytes), + format_bytes(average_bytes) + )) +} + +pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, _) = rest.split_once(" total bytes, ")?; + Some(( + files.trim().parse::().ok()?, + total_bytes.trim().parse::().ok()?, + )) +} + +pub(super) fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + + let bytes = bytes as f64; + if bytes >= GIB { + format!("{:.2} GB", bytes / GIB) + } else if bytes >= MIB { + format!("{:.2} MB", bytes / MIB) + } else if bytes >= KIB { + format!("{:.2} KB", bytes / KIB) + } else { + format!("{} B", bytes as u64) + } +} + +pub(super) fn format_count(count: u64) -> String { + let mut digits = count.to_string(); + let mut out = String::new(); + while digits.len() > 3 { + let tail = digits.split_off(digits.len() - 3); + if out.is_empty() { + out = tail; + } else { + out = format!("{tail},{out}"); + } + } + if out.is_empty() { + digits + } else { + format!("{digits},{out}") + } +} + +fn parsed_details(check: &DoctorCheck) -> Vec { + check + .details + .iter() + .map(|detail| redact_detail(detail)) + .map(|detail| { + detail + .split_once(": ") + .map(|(label, value)| ParsedDetail { + label: label.to_string(), + value: value.to_string(), + }) + .unwrap_or_else(|| ParsedDetail { + label: String::new(), + value: detail, + }) + }) + .collect() +} + +fn runtime_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "version", "version"); + push_row_if_present(&mut out, parsed, "install method", "install method"); + push_row_if_present(&mut out, parsed, "commit", "commit"); + push_row_if_present(&mut out, parsed, "current executable", "executable"); + push_remaining( + &mut out, + parsed, + &[ + "version", + "platform", + "install method", + "commit", + "current executable", + ], + &[], + ); + out +} + +fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "install context", "context"); + if parsed.iter().any(|detail| { + detail.value == "ignored inherited package-manager launch env for cargo-built binary" + }) { + out.push(HumanDetail::Bullet( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + )); + } + + let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); + let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let package_root = value(parsed, "managed package root").unwrap_or("not set"); + out.push(HumanDetail::Row { + label: "managed by".to_string(), + value: format!( + "npm: {} · bun: {} · package root {}", + yes_no(managed_by_npm), + yes_no(managed_by_bun), + if is_falsy(package_root) { + "—".to_string() + } else { + package_root.to_string() + } + ), + expected: None, + }); + + let path_entries = numbered_values(parsed, "PATH codex #"); + if !path_entries.is_empty() { + let total = path_entries.len(); + let shown = if options.show_all { + total + } else { + total.min(3) + }; + out.push(HumanDetail::Row { + label: format!("PATH entries ({total})"), + value: path_entries[0].clone(), + expected: None, + }); + out.extend( + path_entries + .iter() + .skip(1) + .take(shown.saturating_sub(1)) + .cloned() + .map(HumanDetail::Continuation), + ); + if shown < total { + out.push(HumanDetail::Continuation( + "… (full list with --all)".to_string(), + )); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "current executable", + "install context", + "managed by npm", + "managed by bun", + "managed package root", + "PATH codex entries", + ], + &["PATH codex #"], + ); + out +} + +fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + if let Some(model) = value(parsed, "model") { + let value = value(parsed, "model provider").map_or_else( + || model.to_string(), + |provider| format!("{model} · {provider}"), + ); + out.push(HumanDetail::Row { + label: "model".to_string(), + value, + expected: None, + }); + } + push_row_if_present(&mut out, parsed, "cwd", "cwd"); + push_row_if_present(&mut out, parsed, "config.toml", "config.toml"); + push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse"); + push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read"); + push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers"); + push_feature_flags(&mut out, parsed, options); + + for detail in parsed + .iter() + .filter(|detail| detail.label == "legacy feature flag") + { + out.push(HumanDetail::Row { + label: "legacy alias".to_string(), + value: detail.value.clone(), + expected: None, + }); + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "cwd", + "model", + "model provider", + "log dir", + "sqlite home", + "mcp servers", + "feature flags enabled", + "enabled feature flags", + "feature flag overrides", + "legacy feature flag", + "config.toml", + "config.toml parse", + "config.toml read", + ], + &[], + ); + out +} + +fn state_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME"); + push_row_if_present(&mut out, parsed, "log dir", "log dir"); + push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); + push_database_row(&mut out, parsed, "state DB"); + push_database_row(&mut out, parsed, "log DB"); + + for (source, label) in [ + ("active rollout files", "active rollouts"), + ("archived rollout files", "archived rollouts"), + ] { + if let Some(value) = value(parsed, source) { + out.push(HumanDetail::Row { + label: label.to_string(), + value: rollout_summary(value).unwrap_or_else(|| value.to_string()), + expected: None, + }); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "log dir", + "sqlite home", + "state DB", + "log DB", + "state DB integrity", + "log DB integrity", + "active rollout files", + "archived rollout files", + ], + &[], + ); + out +} + +fn generic_details(parsed: &[ParsedDetail]) -> Vec { + parsed + .iter() + .map(|detail| { + if detail.label.is_empty() { + HumanDetail::Bullet(detail.value.clone()) + } else { + HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + } + } + }) + .collect() +} + +fn push_feature_flags( + out: &mut Vec, + parsed: &[ParsedDetail], + options: HumanOutputOptions, +) { + let enabled_count = value(parsed, "feature flags enabled") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none")); + let override_count = overrides.len(); + let hint = if !options.show_all && enabled_count > 0 { + " (full list with --all)" + } else { + "" + }; + out.push(HumanDetail::Row { + label: "feature flags".to_string(), + value: format!("{enabled_count} enabled · {override_count} overridden{hint}"), + expected: None, + }); + + if !overrides.is_empty() { + push_list_row(out, "overrides", &override_names(&overrides), options); + } + if options.show_all { + let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none")); + if !enabled.is_empty() { + push_list_row(out, "enabled flags", &enabled, options); + } + } +} + +fn push_list_row( + out: &mut Vec, + label: &str, + items: &[String], + options: HumanOutputOptions, +) { + let limit = if options.show_all { + items.len() + } else { + items.len().min(LIST_LIMIT) + }; + let mut value = items + .iter() + .take(limit) + .cloned() + .collect::>() + .join(", "); + if limit < items.len() { + value.push_str(", … (full list with --all)"); + } + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_database_row(out: &mut Vec, parsed: &[ParsedDetail], label: &str) { + let Some(path) = value(parsed, label) else { + return; + }; + let integrity = value(parsed, &format!("{label} integrity")); + let value = integrity.map_or_else( + || path.to_string(), + |integrity| format!("{path} · integrity {integrity}"), + ); + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_row_if_present( + out: &mut Vec, + parsed: &[ParsedDetail], + source_label: &str, + display_label: &str, +) { + if let Some(value) = value(parsed, source_label) { + out.push(HumanDetail::Row { + label: display_label.to_string(), + value: value.to_string(), + expected: None, + }); + } +} + +fn push_remaining( + out: &mut Vec, + parsed: &[ParsedDetail], + consumed_labels: &[&str], + consumed_prefixes: &[&str], +) { + for detail in parsed { + if detail.value == "ignored inherited package-manager launch env for cargo-built binary" { + continue; + } + if consumed_labels.contains(&detail.label.as_str()) + || consumed_prefixes + .iter() + .any(|prefix| detail.label.starts_with(prefix)) + { + continue; + } + if detail.label.is_empty() { + out.push(HumanDetail::Bullet(detail.value.clone())); + } else { + out.push(HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + }); + } + } +} + +fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail { + match detail { + HumanDetail::Row { + label, + value, + expected, + } => HumanDetail::Row { + label, + value: humanize_value(&value, options), + expected, + }, + HumanDetail::Continuation(value) => { + HumanDetail::Continuation(humanize_value(&value, options)) + } + HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)), + HumanDetail::Remedy(value) => HumanDetail::Remedy(value), + } +} + +fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail { + let HumanDetail::Row { + label, + value, + expected, + } = detail + else { + return detail; + }; + let expected = expected.or_else(|| issue_expected_for_label(check, &label)); + HumanDetail::Row { + label, + value, + expected, + } +} + +fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option { + check + .issues + .iter() + .find(|issue| { + issue + .fields + .iter() + .any(|field| display_label(field) == label || field == label) + }) + .and_then(|issue| issue.expected.clone()) +} + +fn issue_remedies(check: &DoctorCheck) -> Vec { + let mut seen = BTreeSet::new(); + check + .issues + .iter() + .filter_map(|issue| issue.remedy.as_ref()) + .filter(|remedy| seen.insert((*remedy).clone())) + .cloned() + .map(HumanDetail::Remedy) + .collect() +} + +fn humanize_value(value: &str, _options: HumanOutputOptions) -> String { + if looks_like_path(value) { + return shorten_path_prefix(value); + } + if let Some(timestamp) = humanize_timestamp(value) { + return timestamp; + } + value.to_string() +} + +fn humanize_timestamp(value: &str) -> Option { + if value.len() < 17 || !value.ends_with('Z') { + return None; + } + let (date, time) = value.split_once('T')?; + let hour_minute = time.get(..5)?; + Some(format!("{date} {hour_minute} UTC")) +} + +fn shorten_path_prefix(value: &str) -> String { + let (path, suffix) = value.split_once(" (").map_or_else( + || (value, String::new()), + |(path, suffix)| (path, format!(" ({suffix}")), + ); + let home_shortened = home_shortened_path(path); + let shortened = middle_truncate(&home_shortened, PATH_LIMIT); + format!("{shortened}{suffix}") +} + +fn home_shortened_path(path: &str) -> String { + let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else { + return path.to_string(); + }; + if path == home { + "~".to_string() + } else { + path.strip_prefix(&format!("{home}/")) + .map_or_else(|| path.to_string(), |tail| format!("~/{tail}")) + } +} + +fn middle_truncate(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + let head_len = max_chars / 2; + let tail_len = max_chars.saturating_sub(head_len + 1); + let head = value.chars().take(head_len).collect::(); + let tail = value + .chars() + .rev() + .take(tail_len) + .collect::() + .chars() + .rev() + .collect::(); + format!("{head}…{tail}") +} + +fn looks_like_path(value: &str) -> bool { + value.starts_with('/') + || value.starts_with("~/") + || value.starts_with("./") + || value.starts_with("../") +} + +fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec { + parsed + .iter() + .filter(|detail| detail.label.starts_with(prefix)) + .map(|detail| detail.value.clone()) + .collect() +} + +fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> { + parsed + .iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value.as_str()) +} + +fn display_label(label: &str) -> String { + match label { + "codex-linux-sandbox helper" => "linux helper", + "optional reachability failed" => "optional reachability", + "check for update on startup" => "startup update check", + other => other, + } + .to_string() +} + +fn list_items(value: &str) -> Vec { + if is_falsy(value) { + return Vec::new(); + } + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn override_names(items: &[String]) -> Vec { + items + .iter() + .map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name)) + .map(str::to_string) + .collect() +} + +fn yes_no(value: &str) -> &'static str { + if value == "true" { "yes" } else { "no" } +} + +pub(super) fn is_falsy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-" + ) +} diff --git a/codex-rs/cli/src/doctor/progress.rs b/codex-rs/cli/src/doctor/progress.rs new file mode 100644 index 0000000000..ba8d65a357 --- /dev/null +++ b/codex-rs/cli/src/doctor/progress.rs @@ -0,0 +1,139 @@ +use std::io; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::Mutex; +use std::time::Duration; + +use super::CheckStatus; + +/// Receives check lifecycle events while doctor builds the final report. +/// +/// Progress implementations must not write to stdout. The final report owns +/// stdout so JSON and redirected human reports stay clean. +pub(super) trait DoctorProgress: Send + Sync { + fn begin(&self, label: &'static str); + fn heartbeat(&self, label: &'static str, elapsed: Duration); + fn finish(&self, label: &'static str, status: CheckStatus); + fn settle(&self); +} + +/// Selects the progress implementation for the current output mode. +/// +/// JSON output is always quiet so stdout remains valid JSON. Human output uses a +/// transient stderr line only for interactive terminals, then clears it before +/// the final report is printed. +pub(super) fn doctor_progress(json: bool) -> std::sync::Arc { + if should_show_progress( + json, + std::env::var("TERM").ok().as_deref(), + io::stderr().is_terminal(), + ) { + std::sync::Arc::new(StderrProgress::default()) + } else { + std::sync::Arc::new(QuietProgress) + } +} + +fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool { + !json && stderr_is_tty && term != Some("dumb") +} + +struct QuietProgress; + +impl DoctorProgress for QuietProgress { + fn begin(&self, _label: &'static str) {} + + fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {} + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) {} +} + +#[derive(Default)] +struct StderrProgress { + state: Mutex, +} + +#[derive(Default)] +struct StderrProgressState { + wrote_line: bool, +} + +impl StderrProgress { + fn render(&self, message: String) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{message}"); + let _ = stderr.flush(); + state.wrote_line = true; + } +} + +impl DoctorProgress for StderrProgress { + fn begin(&self, label: &'static str) { + self.render(format!("Checking {label}...")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.render(format!("Still checking {label}... {}s", elapsed.as_secs())); + } + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if !state.wrote_line { + return; + } + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + state.wrote_line = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_is_quiet_for_json() { + assert!(!should_show_progress( + /*json*/ true, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_quiet_for_non_tty() { + assert!(!should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ false, + )); + } + + #[test] + fn progress_is_quiet_for_dumb_terminal() { + assert!(!should_show_progress( + /*json*/ false, + Some("dumb"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_shown_for_human_tty_output() { + assert!(should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } +} diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs new file mode 100644 index 0000000000..96e806762c --- /dev/null +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -0,0 +1,141 @@ +//! Captures how this Codex process was launched. +//! +//! Runtime diagnostics answer provenance questions that are hard to infer from +//! user reports: which binary is running, which install channel it resembles, +//! which platform it targets, and whether the search command comes from bundled +//! standalone resources or from PATH. + +use std::env; +use std::process::Command; + +use codex_install_context::InstallContext; + +use super::CheckStatus; +use super::DoctorCheck; +use super::describe_install_context; +use super::doctor_install_context; +use super::push_path_detail; + +/// Builds the process provenance row for the current Codex executable. +/// +/// This check is informational and should not fail on its own; inconsistent +/// install state is reported by the installation and update checks instead. +pub(super) fn runtime_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let os = env::consts::OS; + let arch = env::consts::ARCH; + let platform = format!("{os}-{arch}"); + let install_method = install_method_name(&install_context); + let mut details = vec![ + format!("version: {}", env!("CARGO_PKG_VERSION")), + format!("platform: {platform}"), + format!( + "install method: {}", + describe_install_context(&install_context) + ), + format!("commit: {}", build_commit()), + ]; + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + format!("running {install_method} on {platform}"), + ) + .details(details) +} + +/// Verifies that the search command selected by the install context is usable. +/// +/// Standalone installs should point at a bundled ripgrep binary, while local or +/// package-managed installs usually resolve rg from PATH. A warning here means +/// features that depend on file search may degrade even when the CLI launches. +pub(super) fn search_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let rg_command = install_context.rg_command(); + let provider = search_provider(&install_context); + let mut details = vec![ + format!("search command: {}", rg_command.display()), + format!("search provider: {provider}"), + ]; + + let status = if rg_command.components().count() > 1 { + match std::fs::metadata(&rg_command) { + Ok(metadata) if metadata.is_file() => { + details.push("search command readiness: file exists".to_string()); + CheckStatus::Ok + } + Ok(_) => { + details.push("search command readiness: path is not a file".to_string()); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + } else { + match Command::new(&rg_command).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("rg version unknown") + .to_string(); + details.push(format!("search command readiness: {version}")); + CheckStatus::Ok + } + Ok(output) => { + details.push(format!( + "search command readiness: exited with status {}", + output.status + )); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + }; + + let summary = match status { + CheckStatus::Ok => format!("search is OK ({provider})"), + CheckStatus::Warning => "search command could not be verified".to_string(), + CheckStatus::Fail => unreachable!(), + }; + let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + } + check +} + +fn install_method_name(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { .. } => "standalone", + InstallContext::Npm => "npm", + InstallContext::Bun => "bun", + InstallContext::Brew => "brew", + InstallContext::Other => "local build", + } +} + +fn search_provider(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { + resources_dir: Some(resources_dir), + .. + } if context.rg_command().starts_with(resources_dir) => "bundled", + _ => "system", + } +} + +fn build_commit() -> &'static str { + option_env!("CODEX_BUILD_COMMIT") + .or(option_env!("GIT_COMMIT")) + .unwrap_or("unknown") +} diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs new file mode 100644 index 0000000000..3d728fc07b --- /dev/null +++ b/codex-rs/cli/src/doctor/updates.rs @@ -0,0 +1,227 @@ +//! Diagnoses whether Codex update paths target the running installation. +//! +//! Update diagnostics combine cached version metadata, install-channel hints, +//! and bounded latest-version probes. For npm-managed launches, this module also +//! verifies that npm install -g would update the package root that launched the +//! current process, which catches PATH and prefix mismatches before the user runs +//! an update command. + +use std::path::Path; + +use codex_core::config::Config; +use codex_install_context::InstallContext; +use serde::Deserialize; + +use super::CheckStatus; +use super::DoctorCheck; +use super::NpmRootCheck; +use super::doctor_install_context; +use super::doctor_managed_by_npm; +use super::npm_global_root_check; +use super::run_command; + +const VERSION_FILE_NAME: &str = "version.json"; +const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; + +/// Builds the update-health row for the current installation. +/// +/// Network failures while fetching latest-version metadata degrade the row to a +/// warning instead of failing doctor outright; update freshness is useful +/// support context but should not mask more direct install/config failures. +pub(super) fn updates_check(config: &Config) -> DoctorCheck { + let current_exe = std::env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let mut details = vec![ + format!( + "check for update on startup: {}", + config.check_for_update_on_startup + ), + format!("update action: {}", update_action_label(&install_context)), + ]; + let version_file = config.codex_home.join(VERSION_FILE_NAME); + push_cached_version_details(&mut details, &version_file); + + let mut status = CheckStatus::Ok; + let mut summary = "update configuration is locally consistent".to_string(); + let mut remediation = None; + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = "update would target a different npm install".to_string(); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be proven".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be inspected".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + match fetch_latest_version(&install_context) { + Ok(latest_version) => { + details.push(format!("latest version: {latest_version}")); + if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) { + details.push("latest version status: newer version is available".to_string()); + } else { + details.push("latest version status: current version is not older".to_string()); + } + } + Err(err) => { + status = status.max(CheckStatus::Warning); + details.push(format!("latest version probe: {err}")); + } + } + + let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn push_cached_version_details(details: &mut Vec, version_file: &Path) { + details.push(format!("version cache: {}", version_file.display())); + match std::fs::read_to_string(version_file) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(info) => { + details.push(format!("cached latest version: {}", info.latest_version)); + if let Some(last_checked_at) = info.last_checked_at { + details.push(format!("last checked at: {last_checked_at}")); + } + if let Some(dismissed_version) = info.dismissed_version { + details.push(format!("dismissed version: {dismissed_version}")); + } + } + Err(err) => details.push(format!("version cache parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("version cache: missing".to_string()); + } + Err(err) => details.push(format!("version cache read: {err}")), + } +} + +fn update_action_label(context: &InstallContext) -> &'static str { + match context { + InstallContext::Npm => "npm install -g @openai/codex", + InstallContext::Bun => "bun install -g @openai/codex", + InstallContext::Brew => "brew upgrade --cask codex", + InstallContext::Standalone { .. } => "standalone installer", + InstallContext::Other => "manual or unknown", + } +} + +fn fetch_latest_version(context: &InstallContext) -> Result { + match context { + InstallContext::Brew => fetch_homebrew_cask_version(), + InstallContext::Npm + | InstallContext::Bun + | InstallContext::Standalone { .. } + | InstallContext::Other => fetch_latest_github_release_version(), + } +} + +fn fetch_latest_github_release_version() -> Result { + #[derive(Deserialize)] + struct ReleaseInfo { + tag_name: String, + } + + let info = http_get_json::(GITHUB_LATEST_RELEASE_URL)?; + info.tag_name + .strip_prefix("rust-v") + .map(str::to_string) + .ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name)) +} + +fn fetch_homebrew_cask_version() -> Result { + #[derive(Deserialize)] + struct HomebrewCaskInfo { + version: String, + } + + http_get_json::(HOMEBREW_CASK_API_URL).map(|info| info.version) +} + +fn http_get_json(url: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?; + serde_json::from_str::(&body).map_err(|err| err.to_string()) +} + +fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => Some(latest > current), + (Some(_), None) | (None, Some(_)) | (None, None) => None, + } +} + +fn parse_version(value: &str) -> Option<(u64, u64, u64)> { + let mut parts = value.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +#[derive(Deserialize)] +struct VersionInfo { + latest_version: String, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + dismissed_version: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_compares_plain_semver() { + assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true)); + assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false)); + assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None); + } + + #[test] + fn update_action_labels_install_contexts() { + assert_eq!( + update_action_label(&InstallContext::Npm), + "npm install -g @openai/codex" + ); + assert_eq!( + update_action_label(&InstallContext::Other), + "manual or unknown" + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ef54aa52cd..00c798517c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -46,6 +46,7 @@ use supports_color::Stream; mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; +mod doctor; mod marketplace_cmd; mod mcp_cmd; #[cfg(not(windows))] @@ -53,6 +54,7 @@ mod wsl_paths; use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; +use doctor::DoctorCommand; use codex_core::build_models_manager; use codex_core::config::ConfigBuilder; @@ -142,6 +144,9 @@ enum Subcommand { /// Update Codex to the latest version. Update, + /// Diagnose local Codex installation, config, auth, and runtime health. + Doctor(DoctorCommand), + /// Run commands within a Codex-provided sandbox. Sandbox(SandboxArgs), @@ -1163,6 +1168,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; run_update_command()?; } + Some(Subcommand::Doctor(doctor_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "doctor", + )?; + doctor::run_doctor( + doctor_cli, + root_config_overrides.clone(), + &interactive, + &arg0_paths, + ) + .await?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1688,7 +1707,8 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) | Some(Subcommand::Resume(_)) - | Some(Subcommand::Fork(_)) => None, + | Some(Subcommand::Fork(_)) + | Some(Subcommand::Doctor(_)) => None, Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index c16687ff28..70c52b9580 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -24,4 +24,6 @@ pub use realtime_websocket::session_update_session_json; pub use responses::ResponsesClient; pub use responses::ResponsesOptions; pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; +pub use responses_websocket::ResponsesWebsocketProbe; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c5a682b328..f0a0019817 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tracing::Instrument; use tracing::Span; use tracing::debug; @@ -321,12 +322,40 @@ impl ResponsesWebsocketConnection { } } +/// Client for connecting to the Responses WebSocket endpoint for one provider. pub struct ResponsesWebsocketClient { provider: Provider, auth: SharedAuthProvider, } +/// Close frame information captured by a handshake probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketClose { + /// WebSocket close code returned by the server. + pub code: String, + /// Human-readable close reason returned by the server. + pub reason: String, +} + +/// Result of a handshake-only Responses WebSocket probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketProbe { + /// Redacted by callers before displaying or serializing support reports. + pub url: String, + /// HTTP status returned by the successful WebSocket upgrade. + pub status: StatusCode, + /// Whether the server reported reasoning support in the upgrade response. + pub reasoning_included: bool, + /// Whether the server returned a model catalog ETag in the upgrade response. + pub models_etag_present: bool, + /// Whether the server returned a server-selected model in the upgrade response. + pub server_model_present: bool, + /// Close frame received immediately after upgrade, when one arrives quickly. + pub immediate_close: Option, +} + impl ResponsesWebsocketClient { + /// Creates a Responses WebSocket client for an already-resolved provider and auth source. pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self { Self { provider, auth } } @@ -353,7 +382,7 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); self.auth.add_auth_headers(&mut headers); - let (stream, server_reasoning_included, models_etag, server_model) = + let (stream, _status, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, @@ -364,6 +393,64 @@ impl ResponsesWebsocketClient { telemetry, )) } + + /// Opens a WebSocket connection long enough to validate the upgrade response. + /// + /// The probe uses the same URL construction, headers, authentication, TLS, + /// and custom-CA path as a real Responses WebSocket connection, but it does + /// not send a request frame. After the HTTP 101 upgrade succeeds, it waits + /// briefly for an immediate server close frame so diagnostics can distinguish + /// a usable connection from a policy rejection that closes right away. + pub async fn probe_handshake( + &self, + extra_headers: HeaderMap, + default_headers: HeaderMap, + immediate_close_timeout: Duration, + ) -> Result { + let ws_url = self + .provider + .websocket_url_for_path("responses") + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = + merge_request_headers(&self.provider.headers, extra_headers, default_headers); + self.auth.add_auth_headers(&mut headers); + + let (mut stream, status, reasoning_included, models_etag, server_model) = + connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) + .await + .ok() + .flatten() + .transpose() + .map_err(|err| { + ApiError::Stream(format!("failed to read websocket probe event: {err}")) + })? + .and_then(immediate_close_from_message); + + Ok(ResponsesWebsocketProbe { + url: ws_url.to_string(), + status, + reasoning_included, + models_etag_present: models_etag.is_some(), + server_model_present: server_model.is_some(), + immediate_close, + }) + } +} + +fn immediate_close_from_message(message: Message) -> Option { + let Message::Close(frame) = message else { + return None; + }; + frame.map(close_frame_to_probe) +} + +fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose { + ResponsesWebsocketClose { + code: frame.code.to_string(), + reason: frame.reason.to_string(), + } } fn merge_request_headers( @@ -385,7 +472,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option, Option), ApiError> { +) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -445,6 +532,7 @@ async fn connect_websocket( } Ok(( WsStream::new(stream), + response.status(), reasoning_included, models_etag, server_model, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d7017c8d3f..99470cac59 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -55,7 +55,9 @@ pub use crate::endpoint::RealtimeWebsocketWriter; pub use crate::endpoint::ResponsesClient; pub use crate::endpoint::ResponsesOptions; pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; +pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 0adf864d64..9a8caf489a 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -27,6 +27,8 @@ pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; pub use feedback_diagnostics::FeedbackDiagnostic; pub use feedback_diagnostics::FeedbackDiagnostics; +/// Filename used for the redacted `codex doctor --json` feedback attachment. +pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json"; const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; @@ -344,11 +346,37 @@ pub struct FeedbackAttachmentPath { pub attachment_filename_override: Option, } +/// In-memory attachment to include in a feedback upload. +/// +/// Use this for generated diagnostics that should not be materialized on disk, +/// such as the redacted doctor report. File-backed artifacts should use +/// `FeedbackAttachmentPath` so upload-time read failures can be logged and +/// skipped independently. +pub struct FeedbackAttachment { + /// Attachment filename shown in Sentry and in the feedback consent UI. + pub filename: String, + /// Optional MIME type for consumers that render or classify attachments. + pub content_type: Option, + /// Attachment bytes captured before the upload starts. + pub buffer: Vec, +} + +/// Inputs that control one feedback upload to Sentry. +/// +/// The caller is responsible for applying any user-consent gate before setting +/// `include_logs` or passing diagnostic attachments. This type only describes +/// what to upload once that decision has been made. pub struct FeedbackUploadOptions<'a> { pub classification: &'a str, pub reason: Option<&'a str>, pub tags: Option<&'a BTreeMap>, pub include_logs: bool, + /// Generated attachments that are already buffered and safe to upload. + /// + /// These are included after `codex-logs.log` and before path-backed rollout + /// attachments. They are only passed by the caller after any user consent + /// gate has decided logs and diagnostics should be uploaded. + pub extra_attachments: &'a [FeedbackAttachment], pub extra_attachment_paths: &'a [FeedbackAttachmentPath], pub session_source: Option, pub logs_override: Option>, @@ -444,6 +472,7 @@ impl FeedbackSnapshot { for attachment in self.feedback_attachments( options.include_logs, + options.extra_attachments, options.extra_attachment_paths, options.logs_override, ) { @@ -507,6 +536,7 @@ impl FeedbackSnapshot { fn feedback_attachments( &self, include_logs: bool, + extra_attachments: &[FeedbackAttachment], extra_attachment_paths: &[FeedbackAttachmentPath], logs_override: Option>, ) -> Vec { @@ -523,6 +553,13 @@ impl FeedbackSnapshot { }); } + attachments.extend(extra_attachments.iter().map(|attachment| Attachment { + buffer: attachment.buffer.clone(), + filename: attachment.filename.clone(), + content_type: attachment.content_type.clone(), + ty: None, + })); + if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) { attachments.push(Attachment { buffer: text.into_bytes(), @@ -704,6 +741,11 @@ mod tests { let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments( /*include_logs*/ true, + &[FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: b"{\"overallStatus\":\"ok\"}".to_vec(), + }], std::slice::from_ref(&extra_attachment_path), Some(vec![1]), ); @@ -715,6 +757,7 @@ mod tests { .collect::>(), vec![ "codex-logs.log", + DOCTOR_REPORT_ATTACHMENT_FILENAME, FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, extra_filename.as_str() ] @@ -722,17 +765,21 @@ mod tests { assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); assert_eq!( attachments_with_diagnostics[1].buffer, + b"{\"overallStatus\":\"ok\"}".to_vec() + ); + assert_eq!( + attachments_with_diagnostics[2].buffer, b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() ); - assert_eq!(attachments_with_diagnostics[2].buffer, b"rollout".to_vec()); + assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec()); assert_eq!( - OsStr::new(attachments_with_diagnostics[2].filename.as_str()), + OsStr::new(attachments_with_diagnostics[3].filename.as_str()), OsStr::new(extra_filename.as_str()) ); let attachments_without_diagnostics = CodexFeedback::new() .snapshot(/*session_id*/ None) .with_feedback_diagnostics(FeedbackDiagnostics::default()) - .feedback_attachments(/*include_logs*/ true, &[], Some(vec![1])); + .feedback_attachments(/*include_logs*/ true, &[], &[], Some(vec![1])); assert_eq!( attachments_without_diagnostics diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 3c98a77978..43c02faae2 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -55,6 +55,7 @@ pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::ThreadGoalUpdate; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; +pub use runtime::sqlite_integrity_check; pub use runtime::state_db_filename; pub use runtime::state_db_path; pub use telemetry::DbTelemetry; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index a5748da9eb..d6ed7bfcc0 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -300,11 +300,30 @@ pub fn logs_db_path(codex_home: &Path) -> PathBuf { codex_home.join(logs_db_filename()) } +/// Run SQLite's built-in integrity check against an existing database file. +pub async fn sqlite_integrity_check(path: &Path) -> anyhow::Result> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true) + .log_statements(LevelFilter::Off); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let rows = sqlx::query_scalar::<_, String>("PRAGMA integrity_check") + .fetch_all(&pool) + .await?; + pool.close().await; + Ok(rows) +} + #[cfg(test)] mod tests { use super::StateRuntime; use super::open_state_sqlite; use super::runtime_state_migrator; + use super::sqlite_integrity_check; use super::state_db_path; use super::test_support::unique_temp_dir; use crate::DB_INIT_METRIC; @@ -380,6 +399,34 @@ mod tests { .expect("open sqlite pool") } + #[tokio::test] + async fn sqlite_integrity_check_reports_ok_for_valid_db() { + let codex_home = unique_temp_dir(); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let path = state_db_path(codex_home.as_path()); + let pool = SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("open sqlite db"); + sqlx::query("CREATE TABLE sample (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create sample table"); + pool.close().await; + + let result = sqlite_integrity_check(&path) + .await + .expect("integrity check should run"); + + assert_eq!(result, vec!["ok".to_string()]); + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + #[tokio::test] async fn open_state_sqlite_tolerates_newer_applied_migrations() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 6474c189a5..c42733380d 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -64,7 +64,10 @@ pub enum Multiplexer { version: Option, }, /// zellij terminal multiplexer. - Zellij {}, + Zellij { + /// Zellij version string when ZELLIJ_VERSION is available. + version: Option, + }, } /// tmux client terminal identification captured via `tmux display-message`. @@ -207,7 +210,7 @@ impl TerminalInfo { /// Returns whether the active terminal multiplexer is Zellij. pub fn is_zellij(&self) -> bool { - matches!(self.multiplexer, Some(Multiplexer::Zellij {})) + matches!(self.multiplexer, Some(Multiplexer::Zellij { .. })) } } @@ -237,6 +240,11 @@ trait Environment { /// Returns tmux client details when available. fn tmux_client_info(&self) -> TmuxClientInfo; + + /// Returns Zellij version details when available. + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + } } /// Reads environment variables from the running process. @@ -257,6 +265,11 @@ impl Environment for ProcessEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { tmux_client_info() } + + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + .or_else(zellij_version_from_command) + } } /// Returns a sanitized terminal identifier for User-Agent strings. @@ -385,7 +398,9 @@ fn detect_multiplexer(env: &dyn Environment) -> Option { || env.has_non_empty("ZELLIJ_SESSION_NAME") || env.has_non_empty("ZELLIJ_VERSION") { - return Some(Multiplexer::Zellij {}); + return Some(Multiplexer::Zellij { + version: env.zellij_version(), + }); } None @@ -456,6 +471,32 @@ fn tmux_display_message(format: &str) -> Option { none_if_whitespace(value.trim().to_string()) } +fn zellij_version_from_command() -> Option { + // Best-effort fallback: missing or broken zellij binaries should not affect + // terminal detection. + let output = std::process::Command::new("zellij") + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_zellij_version(stdout.trim()) +} + +fn parse_zellij_version(value: &str) -> Option { + let value = none_if_whitespace(value.to_string())?; + let mut parts = value.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(command), Some(version)) if command.eq_ignore_ascii_case("zellij") => { + Some(version.to_string()) + } + _ => Some(value), + } +} + /// Sanitizes a terminal token for use in User-Agent headers. /// /// Invalid header characters are replaced with underscores. diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index ec2a3b1166..54f0a7a5a9 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; struct FakeEnvironment { vars: HashMap, tmux_client_info: TmuxClientInfo, + zellij_version: Option, } impl FakeEnvironment { @@ -12,6 +13,7 @@ impl FakeEnvironment { Self { vars: HashMap::new(), tmux_client_info: TmuxClientInfo::default(), + zellij_version: None, } } @@ -27,6 +29,11 @@ impl FakeEnvironment { }; self } + + fn with_zellij_version(mut self, version: &str) -> Self { + self.zellij_version = Some(version.to_string()); + self + } } impl Environment for FakeEnvironment { @@ -37,6 +44,12 @@ impl Environment for FakeEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { self.tmux_client_info.clone() } + + fn zellij_version(&self) -> Option { + self.zellij_version + .clone() + .or_else(|| self.var_non_empty("ZELLIJ_VERSION")) + } } fn terminal_info( @@ -129,7 +142,7 @@ fn terminal_info_reports_is_zellij() { /*term_program*/ None, /*version*/ None, /*term*/ None, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), ); assert!(zellij.is_zellij()); @@ -312,12 +325,62 @@ fn detects_zellij_multiplexer() { term_program: None, version: None, term: None, - multiplexer: Some(Multiplexer::Zellij {}), + multiplexer: Some(Multiplexer::Zellij { version: None }), }, "zellij_multiplexer" ); } +#[test] +fn detects_zellij_multiplexer_version() { + let env = FakeEnvironment::new().with_var("ZELLIJ_VERSION", "0.43.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.43.1".to_string()), + }), + ), + "zellij_multiplexer_version" + ); +} + +#[test] +fn detects_zellij_multiplexer_command_version() { + let env = FakeEnvironment::new() + .with_var("ZELLIJ", "1") + .with_zellij_version("0.44.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.44.1".to_string()), + }), + ), + "zellij_multiplexer_command_version" + ); +} + +#[test] +fn parses_zellij_version_output() { + assert_eq!( + parse_zellij_version("zellij 0.44.1"), + Some("0.44.1".to_string()) + ); + assert_eq!(parse_zellij_version("0.44.1"), Some("0.44.1".to_string())); + assert_eq!(parse_zellij_version(""), None); +} + #[test] fn detects_tmux_client_termtype() { let env = FakeEnvironment::new() diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 59e30aba7b..04bcbd0b00 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,3 +1,4 @@ +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; use codex_feedback::FeedbackDiagnostics; use crossterm::event::KeyCode; @@ -502,6 +503,11 @@ pub(crate) fn feedback_upload_consent_params( Line::from("").into(), Line::from("The following files will be sent:".dim()).into(), Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + Line::from(vec![ + " • ".into(), + DOCTOR_REPORT_ATTACHMENT_FILENAME.into(), + ]) + .into(), ]; if let Some(path) = rollout_path.as_deref() && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) @@ -538,7 +544,7 @@ pub(crate) fn feedback_upload_consent_params( super::SelectionItem { name: "Yes".to_string(), description: Some( - "Share the current Codex session logs with the team for troubleshooting." + "Share the current Codex session logs and diagnostics with the team for troubleshooting." .to_string(), ), actions: vec![yes_action], @@ -572,7 +578,18 @@ mod tests { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); view.render(area, &mut buf); + render_buffer(area, &buf) + } + fn render_renderable(renderable: &dyn Renderable, width: u16) -> String { + let height = renderable.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + renderable.render(area, &mut buf); + render_buffer(area, &buf) + } + + fn render_buffer(area: Rect, buf: &Buffer) -> String { let mut lines: Vec = (0..area.height) .map(|row| { let mut line = String::new(); @@ -670,6 +687,23 @@ mod tests { insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); } + #[test] + fn feedback_upload_consent_lists_doctor_report() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); + } + #[test] fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap new file mode 100644 index 0000000000..6cca54542d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 704 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • rollout.jsonl + • auto-review-rollout.jsonl 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 index bafa94b09d..98fabc8444 100644 --- 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 @@ -11,7 +11,7 @@ expression: rendered -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No 3. Cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index 2ba27d409f..cdeedc7b78 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -6,11 +6,12 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index b027333ad7..354e473a18 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -6,6 +6,7 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt @@ -13,8 +14,8 @@ expression: popup - Proxy environment variables are set and may affect connectivity. - HTTPS_PROXY = hello -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index 25a20c3528..cf241aee9f 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -132,7 +132,7 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { Some(Multiplexer::Tmux { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux); } - Some(Multiplexer::Zellij {}) => { + Some(Multiplexer::Zellij { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij); } None => {} @@ -389,7 +389,7 @@ mod tests { assert_eq!( pet_image_support_for_terminal(&terminal_info_for_test( TerminalName::Kitty, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), Some("kitty"), /*term*/ None, )), From 3de4d7f238497b60aab4d4cf3246897ebc9c95a9 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 13 May 2026 14:28:57 -0700 Subject: [PATCH 24/52] clean up instructions (#22543) rm behavioral steering in tool docs for code mode. --- codex-rs/code-mode/src/description.rs | 9 +++------ codex-rs/core/src/tools/spec_plan_tests.rs | 5 ++--- codex-rs/core/src/tools/spec_tests.rs | 5 +++++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 4c5eb6fbdc..0c2813e51a 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -7,10 +7,8 @@ use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. -To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#; +To find one, filter `ALL_TOOLS` by `name` and `description`."#; const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. - All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. @@ -256,9 +254,6 @@ pub fn build_exec_tool_description( deferred_tools_available: bool, ) -> String { let mut sections = Vec::new(); - if code_mode_only { - sections.push(CODE_MODE_ONLY_PREFACE.to_string()); - } sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string()); if deferred_tools_available { sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string()); @@ -875,6 +870,7 @@ mod tests { "### `foo` bar" )); + assert!(!description.contains("do not attempt to use any other tools directly")); } #[test] @@ -1097,5 +1093,6 @@ bar" assert!(description.contains("Some nested MCP/app tools may be omitted")); assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`")); + assert!(!description.contains("do not print the full `ALL_TOOLS` array")); } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 044c445046..e40377e1d2 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2265,9 +2265,8 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() { assert!(!description.contains("Enabled nested tools:")); assert!(!description.contains("Nested tool reference:")); - assert!(description.starts_with( - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" - )); + assert!(description.starts_with("Run JavaScript code to orchestrate/compose tool calls")); + assert!(!description.contains("do not attempt to use any other tools directly")); assert!(description.contains("### `update_plan`")); assert!(description.contains("### `view_image`")); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index d275b89de3..4f8141065c 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1441,6 +1441,11 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { }; assert!(!exec.description.contains("spawn_agent")); assert!(!exec.description.contains("wait_agent")); + assert!( + !exec + .description + .contains("do not attempt to use any other tools directly") + ); let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); let ToolSpec::Function(spawn_agent) = spawn_agent else { From 8ae0c837f0fbc090bef94c6323d0fa3803e55258 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 13 May 2026 14:37:50 -0700 Subject: [PATCH 25/52] Avoid PowerShell profiles in elevated Windows sandbox (#21400) ## Why On Windows, elevated sandboxed commands run under a dedicated sandbox account while `HOME` / `USERPROFILE` can still point at the real user's profile directory. For PowerShell login shells, that combination can make the sandbox account try to load the real user's PowerShell profile script. If the sandbox account's execution policy differs from the real user's policy, startup can emit profile-loading errors before the requested command runs. For this backend, loading the profile is not a faithful user login shell: it is cross-account profile execution. Treating these PowerShell invocations as non-login shells avoids that invalid startup path. ## Why This Happens Late The normal `login` decision is resolved when shell argv is created, but that point is too early to make this Windows sandbox-specific decision. At argv creation time we do not yet know the actual sandbox attempt that will run the command. A turn can include sandboxed and unsandboxed attempts, and a broad turn-level override would also affect Full Access commands where the user's profile should remain available. Instead, this change carries the selected `ShellType` alongside the argv and applies the `-NoProfile` adjustment in the shell runtimes once the `SandboxAttempt` is known. That keeps the override scoped to actual `WindowsRestrictedToken` attempts with `WindowsSandboxLevel::Elevated`. The runtime uses the selected shell metadata rather than re-detecting PowerShell from argv. That avoids brittle parsing and covers PowerShell invocation shapes such as `-EncodedCommand`. ## What Changed - Carry selected shell metadata through `exec_command` / unified exec requests and shell tool requests. - Insert `-NoProfile` for PowerShell commands only when the runtime is about to execute a sandboxed elevated Windows attempt. - Add focused unit coverage for elevated Windows PowerShell, `-EncodedCommand`, existing `-NoProfile`, legacy restricted-token attempts, unsandboxed attempts, and non-PowerShell commands. ## Verification - `cargo test -p codex-core disable_powershell_profile_tests` - `cargo test -p codex-core test_get_command` - `cargo clippy --fix --tests --allow-dirty --allow-no-vcs -p codex-core` A full `cargo test -p codex-core` run was also attempted during development, but it still hit an unrelated stack overflow in `agent::control` tests before reaching this area. --- codex-rs/core/src/memory_usage.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 4 + .../src/tools/handlers/shell/shell_command.rs | 2 + .../core/src/tools/handlers/unified_exec.rs | 27 ++- .../handlers/unified_exec/exec_command.rs | 5 +- .../src/tools/handlers/unified_exec_tests.rs | 39 ++++- codex-rs/core/src/tools/runtimes/mod.rs | 163 ++++++++++++++++++ codex-rs/core/src/tools/runtimes/shell.rs | 8 + .../core/src/tools/runtimes/unified_exec.rs | 9 + codex-rs/core/src/unified_exec/mod.rs | 2 + .../core/src/unified_exec/process_manager.rs | 1 + .../src/unified_exec/process_manager_tests.rs | 1 + 12 files changed, 245 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index b5bc6eef53..baa64459a2 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -71,7 +71,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec None, } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 84f400854d..c8c93bd6c7 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -7,6 +7,7 @@ use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; +use crate::shell::ShellType; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; @@ -44,6 +45,7 @@ struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, hook_command: String, + shell_type: Option, additional_permissions: Option, prefix_rule: Option>, session: Arc, @@ -59,6 +61,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { session.conversation_id, turn.tools_config.allow_login_shell, )?; + let shell_type = Some(session.user_shell().shell_type.clone()); run_exec_like(RunExecLikeArgs { tool_name, exec_params, hook_command: params.command, + shell_type, additional_permissions: params.additional_permissions.clone(), prefix_rule, session, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c97f5bb6f2..22e0a16dd8 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,6 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; @@ -79,6 +80,12 @@ fn effective_max_output_tokens( resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) } +#[derive(Debug)] +pub(crate) struct ResolvedCommand { + pub(crate) command: Vec, + pub(crate) shell_type: ShellType, +} + fn post_unified_exec_tool_use_payload( invocation: &ToolInvocation, result: &ExecCommandToolOutput, @@ -107,7 +114,7 @@ pub(crate) fn get_command( session_shell: Arc, shell_mode: &UnifiedExecShellMode, allow_login_shell: bool, -) -> Result, String> { +) -> Result { let use_login_shell = match args.login { Some(true) if !allow_login_shell => { return Err( @@ -126,13 +133,19 @@ pub(crate) fn get_command( shell }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); - Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) + Ok(ResolvedCommand { + command: shell.derive_exec_args(&args.cmd, use_login_shell), + shell_type: shell.shell_type.clone(), + }) } - UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(vec![ - zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), - if use_login_shell { "-lc" } else { "-c" }.to_string(), - args.cmd.clone(), - ]), + UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(ResolvedCommand { + command: vec![ + zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), + if use_login_shell { "-lc" } else { "-c" }.to_string(), + args.cmd.clone(), + ], + shell_type: ShellType::Zsh, + }), } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index cef066ad57..e27c56576a 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -138,13 +138,15 @@ impl ToolExecutor for ExecCommandHandler { ) .await; let process_id = manager.allocate_process_id().await; - let command = get_command( + let resolved_command = get_command( &args, session.user_shell(), &turn.tools_config.unified_exec_shell_mode, turn.tools_config.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; + let command = resolved_command.command; + let shell_type = resolved_command.shell_type; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); let ExecCommandArgs { @@ -249,6 +251,7 @@ impl ToolExecutor for ExecCommandHandler { .exec_command( ExecCommandRequest { command, + shell_type, hook_command: hook_command.clone(), process_id, yield_time_ms, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 02123c4b64..6ab2752b94 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shell::ShellType; use crate::shell::default_user_shell; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; @@ -42,13 +43,14 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> assert!(args.shell.is_none()); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -63,13 +65,14 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -83,21 +86,37 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { #[test] fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#; + let temp_dir = tempfile::tempdir()?; + let powershell_path = temp_dir.path().join(if cfg!(windows) { + "powershell.exe" + } else { + "powershell" + }); + std::fs::write(&powershell_path, "")?; + let json = serde_json::json!({ + "cmd": "echo hello", + "shell": powershell_path, + }) + .to_string(); - let args: ExecCommandArgs = parse_arguments(json)?; + let args: ExecCommandArgs = parse_arguments(&json)?; - assert_eq!(args.shell.as_deref(), Some("powershell")); + assert_eq!( + args.shell.as_deref(), + Some(powershell_path.to_string_lossy().as_ref()) + ); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); + assert_eq!(resolved.shell_type, ShellType::PowerShell); Ok(()) } @@ -109,13 +128,14 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); Ok(()) @@ -159,7 +179,7 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< })?, }); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &shell_mode, @@ -168,13 +188,14 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< .map_err(anyhow::Error::msg)?; assert_eq!( - command, + resolved.command, vec![ shell_zsh_path.to_string_lossy().to_string(), "-lc".to_string(), "echo hello".to_string() ] ); + assert_eq!(resolved.shell_type, ShellType::Zsh); Ok(()) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 073fda8ec4..bba1c572ef 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -8,6 +8,7 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::path_utils; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; @@ -15,8 +16,10 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -67,6 +70,35 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } +pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( + command: &[String], + shell_type: Option<&ShellType>, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, +) -> Vec { + if shell_type != Some(&ShellType::PowerShell) + || sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level != WindowsSandboxLevel::Elevated + || command.is_empty() + { + return command.to_vec(); + } + + if command[1..] + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) + { + return command.to_vec(); + } + + // The elevated Windows sandbox runs as a dedicated sandbox account while + // HOME/USERPROFILE may still point at the real user profile. Loading + // PowerShell profiles in that mixed context is not a valid login shell. + let mut command = command.to_vec(); + command.insert(1, "-NoProfile".to_string()); + command +} + /// POSIX-only helper: for commands produced by `Shell::derive_exec_args` /// for Bash/Zsh/sh of the form `[shell_path, "-lc", "