Allow disabling the in-process code-mode host fallback (#35266)

## What changed

- Allow `features.code_mode_host` to use a configuration table with
  `disable_in_process_fallback`. When enabled, failure to start the standalone
  host is returned as tool output instead of falling back to embedded V8.
- Preserve the existing fallback behavior by default and continue accepting the
  boolean feature toggle.
- Limit displayed host paths in spawn errors to 512 bytes while retaining the
  executable-bearing suffix and valid UTF-8 boundaries.

## Testing

- Cover boolean and table-based feature configuration, fallback-disabled host
  failures, and bounded ASCII and UTF-8 error paths.

GitOrigin-RevId: ab3d014e79054c2f8beef9a658915f01cca197b2
This commit is contained in:
Channing Conger
2026-07-25 00:01:43 +00:00
committed by copyberry
parent a453588416
commit cba0e2701c
14 changed files with 297 additions and 18 deletions
+8 -1
View File
@@ -38,6 +38,7 @@ type ShutdownResultReceiver = watch::Receiver<Option<Result<(), String>>>;
/// Creates code-mode sessions backed by one lazily spawned process host.
pub struct ProcessOwnedCodeModeSessionProvider {
state: StdMutex<ProviderState>,
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<Arc<OwnedCodeModeHost>> {
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()
@@ -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::<u32>();
// 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),
}
}
@@ -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();
+9
View File
@@ -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::<codex_features::FeatureToml<
codex_features::CodeModeHostConfigToml,
>>(),
);
continue;
}
if feature.id == codex_features::Feature::NonPrefixedMcpToolNames {
validation.properties.insert(
feature.key.to_string(),
+25 -2
View File
@@ -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"
+6
View File
@@ -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(())
}
+12
View File
@@ -1099,6 +1099,7 @@ pub struct Config {
pub struct CodeModeConfig {
pub excluded_tool_namespaces: Vec<String>,
pub direct_only_tool_namespaces: Vec<String>,
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(),
}
}
+2 -1
View File
@@ -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(
+23 -9
View File
@@ -374,6 +374,17 @@ impl ThreadManager {
config.bundled_skills_enabled(),
restriction_product,
));
let code_mode_session_provider: Arc<dyn CodeModeSessionProvider> =
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
}
+1
View File
@@ -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
+57
View File
@@ -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
+20
View File
@@ -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<bool>,
/// 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<bool>,
}
impl FeatureConfig for CodeModeHostConfigToml {
fn enabled(&self) -> Option<bool> {
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 {
+9
View File
@@ -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<FeatureToml<CodeModeConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_mode_host: Option<FeatureToml<CodeModeHostConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub non_prefixed_mcp_tool_names: Option<FeatureToml<NonPrefixedMcpToolNamesConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_agent_v2: Option<FeatureToml<MultiAgentV2ConfigToml>>,
@@ -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 {
+47
View File
@@ -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 {