diff --git a/codex-rs/code-mode/src/remote_session.rs b/codex-rs/code-mode/src/remote_session.rs index 3c37f2e2d1..8641430ddb 100644 --- a/codex-rs/code-mode/src/remote_session.rs +++ b/codex-rs/code-mode/src/remote_session.rs @@ -38,6 +38,7 @@ type ShutdownResultReceiver = watch::Receiver>>; /// Creates code-mode sessions backed by one lazily spawned process host. pub struct ProcessOwnedCodeModeSessionProvider { state: StdMutex, + allow_in_process_fallback: bool, } /// Creates code-mode sessions backed by one shared remote WebSocket connection. @@ -56,9 +57,15 @@ impl ProcessOwnedCodeModeSessionProvider { state: StdMutex::new(ProviderState::OwnedProcess(Arc::new( OwnedCodeModeHost::new(host_program), ))), + allow_in_process_fallback: true, } } + pub fn without_in_process_fallback(mut self) -> Self { + self.allow_in_process_fallback = false; + self + } + fn process_host(&self) -> Option> { match &*self .state @@ -91,7 +98,7 @@ impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider { match process_host.connection().await { Ok(_) => {} - Err(error) if error.host_program_not_found() => { + Err(error) if error.host_program_not_found() && self.allow_in_process_fallback => { *self .state .lock() diff --git a/codex-rs/code-mode/src/remote_session/connection.rs b/codex-rs/code-mode/src/remote_session/connection.rs index ab386e42d1..82fc0ada87 100644 --- a/codex-rs/code-mode/src/remote_session/connection.rs +++ b/codex-rs/code-mode/src/remote_session/connection.rs @@ -58,6 +58,10 @@ mod transport; const IPC_CHANNEL_CAPACITY: usize = 128; const HOST_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_WEBSOCKET_FRAME_BYTES: usize = MAX_FRAME_BYTES + std::mem::size_of::(); +// Host spawn errors become model-visible tool output. Bound configured paths +// while preserving the executable-bearing suffix needed to diagnose failures. +const MAX_DISPLAYED_HOST_PROGRAM_BYTES: usize = 512; +const TRUNCATED_HOST_PROGRAM_PREFIX: &str = "..."; pub(super) enum ConnectionError { Spawn { @@ -82,11 +86,27 @@ impl fmt::Display for ConnectionError { Self::Spawn { host_program, error, - } => write!( - formatter, - "failed to spawn code-mode host {}: {error}", - host_program.display() - ), + } => { + let host_program = host_program.to_string_lossy(); + if host_program.len() <= MAX_DISPLAYED_HOST_PROGRAM_BYTES { + return write!( + formatter, + "failed to spawn code-mode host {host_program}: {error}" + ); + } + + let mut suffix_start = host_program.len() + - (MAX_DISPLAYED_HOST_PROGRAM_BYTES - TRUNCATED_HOST_PROGRAM_PREFIX.len()); + while !host_program.is_char_boundary(suffix_start) { + suffix_start += 1; + } + + write!( + formatter, + "failed to spawn code-mode host {TRUNCATED_HOST_PROGRAM_PREFIX}{}: {error}", + &host_program[suffix_start..] + ) + } Self::Other(message) => formatter.write_str(message), } } diff --git a/codex-rs/code-mode/src/remote_session_tests.rs b/codex-rs/code-mode/src/remote_session_tests.rs index 68f03a3a2c..e6ff8e9df3 100644 --- a/codex-rs/code-mode/src/remote_session_tests.rs +++ b/codex-rs/code-mode/src/remote_session_tests.rs @@ -32,6 +32,7 @@ use tokio_tungstenite::tungstenite::Message; use super::ProcessOwnedCodeModeSession; use super::ProcessOwnedCodeModeSessionProvider; use super::WebSocketCodeModeSessionProvider; +use super::connection::ConnectionError; use super::resolve_host_program; use crate::NoopCodeModeSessionDelegate; @@ -93,6 +94,41 @@ fn host_program_falls_back_to_its_name_when_main_executable_is_unknown() { ); } +#[test] +fn missing_host_error_limits_the_displayed_path_to_512_bytes() { + let executable = "codex-code-mode-host-does-not-exist"; + let host_program = format!("{}{executable}", "missing-directory/".repeat(/*n*/ 64)); + let expected_suffix = &host_program[host_program.len() - (512 - "...".len())..]; + let error = ConnectionError::Spawn { + host_program: PathBuf::from(&host_program), + error: io::Error::new(io::ErrorKind::NotFound, "host unavailable"), + }; + + assert_eq!( + error.to_string(), + format!("failed to spawn code-mode host ...{expected_suffix}: host unavailable") + ); +} + +#[test] +fn missing_host_error_preserves_utf8_boundaries_when_truncating_the_path() { + let executable = "codex-code-mode-host-does-not-exist"; + let host_program = format!("{}{executable}", "🦀".repeat(/*n*/ 256)); + let error = ConnectionError::Spawn { + host_program: PathBuf::from(host_program), + error: io::Error::new(io::ErrorKind::NotFound, "host unavailable"), + } + .to_string(); + let displayed_path = error + .strip_prefix("failed to spawn code-mode host ") + .and_then(|message| message.strip_suffix(": host unavailable")) + .expect("missing-host error should contain the displayed host path"); + + assert!(displayed_path.starts_with("...")); + assert!(displayed_path.ends_with(executable)); + assert!(displayed_path.len() <= 512); +} + #[tokio::test] async fn provider_falls_back_to_in_process_session_when_host_is_missing() { let provider = ProcessOwnedCodeModeSessionProvider::with_host_program( @@ -273,6 +309,23 @@ async fn websocket_provider_executes_over_shared_connector() { .expect("websocket test host task should succeed"); } +#[tokio::test] +async fn provider_returns_missing_host_error_when_in_process_fallback_is_disabled() { + let provider = ProcessOwnedCodeModeSessionProvider::with_host_program( + "codex-code-mode-host-does-not-exist".into(), + ) + .without_in_process_fallback(); + + let error = provider + .create_session(Arc::new(NoopCodeModeSessionDelegate)) + .await + .err() + .expect("missing host should fail when in-process fallback is disabled"); + + assert!(error.contains("failed to spawn code-mode host codex-code-mode-host-does-not-exist")); + assert!(provider.process_host().is_some()); +} + #[tokio::test] async fn shutdown_before_open_does_not_spawn_the_host() { let session = ProcessOwnedCodeModeSession::new(); diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 477f72a555..8502bfaf5a 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -35,6 +35,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::CodeModeHost { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::NonPrefixedMcpToolNames { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2e2be872cd..0e42e3e9aa 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -385,6 +385,19 @@ }, "type": "object" }, + "CodeModeHostConfigToml": { + "additionalProperties": false, + "properties": { + "disable_in_process_fallback": { + "description": "Fail instead of running embedded V8 when the standalone host is unavailable.", + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "ConfigProfile": { "additionalProperties": false, "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", @@ -462,7 +475,7 @@ "type": "boolean" }, "code_mode_host": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeHostConfigToml" }, "code_mode_only": { "type": "boolean" @@ -983,6 +996,16 @@ } ] }, + "FeatureToml_for_CodeModeHostConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CodeModeHostConfigToml" + } + ] + }, "FeatureToml_for_CurrentTimeReminderConfigToml": { "anyOf": [ { @@ -5002,7 +5025,7 @@ "type": "boolean" }, "code_mode_host": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeHostConfigToml" }, "code_mode_only": { "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f9a4dfc43c..a19584488a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -537,6 +537,10 @@ async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] + +[features.code_mode_host] +enabled = true +disable_in_process_fallback = true "#, ) .expect("TOML deserialization should succeed"); @@ -555,7 +559,9 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] config.code_mode.direct_only_tool_namespaces, vec!["mcp__history".to_string(), "mcp__notes".to_string()] ); + assert!(config.code_mode.disable_in_process_fallback); assert!(config.features.enabled(Feature::CodeMode)); + assert!(config.features.enabled(Feature::CodeModeHost)); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6ef45cb91b..09c567ff1c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1099,6 +1099,7 @@ pub struct Config { pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, pub direct_only_tool_namespaces: Vec, + pub disable_in_process_fallback: bool, } pub(crate) const DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE: &str = concat!( @@ -2556,6 +2557,14 @@ fn resolve_orchestrator_feature_enabled( fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { let base = code_mode_toml_config(config_toml.features.as_ref()); + let host = config_toml + .features + .as_ref() + .and_then(|features| features.code_mode_host.as_ref()) + .and_then(|feature| match feature { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + }); CodeModeConfig { excluded_tool_namespaces: base @@ -2566,6 +2575,9 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { .and_then(|config| config.direct_only_tool_namespaces.as_ref()) .cloned() .unwrap_or_default(), + disable_in_process_fallback: host + .and_then(|config| config.disable_in_process_fallback) + .unwrap_or_default(), } } diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index aae55b8827..15bc24cef8 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -76,8 +76,9 @@ pub fn auth_manager_from_auth_with_home(auth: CodexAuth, codex_home: PathBuf) -> pub fn with_code_mode_host_program( thread_manager: ThreadManager, host_program: PathBuf, + config: &crate::config::Config, ) -> ThreadManager { - thread_manager.with_code_mode_host_program_for_tests(host_program) + thread_manager.with_code_mode_host_program_for_tests(host_program, config) } pub fn thread_manager_with_models_provider( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index f24e0e1b99..5c718cede6 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -374,6 +374,17 @@ impl ThreadManager { config.bundled_skills_enabled(), restriction_product, )); + let code_mode_session_provider: Arc = + if config.features.enabled(Feature::CodeModeHost) { + let provider = ProcessOwnedCodeModeSessionProvider::default(); + if config.code_mode.disable_in_process_fallback { + Arc::new(provider.without_in_process_fallback()) + } else { + Arc::new(provider) + } + } else { + Arc::new(InProcessCodeModeSessionProvider) + }; Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -384,11 +395,7 @@ impl ThreadManager { skills_service, plugins_manager, mcp_manager, - code_mode_session_provider: if config.features.enabled(Feature::CodeModeHost) { - Arc::new(ProcessOwnedCodeModeSessionProvider::default()) - } else { - Arc::new(InProcessCodeModeSessionProvider) - }, + code_mode_session_provider, extensions, user_instructions_provider, thread_store, @@ -418,13 +425,20 @@ impl ThreadManager { self } - pub(crate) fn with_code_mode_host_program_for_tests(mut self, host_program: PathBuf) -> Self { + pub(crate) fn with_code_mode_host_program_for_tests( + mut self, + host_program: PathBuf, + config: &Config, + ) -> Self { let Some(state) = Arc::get_mut(&mut self.state) else { unreachable!("new thread manager state should not be shared"); }; - state.code_mode_session_provider = Arc::new( - ProcessOwnedCodeModeSessionProvider::with_host_program(host_program), - ); + let provider = ProcessOwnedCodeModeSessionProvider::with_host_program(host_program); + state.code_mode_session_provider = if config.code_mode.disable_in_process_fallback { + Arc::new(provider.without_in_process_fallback()) + } else { + Arc::new(provider) + }; self } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index e85128e6cd..cde7699375 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -650,6 +650,7 @@ impl TestCodexBuilder { codex_core::test_support::with_code_mode_host_program( thread_manager, code_mode_host_program, + &config, ) } else { thread_manager diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0232c72051..1003534324 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -259,6 +259,63 @@ async fn missing_process_host_falls_back_to_in_process_code_mode() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_process_host_fails_when_in_process_fallback_is_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_code_mode_host_program("codex-code-mode-host-does-not-exist".into()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("code mode should be enabled"); + config.code_mode.disable_in_process_fallback = true; + }); + let (_test, follow_up_mock) = + run_code_mode_turn_with_builder(&server, "Run code mode", "text('unreachable')", builder) + .await?; + + let (output, _) = + custom_tool_output_body_and_success(&follow_up_mock.single_request(), "call-1"); + assert!(output.contains("failed to spawn code-mode host codex-code-mode-host-does-not-exist")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_process_host_error_is_bounded_when_in_process_fallback_is_disabled() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let executable = "codex-code-mode-host-does-not-exist"; + let host_program = format!("{}{executable}", "missing-directory/".repeat(/*n*/ 64)); + let builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_code_mode_host_program(host_program.into()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("code mode should be enabled"); + config.code_mode.disable_in_process_fallback = true; + }); + let (_test, follow_up_mock) = + run_code_mode_turn_with_builder(&server, "Run code mode", "text('unreachable')", builder) + .await?; + + let (output, _) = + custom_tool_output_body_and_success(&follow_up_mock.single_request(), "call-1"); + assert!(output.contains("failed to spawn code-mode host ...")); + assert!(output.contains(executable)); + assert!(output.len() <= 1024, "host error must remain bounded"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_call_standalone_web_search() -> Result<()> { assert_code_mode_standalone_web_search(WebSearchMode::Live, serde_json::json!(true)).await diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index d596e46e8b..75d286563e 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -29,6 +29,26 @@ impl FeatureConfig for CodeModeConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CodeModeHostConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Fail instead of running embedded V8 when the standalone host is unavailable. + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_in_process_fallback: Option, +} + +impl FeatureConfig for CodeModeHostConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct NonPrefixedMcpToolNamesConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 4f3ba9712d..000f2c4bf4 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,6 +17,7 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::CodeModeConfigToml; +pub use feature_configs::CodeModeHostConfigToml; pub use feature_configs::CurrentTimeReminderConfigToml; pub use feature_configs::CurrentTimeReminderDeliveryMode; pub use feature_configs::CurrentTimeSource; @@ -652,6 +653,8 @@ pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub code_mode_host: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub non_prefixed_mcp_tool_names: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, @@ -690,6 +693,9 @@ impl FeaturesToml { if let Some(enabled) = self.code_mode.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::CodeMode.key().to_string(), enabled); } + if let Some(enabled) = self.code_mode_host.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::CodeModeHost.key().to_string(), enabled); + } if let Some(enabled) = self .non_prefixed_mcp_tool_names .as_ref() @@ -723,6 +729,7 @@ impl FeaturesToml { self.clear_removed_compatibility_entries(); let Self { code_mode, + code_mode_host, non_prefixed_mcp_tool_names, multi_agent_v2, token_budget, @@ -739,6 +746,8 @@ impl FeaturesToml { let enabled = features.enabled(spec.id); if spec.id == Feature::CodeMode { materialize_resolved_feature_enabled(code_mode, enabled); + } else if spec.id == Feature::CodeModeHost { + materialize_resolved_feature_enabled(code_mode_host, enabled); } else if spec.id == Feature::NonPrefixedMcpToolNames { materialize_resolved_feature_enabled(non_prefixed_mcp_tool_names, enabled); } else if spec.id == Feature::MultiAgentV2 { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a8b28a016e..53bc26c3bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -85,6 +85,42 @@ fn code_mode_only_requires_code_mode() { assert_eq!(features.enabled(Feature::CodeMode), true); } +#[test] +fn code_mode_host_feature_config_preserves_boolean_toggle() { + let features: FeaturesToml = + toml::from_str("code_mode_host = false").expect("features table should deserialize"); + + assert_eq!(features.code_mode_host, Some(FeatureToml::Enabled(false))); + assert_eq!( + features.entries(), + BTreeMap::from([("code_mode_host".to_string(), false)]) + ); +} + +#[test] +fn code_mode_host_feature_config_deserializes_fallback_setting() { + let features: FeaturesToml = toml::from_str( + r#" +[code_mode_host] +enabled = true +disable_in_process_fallback = true +"#, + ) + .expect("features table should deserialize"); + + assert_eq!( + features.code_mode_host, + Some(FeatureToml::Config(crate::CodeModeHostConfigToml { + enabled: Some(true), + disable_in_process_fallback: Some(true), + })) + ); + assert_eq!( + features.entries(), + BTreeMap::from([("code_mode_host".to_string(), true)]) + ); +} + #[test] fn from_sources_ignores_removed_terminal_resize_reflow_feature_key() { let features_toml = FeaturesToml::from(BTreeMap::from([( @@ -495,6 +531,10 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( features.enable(Feature::RespectSystemProxy); let mut features_toml = FeaturesToml { + code_mode_host: Some(FeatureToml::Config(crate::CodeModeHostConfigToml { + enabled: Some(false), + disable_in_process_fallback: Some(true), + })), multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: Some(false), min_wait_timeout_ms: Some(2500), @@ -526,6 +566,13 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( spec.key ); } + assert_eq!( + features_toml.code_mode_host, + Some(FeatureToml::Config(crate::CodeModeHostConfigToml { + enabled: Some(true), + disable_in_process_fallback: Some(true), + })) + ); assert_eq!( features_toml.multi_agent_v2, Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml {