diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9948bc70d2..51573fb14c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2538,17 +2538,15 @@ version = "0.0.0" dependencies = [ "codex-code-mode-protocol", "codex-http-client", + "codex-install-context", "codex-protocol", "codex-websocket-client", - "deno_core_icudata", "futures", "pretty_assertions", - "serde_json", "tokio", "tokio-tungstenite", "tokio-util", "tracing", - "v8", ] [[package]] @@ -2560,6 +2558,7 @@ dependencies = [ "clap", "codex-code-mode", "codex-code-mode-protocol", + "codex-code-mode-runtime", "codex-protocol", "codex-utils-cargo-bin", "futures", @@ -2585,6 +2584,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-code-mode-runtime" +version = "0.0.0" +dependencies = [ + "codex-code-mode-protocol", + "codex-protocol", + "deno_core_icudata", + "futures", + "pretty_assertions", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "v8", +] + [[package]] name = "codex-collaboration-mode-templates" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index aa80daee7f..32f34068b5 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -24,6 +24,7 @@ members = [ "code-mode", "code-mode-host", "code-mode-protocol", + "code-mode-runtime", "codex-home", "cloud-config", "cloud-tasks", @@ -168,6 +169,7 @@ codex-cloud-tasks-client = { path = "cloud-tasks-client" } codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } codex-code-mode-protocol = { path = "code-mode-protocol" } +codex-code-mode-runtime = { path = "code-mode-runtime" } codex-home = { path = "codex-home" } codex-http-client = { path = "http-client" } codex-websocket-client = { path = "websocket-client" } diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index ff04657a18..97df9d9035 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -157,13 +157,13 @@ pub struct TestAppServer { // removing an owned CODEX_HOME that may still be its cwd on Windows. _delayed_exec_server: Option<(LocalWebsocketExecServer, WebsocketDelayInterposer)>, _attribution_settings_server: Option, + _owned_install_dir: Option, _owned_codex_home: Option, } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests"; const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; -const CODE_MODE_HOST_PATH_ENV_VAR: &str = "CODEX_CODE_MODE_HOST_PATH"; #[cfg(windows)] const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(25); #[cfg(not(windows))] @@ -293,6 +293,7 @@ impl TestAppServer { json_logs, _delayed_exec_server: None, _attribution_settings_server: None, + _owned_install_dir: None, _owned_codex_home: None, }) } @@ -1997,25 +1998,42 @@ impl TestAppServerBuilder { (None, None) } }; - if !env_overrides - .iter() - .any(|(key, _)| key == CODE_MODE_HOST_PATH_ENV_VAR) - && let Ok(code_mode_host_program) = - codex_utils_cargo_bin::cargo_bin("codex-code-mode-host") - { - env_overrides.insert( - 0, - ( - CODE_MODE_HOST_PATH_ENV_VAR.to_string(), - Some(code_mode_host_program.to_string_lossy().into_owned()), - ), - ); - } - let program = match program { + let custom_program = program.is_some(); + let mut program = match program { Some(program) => program, None => codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find binary for codex-app-server")?, }; + let mut owned_install_dir = None; + if !custom_program + && codex_utils_cargo_bin::runfiles_available() + && let Ok(code_mode_host_program) = + codex_utils_cargo_bin::cargo_bin("codex-code-mode-host") + { + // Bazel keeps binary targets in separate package directories. + // Recreate the installed sibling layout without a path override. + let install_dir = TempDir::new()?; + let staged_program = install_dir.path().join( + program + .file_name() + .context("app-server executable should have a filename")?, + ); + let staged_host = install_dir.path().join( + code_mode_host_program + .file_name() + .context("code-mode host executable should have a filename")?, + ); + for (source, destination) in [ + (&program, &staged_program), + (&code_mode_host_program, &staged_host), + ] { + std::fs::hard_link(source, destination) + .or_else(|_| std::fs::copy(source, destination).map(|_| ())) + .with_context(|| format!("stage executable {}", source.display()))?; + } + program = staged_program; + owned_install_dir = Some(install_dir); + } let env_overrides = env_overrides .iter() .map(|(key, value)| (key.as_str(), value.as_deref())) @@ -2029,6 +2047,7 @@ impl TestAppServerBuilder { ) .await?; app_server.auto_env = auto_env; + app_server._owned_install_dir = owned_install_dir; app_server._owned_codex_home = owned_codex_home; app_server._delayed_exec_server = delayed_exec_server; app_server._attribution_settings_server = attribution_settings_server; diff --git a/codex-rs/code-mode-host/Cargo.toml b/codex-rs/code-mode-host/Cargo.toml index 0d9ee48b02..5bad7553f3 100644 --- a/codex-rs/code-mode-host/Cargo.toml +++ b/codex-rs/code-mode-host/Cargo.toml @@ -20,8 +20,8 @@ workspace = true anyhow = { workspace = true } axum = { workspace = true, features = ["http1", "tokio", "ws"] } clap = { workspace = true, features = ["derive"] } -codex-code-mode = { workspace = true } codex-code-mode-protocol = { workspace = true } +codex-code-mode-runtime = { workspace = true } futures = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } @@ -29,6 +29,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] +codex-code-mode = { workspace = true } codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode-host/src/lib.rs b/codex-rs/code-mode-host/src/lib.rs index 737787301a..83b545d7e7 100644 --- a/codex-rs/code-mode-host/src/lib.rs +++ b/codex-rs/code-mode-host/src/lib.rs @@ -10,7 +10,6 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; -use codex_code_mode::InProcessCodeModeSession; use codex_code_mode_protocol::host::CapabilitySet; use codex_code_mode_protocol::host::ClientToHost; use codex_code_mode_protocol::host::EncodedFrame; @@ -23,6 +22,7 @@ use codex_code_mode_protocol::host::ProtocolVersion; use codex_code_mode_protocol::host::RequestId; use codex_code_mode_protocol::host::SessionId; use codex_code_mode_protocol::host::SupportedProtocolVersions; +use codex_code_mode_runtime::InProcessCodeModeSession; use tokio::io::AsyncRead; use tokio::io::AsyncWrite; use tokio::sync::Semaphore; diff --git a/codex-rs/code-mode-protocol/src/lib.rs b/codex-rs/code-mode-protocol/src/lib.rs index bb47b1a4c4..8c84ab6b12 100644 --- a/codex-rs/code-mode-protocol/src/lib.rs +++ b/codex-rs/code-mode-protocol/src/lib.rs @@ -38,6 +38,7 @@ pub use session::CodeModeSessionDelegate; pub use session::CodeModeSessionProvider; pub use session::CodeModeSessionProviderFuture; pub use session::CodeModeSessionResultFuture; +pub use session::NoopCodeModeSessionDelegate; pub use session::NotificationFuture; pub use session::StartedCell; pub use session::ToolInvocationFuture; diff --git a/codex-rs/code-mode-protocol/src/session.rs b/codex-rs/code-mode-protocol/src/session.rs index 57669c4314..01953f0abb 100644 --- a/codex-rs/code-mode-protocol/src/session.rs +++ b/codex-rs/code-mode-protocol/src/session.rs @@ -104,6 +104,34 @@ pub trait CodeModeSessionDelegate: Send + Sync { fn cell_closed(&self, cell_id: &CellId); } +/// A session delegate for clients that do not expose nested tools or notifications. +pub struct NoopCodeModeSessionDelegate; + +impl CodeModeSessionDelegate for NoopCodeModeSessionDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + cancellation_token.cancelled().await; + Err("code mode nested tools are unavailable".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async { Ok(()) }) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + /// A durable code-mode session owned by one Codex thread. /// /// Cells executed in the same session share stored values. Separate sessions @@ -127,6 +155,11 @@ pub trait CodeModeSession: Send + Sync { /// Implementations may share a remote host process across all sessions created /// by one provider. pub trait CodeModeSessionProvider: Send + Sync { + /// Reports whether this provider can execute code without starting its host. + fn availability(&self) -> Result<(), String> { + Ok(()) + } + fn create_session<'a>( &'a self, delegate: Arc, diff --git a/codex-rs/code-mode-runtime/BUILD.bazel b/codex-rs/code-mode-runtime/BUILD.bazel new file mode 100644 index 0000000000..b3903eb77d --- /dev/null +++ b/codex-rs/code-mode-runtime/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode-runtime", + crate_name = "codex_code_mode_runtime", +) diff --git a/codex-rs/code-mode-runtime/Cargo.toml b/codex-rs/code-mode-runtime/Cargo.toml new file mode 100644 index 0000000000..eb364dfd92 --- /dev/null +++ b/codex-rs/code-mode-runtime/Cargo.toml @@ -0,0 +1,31 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode-runtime" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode_runtime" +path = "src/lib.rs" + +[features] +sandbox = ["v8/v8_enable_sandbox"] + +[lints] +workspace = true + +[dependencies] +codex-code-mode-protocol = { workspace = true } +codex-protocol = { workspace = true } +deno_core_icudata = { workspace = true } +futures = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } diff --git a/codex-rs/code-mode/src/cell_actor/callbacks.rs b/codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/callbacks.rs rename to codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs diff --git a/codex-rs/code-mode/src/cell_actor/callbacks_tests.rs b/codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/callbacks_tests.rs rename to codex-rs/code-mode-runtime/src/cell_actor/callbacks_tests.rs diff --git a/codex-rs/code-mode/src/cell_actor/conversions.rs b/codex-rs/code-mode-runtime/src/cell_actor/conversions.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/conversions.rs rename to codex-rs/code-mode-runtime/src/cell_actor/conversions.rs diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode-runtime/src/cell_actor/mod.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/mod.rs rename to codex-rs/code-mode-runtime/src/cell_actor/mod.rs diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode-runtime/src/cell_actor/tests.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/tests.rs rename to codex-rs/code-mode-runtime/src/cell_actor/tests.rs diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode-runtime/src/cell_actor/types.rs similarity index 100% rename from codex-rs/code-mode/src/cell_actor/types.rs rename to codex-rs/code-mode-runtime/src/cell_actor/types.rs diff --git a/codex-rs/code-mode-runtime/src/lib.rs b/codex-rs/code-mode-runtime/src/lib.rs new file mode 100644 index 0000000000..819636ef0d --- /dev/null +++ b/codex-rs/code-mode-runtime/src/lib.rs @@ -0,0 +1,12 @@ +mod cell_actor; +mod runtime; +mod service; +mod session_runtime; +mod v8_init; + +pub(crate) type TaskFailureHandler = std::sync::Arc; + +pub use codex_code_mode_protocol::*; +pub use service::InProcessCodeModeSession; +pub use v8_init::V8JitMode; +pub use v8_init::initialize_v8; diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode-runtime/src/runtime/callbacks.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/callbacks.rs rename to codex-rs/code-mode-runtime/src/runtime/callbacks.rs diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode-runtime/src/runtime/globals.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/globals.rs rename to codex-rs/code-mode-runtime/src/runtime/globals.rs diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode-runtime/src/runtime/mod.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/mod.rs rename to codex-rs/code-mode-runtime/src/runtime/mod.rs diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode-runtime/src/runtime/module_loader.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/module_loader.rs rename to codex-rs/code-mode-runtime/src/runtime/module_loader.rs diff --git a/codex-rs/code-mode/src/runtime/timers.rs b/codex-rs/code-mode-runtime/src/runtime/timers.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/timers.rs rename to codex-rs/code-mode-runtime/src/runtime/timers.rs diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode-runtime/src/runtime/value.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/value.rs rename to codex-rs/code-mode-runtime/src/runtime/value.rs diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode-runtime/src/service.rs similarity index 90% rename from codex-rs/code-mode/src/service.rs rename to codex-rs/code-mode-runtime/src/service.rs index 952d9b1d84..7ca05eedaf 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode-runtime/src/service.rs @@ -5,8 +5,6 @@ use codex_code_mode_protocol::CellId; use codex_code_mode_protocol::CodeModeNestedToolCall; use codex_code_mode_protocol::CodeModeSession; use codex_code_mode_protocol::CodeModeSessionDelegate; -use codex_code_mode_protocol::CodeModeSessionProvider; -use codex_code_mode_protocol::CodeModeSessionProviderFuture; use codex_code_mode_protocol::CodeModeSessionResultFuture; use codex_code_mode_protocol::CodeModeToolKind; use codex_code_mode_protocol::DEFAULT_EXEC_YIELD_TIME_MS; @@ -14,10 +12,9 @@ use codex_code_mode_protocol::ExecuteRequest; use codex_code_mode_protocol::ExecuteToPendingOutcome; use codex_code_mode_protocol::FunctionCallOutputContentItem; use codex_code_mode_protocol::ImageDetail; -use codex_code_mode_protocol::NotificationFuture; +use codex_code_mode_protocol::NoopCodeModeSessionDelegate; use codex_code_mode_protocol::RuntimeResponse; use codex_code_mode_protocol::StartedCell; -use codex_code_mode_protocol::ToolInvocationFuture; use codex_code_mode_protocol::WaitOutcome; use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::WaitToPendingOutcome; @@ -41,49 +38,6 @@ fn yield_timeout(yield_time_ms: u64) -> Duration { } } -pub struct NoopCodeModeSessionDelegate; - -impl CodeModeSessionDelegate for NoopCodeModeSessionDelegate { - fn invoke_tool<'a>( - &'a self, - _invocation: CodeModeNestedToolCall, - cancellation_token: CancellationToken, - ) -> ToolInvocationFuture<'a> { - Box::pin(async move { - cancellation_token.cancelled().await; - Err("code mode nested tools are unavailable".to_string()) - }) - } - - fn notify<'a>( - &'a self, - _call_id: String, - _cell_id: CellId, - _text: String, - _cancellation_token: CancellationToken, - ) -> NotificationFuture<'a> { - Box::pin(async { Ok(()) }) - } - - fn cell_closed(&self, _cell_id: &CellId) {} -} - -#[derive(Default)] -pub struct InProcessCodeModeSessionProvider; - -impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { - fn create_session<'a>( - &'a self, - delegate: Arc, - ) -> CodeModeSessionProviderFuture<'a> { - Box::pin(async move { - let session: Arc = - Arc::new(InProcessCodeModeSession::with_delegate(delegate)); - Ok(session) - }) - } -} - pub struct InProcessCodeModeSession { runtime: SessionRuntime, } diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode-runtime/src/service_contract_tests.rs similarity index 99% rename from codex-rs/code-mode/src/service_contract_tests.rs rename to codex-rs/code-mode-runtime/src/service_contract_tests.rs index 19b8c8d7e2..9b0ba3e454 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode-runtime/src/service_contract_tests.rs @@ -3,6 +3,8 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use codex_code_mode_protocol::NotificationFuture; +use codex_code_mode_protocol::ToolInvocationFuture; use codex_protocol::ToolName; use pretty_assertions::assert_eq; use tokio::sync::Notify; diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode-runtime/src/service_tests.rs similarity index 99% rename from codex-rs/code-mode/src/service_tests.rs rename to codex-rs/code-mode-runtime/src/service_tests.rs index 6d0fba172a..0e5c3677f8 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode-runtime/src/service_tests.rs @@ -7,9 +7,7 @@ use super::CellId; use super::CodeModeNestedToolCall; use super::CodeModeSessionDelegate; use super::InProcessCodeModeSession; -use super::NotificationFuture; use super::RuntimeResponse; -use super::ToolInvocationFuture; use super::WaitOutcome; use super::WaitRequest; use super::WaitToPendingOutcome; @@ -20,6 +18,8 @@ use crate::ExecuteRequest; use crate::ExecuteToPendingOutcome; use crate::FunctionCallOutputContentItem; use crate::ToolDefinition; +use codex_code_mode_protocol::NotificationFuture; +use codex_code_mode_protocol::ToolInvocationFuture; use codex_protocol::ToolName; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode-runtime/src/session_runtime/mod.rs similarity index 100% rename from codex-rs/code-mode/src/session_runtime/mod.rs rename to codex-rs/code-mode-runtime/src/session_runtime/mod.rs diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode-runtime/src/session_runtime/tests.rs similarity index 100% rename from codex-rs/code-mode/src/session_runtime/tests.rs rename to codex-rs/code-mode-runtime/src/session_runtime/tests.rs diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode-runtime/src/session_runtime/types.rs similarity index 100% rename from codex-rs/code-mode/src/session_runtime/types.rs rename to codex-rs/code-mode-runtime/src/session_runtime/types.rs diff --git a/codex-rs/code-mode/src/v8_init.rs b/codex-rs/code-mode-runtime/src/v8_init.rs similarity index 100% rename from codex-rs/code-mode/src/v8_init.rs rename to codex-rs/code-mode-runtime/src/v8_init.rs diff --git a/codex-rs/code-mode/tests/jit.rs b/codex-rs/code-mode-runtime/tests/jit.rs similarity index 81% rename from codex-rs/code-mode/tests/jit.rs rename to codex-rs/code-mode-runtime/tests/jit.rs index 0f8e866c47..4cc8530277 100644 --- a/codex-rs/code-mode/tests/jit.rs +++ b/codex-rs/code-mode-runtime/tests/jit.rs @@ -1,8 +1,8 @@ -use codex_code_mode::ExecuteRequest; -use codex_code_mode::InProcessCodeModeSession; -use codex_code_mode::RuntimeResponse; -use codex_code_mode::V8JitMode; -use codex_code_mode::initialize_v8; +use codex_code_mode_runtime::ExecuteRequest; +use codex_code_mode_runtime::InProcessCodeModeSession; +use codex_code_mode_runtime::RuntimeResponse; +use codex_code_mode_runtime::V8JitMode; +use codex_code_mode_runtime::initialize_v8; use pretty_assertions::assert_eq; #[tokio::test] diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 56c7791762..60c0fdcfef 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -9,26 +9,21 @@ doctest = false name = "codex_code_mode" path = "src/lib.rs" -[features] -sandbox = ["v8/v8_enable_sandbox"] - [lints] workspace = true [dependencies] codex-code-mode-protocol = { workspace = true } codex-http-client = { workspace = true } -codex-protocol = { workspace = true } +codex-install-context = { workspace = true } codex-websocket-client = { workspace = true } -deno_core_icudata = { workspace = true } futures = { workspace = true } -serde_json = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio-tungstenite = { workspace = true } tokio-util = { workspace = true, features = ["rt"] } tracing = { workspace = true } -v8 = { workspace = true } [dev-dependencies] +codex-protocol = { workspace = true } pretty_assertions = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index c4266a4a55..9e7d7d09e0 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,18 +1,7 @@ -mod cell_actor; mod remote_session; -mod runtime; -mod service; -mod session_runtime; -mod v8_init; - -pub(crate) type TaskFailureHandler = std::sync::Arc; pub use codex_code_mode_protocol::*; +pub use remote_session::DisabledCodeModeSessionProvider; pub use remote_session::ProcessOwnedCodeModeSession; pub use remote_session::ProcessOwnedCodeModeSessionProvider; pub use remote_session::WebSocketCodeModeSessionProvider; -pub use service::InProcessCodeModeSession; -pub use service::InProcessCodeModeSessionProvider; -pub use service::NoopCodeModeSessionDelegate; -pub use v8_init::V8JitMode; -pub use v8_init::initialize_v8; diff --git a/codex-rs/code-mode/src/remote_session.rs b/codex-rs/code-mode/src/remote_session.rs index 8641430ddb..40d4b73934 100644 --- a/codex-rs/code-mode/src/remote_session.rs +++ b/codex-rs/code-mode/src/remote_session.rs @@ -1,4 +1,3 @@ -use std::ffi::OsString; use std::io; use std::path::PathBuf; use std::sync::Arc; @@ -20,6 +19,7 @@ use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::host::SessionId; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; +use codex_install_context::InstallContext; use tokio::sync::Semaphore; use tokio::sync::watch; @@ -31,87 +31,74 @@ use crate::NoopCodeModeSessionDelegate; mod connection; -const CODE_MODE_HOST_PATH_ENV: &str = "CODEX_CODE_MODE_HOST_PATH"; - 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, + host: Arc, } +/// Rejects code-mode sessions when the standalone host is disabled. +#[derive(Default)] +pub struct DisabledCodeModeSessionProvider; + /// Creates code-mode sessions backed by one shared remote WebSocket connection. pub struct WebSocketCodeModeSessionProvider { host: Arc, } -enum ProviderState { - OwnedProcess(Arc), - InProcess, -} - impl ProcessOwnedCodeModeSessionProvider { pub fn with_host_program(host_program: PathBuf) -> Self { Self { - state: StdMutex::new(ProviderState::OwnedProcess(Arc::new( - OwnedCodeModeHost::new(host_program), - ))), - allow_in_process_fallback: true, + host: Arc::new(OwnedCodeModeHost::new(host_program)), } } - pub fn without_in_process_fallback(mut self) -> Self { - self.allow_in_process_fallback = false; - self - } - - fn process_host(&self) -> Option> { - match &*self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - { - ProviderState::OwnedProcess(process_host) => Some(Arc::clone(process_host)), - ProviderState::InProcess => None, - } + fn process_host(&self) -> Arc { + Arc::clone(&self.host) } } impl Default for ProcessOwnedCodeModeSessionProvider { fn default() -> Self { - Self::with_host_program(default_host_program()) + Self::with_host_program(InstallContext::current().code_mode_host_program()) } } impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider { + fn availability(&self) -> Result<(), String> { + let HostEndpoint::Process(host_program) = &self.host.endpoint else { + unreachable!("a process-owned provider always has a process endpoint"); + }; + if host_program.is_file() { + Ok(()) + } else { + Err(ConnectionError::Spawn { + host_program: host_program.clone(), + error: io::Error::new(io::ErrorKind::NotFound, "host executable was not found"), + } + .to_string()) + } + } + fn create_session<'a>( &'a self, delegate: Arc, ) -> CodeModeSessionProviderFuture<'a> { - Box::pin(async move { - let Some(process_host) = self.process_host() else { - let session: Arc = - Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate)); - return Ok(session); - }; + Box::pin(create_host_session(delegate, self.process_host())) + } +} - match process_host.connection().await { - Ok(_) => {} - Err(error) if error.host_program_not_found() && self.allow_in_process_fallback => { - *self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - ProviderState::InProcess; - let session: Arc = - Arc::new(crate::InProcessCodeModeSession::with_delegate(delegate)); - return Ok(session); - } - Err(error) => return Err(error.to_string()), - } - create_host_session(delegate, process_host).await - }) +impl CodeModeSessionProvider for DisabledCodeModeSessionProvider { + fn availability(&self) -> Result<(), String> { + Err("code-mode host is disabled".to_string()) + } + + fn create_session<'a>( + &'a self, + _delegate: Arc, + ) -> CodeModeSessionProviderFuture<'a> { + Box::pin(async { Err("code-mode host is disabled".to_string()) }) } } @@ -273,7 +260,9 @@ impl ProcessOwnedCodeModeSession { pub fn new() -> Self { Self::with_host( Arc::new(NoopCodeModeSessionDelegate), - Arc::new(OwnedCodeModeHost::new(default_host_program())), + Arc::new(OwnedCodeModeHost::new( + InstallContext::current().code_mode_host_program(), + )), ) } @@ -569,33 +558,6 @@ impl CodeModeSession for ProcessOwnedCodeModeSession { } } -fn default_host_program() -> PathBuf { - resolve_host_program( - std::env::var_os(CODE_MODE_HOST_PATH_ENV), - std::env::current_exe(), - ) -} - -fn resolve_host_program( - override_path: Option, - current_exe: io::Result, -) -> PathBuf { - if let Some(path) = override_path { - return PathBuf::from(path); - } - let executable_name = if cfg!(windows) { - "codex-code-mode-host.exe" - } else { - "codex-code-mode-host" - }; - if let Ok(current_exe) = current_exe - && let Some(parent) = current_exe.parent() - { - return parent.join(executable_name); - } - PathBuf::from(executable_name) -} - #[cfg(test)] #[path = "remote_session_tests.rs"] mod tests; diff --git a/codex-rs/code-mode/src/remote_session/connection.rs b/codex-rs/code-mode/src/remote_session/connection.rs index 82fc0ada87..033d24ab57 100644 --- a/codex-rs/code-mode/src/remote_session/connection.rs +++ b/codex-rs/code-mode/src/remote_session/connection.rs @@ -71,15 +71,6 @@ pub(super) enum ConnectionError { Other(String), } -impl ConnectionError { - pub(super) fn host_program_not_found(&self) -> bool { - matches!( - self, - Self::Spawn { error, .. } if error.kind() == io::ErrorKind::NotFound - ) - } -} - impl fmt::Display for ConnectionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/codex-rs/code-mode/src/remote_session_tests.rs b/codex-rs/code-mode/src/remote_session_tests.rs index e6ff8e9df3..0429209157 100644 --- a/codex-rs/code-mode/src/remote_session_tests.rs +++ b/codex-rs/code-mode/src/remote_session_tests.rs @@ -33,67 +33,18 @@ use super::ProcessOwnedCodeModeSession; use super::ProcessOwnedCodeModeSessionProvider; use super::WebSocketCodeModeSessionProvider; use super::connection::ConnectionError; -use super::resolve_host_program; use crate::NoopCodeModeSessionDelegate; #[test] fn provider_reuses_its_live_process_host() { let provider = ProcessOwnedCodeModeSessionProvider::default(); - let first = provider.process_host().expect("owned process host"); - let second = provider.process_host().expect("owned process host"); + let first = provider.process_host(); + let second = provider.process_host(); assert!(Arc::ptr_eq(&first, &second)); } -#[test] -fn host_program_override_takes_precedence() { - assert_eq!( - resolve_host_program( - Some("custom-code-mode-host".into()), - Ok(PathBuf::from("/opt/codex/bin/codex")), - ), - PathBuf::from("custom-code-mode-host") - ); -} - -#[test] -fn host_program_is_next_to_the_main_executable_even_when_missing() { - let executable_name = if cfg!(windows) { - "codex-code-mode-host.exe" - } else { - "codex-code-mode-host" - }; - - assert_eq!( - resolve_host_program( - /*override_path*/ None, - Ok(PathBuf::from("/opt/codex/bin/codex")), - ), - PathBuf::from("/opt/codex/bin").join(executable_name) - ); -} - -#[test] -fn host_program_falls_back_to_its_name_when_main_executable_is_unknown() { - let executable_name = if cfg!(windows) { - "codex-code-mode-host.exe" - } else { - "codex-code-mode-host" - }; - - assert_eq!( - resolve_host_program( - /*override_path*/ None, - Err(io::Error::new( - io::ErrorKind::NotFound, - "missing executable" - )), - ), - PathBuf::from(executable_name) - ); -} - #[test] fn missing_host_error_limits_the_displayed_path_to_512_bytes() { let executable = "codex-code-mode-host-does-not-exist"; @@ -129,42 +80,6 @@ fn missing_host_error_preserves_utf8_boundaries_when_truncating_the_path() { 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( - "codex-code-mode-host-does-not-exist".into(), - ); - - let session = provider - .create_session(Arc::new(NoopCodeModeSessionDelegate)) - .await - .expect("missing host should fall back to an in-process session"); - let response = session - .execute(ExecuteRequest { - tool_call_id: "call-1".to_string(), - enabled_tools: Vec::new(), - source: "text('fallback')".to_string(), - yield_time_ms: None, - max_output_tokens: None, - }) - .await - .expect("execute fallback session") - .initial_response() - .await - .expect("read fallback response"); - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: codex_code_mode_protocol::CellId::new("1".to_string()), - content_items: vec![FunctionCallOutputContentItem::InputText { - text: "fallback".to_string(), - }], - error_text: None, - } - ); -} - #[tokio::test] async fn websocket_provider_executes_over_shared_connector() { let listener = TcpListener::bind("127.0.0.1:0") @@ -310,20 +225,18 @@ async fn websocket_provider_executes_over_shared_connector() { } #[tokio::test] -async fn provider_returns_missing_host_error_when_in_process_fallback_is_disabled() { +async fn provider_returns_missing_host_error() { 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"); + .expect("missing host should fail"); assert!(error.contains("failed to spawn code-mode host codex-code-mode-host-does-not-exist")); - assert!(provider.process_host().is_some()); } #[tokio::test] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index be0a862874..2b3506c398 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -389,7 +389,7 @@ "additionalProperties": false, "properties": { "disable_in_process_fallback": { - "description": "Fail instead of running embedded V8 when the standalone host is unavailable.", + "description": "Keep code mode fail-closed when the standalone host is unavailable.", "type": "boolean" }, "enabled": { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4fcaf7dadb..f6667ae082 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1096,6 +1096,7 @@ pub struct Config { pub struct CodeModeConfig { pub excluded_tool_namespaces: Vec, pub direct_only_tool_namespaces: Vec, + /// Keep code mode fail-closed when the standalone host is unavailable. pub disable_in_process_fallback: bool, } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 9a89c1beeb..4186d9e849 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -112,6 +112,7 @@ pub(super) async fn spawn_review_thread( sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), realtime_active: parent_turn_context.realtime_active, + code_mode_available: parent_turn_context.code_mode_available, config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a71c37fa2f..ad6c05ed41 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5579,7 +5579,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { skills_service, plugins_manager, mcp_manager, - Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), /*supports_openai_form_elicitation*/ false, @@ -5809,7 +5809,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ), executed_tool_calls, code_mode_service: crate::tools::code_mode::CodeModeService::new( - Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), &config.features, ), tool_search_handler_cache: Default::default(), @@ -5985,7 +5985,7 @@ async fn make_session_with_config_and_rx( skills_service, plugins_manager, mcp_manager, - Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), /*supports_openai_form_elicitation*/ false, @@ -6095,7 +6095,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( skills_service, plugins_manager, mcp_manager, - Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), codex_extension_api::ExtensionDataInit::default(), /*supports_openai_form_elicitation*/ false, @@ -7997,7 +7997,7 @@ where ), executed_tool_calls, code_mode_service: crate::tools::code_mode::CodeModeService::new( - Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), &config.features, ), tool_search_handler_cache: Default::default(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5e84027b43..1bbd11a808 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -743,7 +743,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { skills_service, plugins_manager, mcp_manager, - code_mode_session_provider: Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), + code_mode_session_provider: Arc::new(codex_code_mode::DisabledCodeModeSessionProvider), extensions: codex_extension_api::empty_extension_registry(), conversation_history: InitialHistory::New, requested_history_mode: None, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e500a9da22..4d2b2bddc8 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -115,6 +115,7 @@ pub struct TurnContext { pub(crate) sub_id: String, pub(crate) trace_id: Option, pub(crate) realtime_active: bool, + pub(crate) code_mode_available: bool, pub config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, @@ -280,6 +281,7 @@ impl TurnContext { sub_id: self.sub_id.clone(), trace_id: self.trace_id.clone(), realtime_active: self.realtime_active, + code_mode_available: self.code_mode_available, config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), @@ -551,6 +553,7 @@ impl Session { sub_id, trace_id: current_span_trace_id(), realtime_active: false, + code_mode_available: true, config: per_turn_config, auth_manager: auth_manager_for_context, model_info, @@ -798,6 +801,7 @@ impl Session { sub_id, skills_snapshot, ); + turn_context.code_mode_available = self.services.code_mode_service.is_available(); turn_context.extension_data.insert(trusted_plugin_roots); turn_context.realtime_active = self.conversation.running_state().await.is_some(); @@ -830,6 +834,21 @@ impl Session { .await; } + if !tc.code_mode_available + && matches!( + crate::tools::requested_tool_mode(tc), + codex_protocol::openai_models::ToolMode::CodeMode + | codex_protocol::openai_models::ToolMode::CodeModeOnly + ) + && let Some(message) = self + .services + .code_mode_service + .take_unavailable_warning(crate::tools::effective_tool_mode(tc)) + { + self.send_event(tc, EventMsg::Warning(WarningEvent { message })) + .await; + } + if let Some(message) = unsupported_code_mode_warning(&tc.model_info, tc.config.features.get()) { diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 0fdb9fbb26..037752bd5b 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -24,10 +24,8 @@ use codex_agent_graph_store::LocalAgentGraphStore; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::TurnStatus; -use codex_code_mode::CodeModeSessionDelegate; use codex_code_mode::CodeModeSessionProvider; -use codex_code_mode::CodeModeSessionProviderFuture; -use codex_code_mode::InProcessCodeModeSessionProvider; +use codex_code_mode::DisabledCodeModeSessionProvider; use codex_code_mode::ProcessOwnedCodeModeSessionProvider; use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; @@ -99,19 +97,6 @@ use tracing::warn; const THREAD_CREATED_CHANNEL_CAPACITY: usize = 1024; -struct DisabledCodeModeSessionProvider; - -impl CodeModeSessionProvider for DisabledCodeModeSessionProvider { - fn create_session<'a>( - &'a self, - _delegate: Arc, - ) -> CodeModeSessionProviderFuture<'a> { - Box::pin(async { - Err("code-mode host is disabled and in-process fallback is disabled".to_string()) - }) - } -} - /// Test-only override for enabling thread-manager behaviors used by integration /// tests. /// @@ -392,17 +377,12 @@ impl ThreadManager { 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 if config.code_mode.disable_in_process_fallback { - Arc::new(DisabledCodeModeSessionProvider) + if config.features.enabled(Feature::CodeModeHost) + || config.code_mode.disable_in_process_fallback + { + Arc::new(ProcessOwnedCodeModeSessionProvider::default()) } else { - Arc::new(InProcessCodeModeSessionProvider) + Arc::new(DisabledCodeModeSessionProvider) }; Self { state: Arc::new(ThreadManagerState { @@ -447,17 +427,14 @@ impl ThreadManager { pub(crate) fn with_code_mode_host_program_for_tests( mut self, host_program: PathBuf, - config: &Config, + _config: &Config, ) -> Self { let Some(state) = Arc::get_mut(&mut self.state) else { unreachable!("new thread manager state should not be shared"); }; - 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) - }; + state.code_mode_session_provider = Arc::new( + ProcessOwnedCodeModeSessionProvider::with_host_program(host_program), + ); self } @@ -550,7 +527,7 @@ impl ThreadManager { skills_service, plugins_manager, mcp_manager, - code_mode_session_provider: Arc::new(InProcessCodeModeSessionProvider), + code_mode_session_provider: Arc::new(DisabledCodeModeSessionProvider), extensions: empty_extension_registry(), user_instructions_provider: Arc::new( crate::test_support::EmptyUserInstructionsProvider, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index deb0419fa1..d467a12d7f 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -429,7 +429,7 @@ async fn code_mode_session_provider_is_shared_across_threads() { config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); - let provider: Arc = Arc::new(InProcessCodeModeSessionProvider); + let provider: Arc = Arc::new(DisabledCodeModeSessionProvider); let manager = ThreadManager::with_models_provider_and_home_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 6f4b98b986..e02415b193 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -76,9 +76,11 @@ pub(crate) struct ExecContext { pub(crate) struct CodeModeService { session: OnceCell>, session_provider: Arc, + availability: Result<(), String>, dispatch_broker: Arc, default_exec_yield_time_override_ms: Option, shutting_down: AtomicBool, + unavailable_warning_emitted: AtomicBool, } impl CodeModeService { @@ -87,15 +89,38 @@ impl CodeModeService { features: &Features, ) -> Self { let dispatch_broker = Arc::new(CodeModeDispatchBroker::new()); + let availability = session_provider.availability(); Self { session: OnceCell::new(), session_provider, + availability, dispatch_broker, default_exec_yield_time_override_ms: default_exec_yield_time_override_ms(features), shutting_down: AtomicBool::new(false), + unavailable_warning_emitted: AtomicBool::new(false), } } + pub(crate) fn is_available(&self) -> bool { + self.availability.is_ok() + } + + pub(crate) fn take_unavailable_warning(&self, tool_mode: ToolMode) -> Option { + let error = self.availability.as_ref().err()?; + let behavior = match tool_mode { + ToolMode::Direct => "Falling back to direct tools", + ToolMode::CodeMode | ToolMode::CodeModeOnly => "Code mode will fail closed", + }; + (!self + .unavailable_warning_emitted + .swap(true, Ordering::Relaxed)) + .then(|| { + format!( + "Code Mode is unavailable because {error}. {behavior}; enable `features.code_mode_host` and install `codex-code-mode-host`." + ) + }) + } + pub(crate) fn session_provider(&self) -> Arc { Arc::clone(&self.session_provider) } @@ -378,18 +403,10 @@ fn build_freeform_tool_payload( #[cfg(test)] mod tests { - use std::sync::Arc; - - use super::CodeModeService; use super::build_nested_tool_payload; use super::truncate_code_mode_result; use crate::tools::context::ToolPayload; use codex_code_mode::CodeModeToolKind; - use codex_code_mode::ExecuteRequest; - use codex_code_mode::FunctionCallOutputContentItem as CodeModeOutputContentItem; - use codex_code_mode::ProcessOwnedCodeModeSessionProvider; - use codex_code_mode::RuntimeResponse; - use codex_features::Features; use codex_protocol::models::FunctionCallOutputContentItem; use codex_tools::ToolName; use serde_json::json; @@ -460,40 +477,4 @@ mod tests { }] ); } - - #[tokio::test] - async fn missing_process_host_falls_back_to_in_process_session() { - let service = CodeModeService::new( - Arc::new(ProcessOwnedCodeModeSessionProvider::with_host_program( - "codex-code-mode-host-does-not-exist".into(), - )), - &Features::with_defaults(), - ); - - let response = service - .execute(ExecuteRequest { - tool_call_id: "call-1".to_string(), - enabled_tools: Vec::new(), - source: "text('fallback')".to_string(), - yield_time_ms: None, - max_output_tokens: None, - }) - .await - .expect("missing host should fall back to an in-process session") - .initial_response() - .await - .expect("read fallback response"); - - assert_eq!( - response, - RuntimeResponse::Result { - cell_id: codex_code_mode::CellId::new("1".to_string()), - content_items: vec![CodeModeOutputContentItem::InputText { - text: "fallback".to_string(), - }], - error_text: None, - } - ); - service.shutdown().await.expect("shutdown service"); - } } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 17cf4da5fb..eba0fad723 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -63,7 +63,7 @@ pub(crate) fn tool_user_shell_type( } } -fn effective_tool_mode(turn_context: &TurnContext) -> ToolMode { +pub(crate) fn requested_tool_mode(turn_context: &TurnContext) -> ToolMode { turn_context.model_info.tool_mode.unwrap_or_else(|| { if turn_context.config.features.enabled(Feature::CodeModeOnly) { ToolMode::CodeModeOnly @@ -75,6 +75,18 @@ fn effective_tool_mode(turn_context: &TurnContext) -> ToolMode { }) } +pub(crate) fn effective_tool_mode(turn_context: &TurnContext) -> ToolMode { + let requested_tool_mode = requested_tool_mode(turn_context); + if !turn_context.code_mode_available + && requested_tool_mode == ToolMode::CodeMode + && !turn_context.config.code_mode.disable_in_process_fallback + { + ToolMode::Direct + } else { + requested_tool_mode + } +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model( diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index 07ff51234d..6880433f13 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -16,6 +16,7 @@ use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnContext; +use crate::tools::code_mode::CodeModeService; use crate::tools::code_mode::CodeModeWaitHandler; use crate::tools::code_mode::WAIT_TOOL_NAME; use crate::tools::context::FunctionToolOutput; @@ -59,6 +60,56 @@ impl ToolExecutor for TestHandler { impl CoreToolRuntime for TestHandler {} +struct MissingCellCodeModeSessionProvider; + +impl codex_code_mode::CodeModeSessionProvider for MissingCellCodeModeSessionProvider { + fn create_session<'a>( + &'a self, + _delegate: Arc, + ) -> codex_code_mode::CodeModeSessionProviderFuture<'a> { + Box::pin(async { + Ok(Arc::new(MissingCellCodeModeSession) as Arc) + }) + } +} + +struct MissingCellCodeModeSession; + +impl codex_code_mode::CodeModeSession for MissingCellCodeModeSession { + fn execute<'a>( + &'a self, + _request: codex_code_mode::ExecuteRequest, + ) -> codex_code_mode::CodeModeSessionResultFuture<'a, codex_code_mode::StartedCell> { + Box::pin(async { Err("test session cannot execute cells".to_string()) }) + } + + fn wait<'a>( + &'a self, + request: codex_code_mode::WaitRequest, + ) -> codex_code_mode::CodeModeSessionResultFuture<'a, codex_code_mode::WaitOutcome> { + self.terminate(request.cell_id) + } + + fn terminate<'a>( + &'a self, + cell_id: codex_code_mode::CellId, + ) -> codex_code_mode::CodeModeSessionResultFuture<'a, codex_code_mode::WaitOutcome> { + Box::pin(async move { + Ok(codex_code_mode::WaitOutcome::MissingCell( + codex_code_mode::RuntimeResponse::Result { + error_text: Some(format!("exec cell {cell_id} not found")), + cell_id, + content_items: Vec::new(), + }, + )) + }) + } + + fn shutdown<'a>(&'a self) -> codex_code_mode::CodeModeSessionResultFuture<'a, ()> { + Box::pin(async { Ok(()) }) + } +} + #[tokio::test] async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> anyhow::Result<()> { let temp = TempDir::new()?; @@ -226,6 +277,10 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Result<()> { let temp = TempDir::new()?; let (mut session, turn) = make_session_and_context().await; + session.services.code_mode_service = CodeModeService::new( + Arc::new(MissingCellCodeModeSessionProvider), + &turn.config.features, + ); attach_test_trace(&mut session, &turn, temp.path())?; let registry = ToolRegistry::with_handler_for_test(Arc::new(CodeModeWaitHandler)); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 190b0445d6..30c89d2068 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -232,8 +232,89 @@ async fn run_code_mode_turn_with_builder( Ok((test, second_mock)) } +async fn run_unavailable_code_mode_turn( + server: &MockServer, + test: &TestCodex, +) -> Result<(Value, Vec)> { + let response_mock = responses::mount_sse_once( + server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "list available tools".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let mut warnings = Vec::new(); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Warning(warning) => warnings.push(warning.message), + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + Ok((response_mock.single_request().body_json(), warnings)) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_process_host_falls_back_to_in_process_code_mode() -> Result<()> { +async fn missing_process_host_falls_back_to_direct_tools_and_warns_once() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut 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"); + }); + let test = builder.build_with_auto_env(&server).await?; + let (first_body, first_warnings) = run_unavailable_code_mode_turn(&server, &test).await?; + let first_tools = tool_names(&first_body); + assert!( + first_tools + .iter() + .all(|name| name != "exec" && name != "wait"), + "unavailable code mode must not expose code-mode tools: {first_tools:?}" + ); + assert!( + first_warnings.iter().any(|warning| { + warning.contains("Code Mode is unavailable") + && warning.contains("codex-code-mode-host-does-not-exist") + }), + "missing host should produce an actionable warning: {first_warnings:?}" + ); + + let (_, second_warnings) = run_unavailable_code_mode_turn(&server, &test).await?; + assert!( + second_warnings + .iter() + .all(|warning| !warning.contains("Code Mode is unavailable")), + "host availability warning should be emitted once per thread: {second_warnings:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn missing_process_host_keeps_code_mode_only_and_fails_closed() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -243,26 +324,39 @@ async fn missing_process_host_falls_back_to_in_process_code_mode() -> Result<()> .with_config(|config| { config .features - .enable(Feature::CodeMode) + .enable(Feature::CodeModeOnly) .expect("code mode should be enabled"); }); - let (_test, follow_up_mock) = - run_code_mode_turn_with_builder(&server, "Run code mode", "text('fallback')", builder) - .await?; - - assert_eq!( - text_item( - &custom_tool_output_items(&follow_up_mock.single_request(), "call-1"), - /*index*/ 1, - ), - "fallback" + let (_test, follow_up_mock) = run_code_mode_turn_with_builder( + &server, + "Run required code mode", + "text('unreachable')", + builder, + ) + .await?; + let request = follow_up_mock.single_request(); + let tools = tool_names(&request.body_json()); + assert!( + tools.iter().any(|name| name == "exec") && tools.iter().any(|name| name == "wait"), + "code-mode-only must retain code-mode tools: {tools:?}" + ); + assert!( + tools + .iter() + .all(|name| { !matches!(name.as_str(), "shell" | "shell_command" | "exec_command") }), + "code-mode-only must never expose direct shell tools: {tools:?}" + ); + let (output, _) = custom_tool_output_body_and_success(&request, "call-1"); + assert!( + output.contains("codex-code-mode-host-does-not-exist"), + "code-mode-only must report the host failure: {output}" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_process_host_fails_when_in_process_fallback_is_disabled() -> Result<()> { +async fn missing_process_host_fails_closed_when_direct_fallback_is_disabled() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -276,19 +370,30 @@ async fn missing_process_host_fails_when_in_process_fallback_is_disabled() -> Re .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")); + let (_test, follow_up_mock) = run_code_mode_turn_with_builder( + &server, + "Run required code mode", + "text('unreachable')", + builder, + ) + .await?; + let request = follow_up_mock.single_request(); + let tools = tool_names(&request.body_json()); + assert!( + tools.iter().any(|name| name == "exec") && tools.iter().any(|name| name == "wait"), + "disabled fallback must retain code-mode tools: {tools:?}" + ); + let (output, _) = custom_tool_output_body_and_success(&request, "call-1"); + assert!( + output.contains("codex-code-mode-host-does-not-exist"), + "disabled fallback must report the host failure: {output}" + ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn disabled_process_host_fails_when_in_process_fallback_is_disabled() -> Result<()> { +async fn disabled_process_host_with_fallback_disabled_attempts_the_host() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -305,44 +410,19 @@ async fn disabled_process_host_fails_when_in_process_fallback_is_disabled() -> R .expect("code-mode host should be disabled"); 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("code-mode host is disabled and in-process fallback is disabled")); - - 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"); + let (_test, follow_up_mock) = run_code_mode_turn_with_builder( + &server, + "Run required code mode", + "text('unreachable')", + builder, + ) + .await?; + let request = follow_up_mock.single_request(); + let (output, _) = custom_tool_output_body_and_success(&request, "call-1"); + assert!( + output.contains("failed to spawn code-mode host"), + "disabled fallback must still attempt the standalone host: {output}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 55ee5de21f..8061839b4e 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -231,6 +231,48 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_code_mode_only_selector_fails_closed_when_host_is_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut model = remote_model("test-tool-mode-code-mode-only-host-disabled"); + model.tool_mode = Some(ToolMode::CodeModeOnly); + let response = response_for_remote_model(model, |config| { + config + .features + .disable(Feature::CodeModeHost) + .expect("code-mode host should be disabled"); + }) + .await?; + + let tools = tool_names(&response.body); + assert!( + tools + .iter() + .any(|name| name == codex_code_mode::PUBLIC_TOOL_NAME) + && tools + .iter() + .any(|name| name == codex_code_mode::WAIT_TOOL_NAME), + "code-mode-only must retain code-mode tools: {tools:?}" + ); + assert!( + tools + .iter() + .all(|name| { !matches!(name.as_str(), "shell" | "shell_command" | "exec_command") }), + "code-mode-only must never expose direct shell tools: {tools:?}" + ); + assert!( + response + .warnings + .iter() + .any(|warning| warning.contains("Code mode will fail closed")), + "code-mode-only should explain that it fails closed: {:?}", + response.warnings + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unsupported_code_mode_warning_is_emitted_each_turn() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 02c554ed7e..f4c229572a 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -34,7 +34,7 @@ impl FeatureConfig for CodeModeConfigToml { 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. + /// Keep code mode fail-closed when the standalone host is unavailable. #[serde(skip_serializing_if = "Option::is_none")] pub disable_in_process_fallback: Option, } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 627b241c64..937dd1407a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -94,7 +94,7 @@ pub enum Feature { // Experimental /// Record model-attempted tool calls in internal Responses metadata. ExecutedToolCallMetadata, - /// Enable JavaScript code mode backed by the in-process V8 runtime. + /// Enable JavaScript code mode backed by the standalone host process. CodeMode, /// Use a 30-second default yield timeout for code mode exec calls. CodeModeBufferedExec, diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 61dabb0e02..6af62c3a2e 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -6,6 +6,11 @@ use std::sync::OnceLock; use codex_utils_absolute_path::AbsolutePathBuf; const BIN_DIRNAME: &str = "bin"; +const CODE_MODE_HOST_EXECUTABLE_NAME: &str = if cfg!(windows) { + "codex-code-mode-host.exe" +} else { + "codex-code-mode-host" +}; const PACKAGE_METADATA_FILENAME: &str = "codex-package.json"; const PATH_DIRNAME: &str = "codex-path"; const RELEASES_DIRNAME: &str = "releases"; @@ -147,6 +152,33 @@ impl InstallContext { default_rg_command() } + pub fn code_mode_host_program(&self) -> PathBuf { + self.code_mode_host_program_from_exe(std::env::current_exe().ok().as_deref()) + } + + fn code_mode_host_program_from_exe(&self, current_exe: Option<&Path>) -> PathBuf { + let executable_dir = if let Some(package_layout) = &self.package_layout { + Some(package_layout.bin_dir.clone()) + } else if let InstallMethod::Standalone { release_dir, .. } = &self.method { + Some(release_dir.clone()) + } else { + current_exe + .and_then(Path::parent) + .and_then(canonical_absolute_path) + }; + if let Some(executable_dir) = executable_dir { + let executable = executable_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + if executable.is_file() { + return executable.into_path_buf(); + } + } + + current_exe + .and_then(Path::parent) + .map(|parent| parent.join(CODE_MODE_HOST_EXECUTABLE_NAME)) + .unwrap_or_else(|| PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME)) + } + pub fn bundled_resource(&self, file_name: impl AsRef) -> Option { if let Some(package_layout) = &self.package_layout && let Some(resources_dir) = &package_layout.resources_dir @@ -301,6 +333,7 @@ mod tests { fs::create_dir_all(&resources_dir)?; let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; + fs::write(release_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; fs::write(resources_dir.join(default_rg_command()), "")?; fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; let canonical_release_dir = @@ -318,13 +351,19 @@ mod tests { context, InstallContext { method: InstallMethod::Standalone { - release_dir: canonical_release_dir, + release_dir: canonical_release_dir.clone(), resources_dir: Some(canonical_resources_dir.clone()), platform: standalone_platform(), }, package_layout: None, } ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_release_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); assert_eq!( context.bundled_resource(TEST_RESOURCE_NAME), Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) @@ -364,6 +403,7 @@ mod tests { fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; + fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; fs::write(path_dir.join(default_rg_command()), "")?; if !cfg!(windows) { @@ -379,7 +419,7 @@ mod tests { let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; let package_layout = CodexPackageLayout { package_dir: canonical_package_dir, - bin_dir: canonical_bin_dir, + bin_dir: canonical_bin_dir.clone(), resources_dir: Some(canonical_resources_dir.clone()), path_dir: Some(canonical_path_dir.clone()), }; @@ -397,6 +437,12 @@ mod tests { package_layout: Some(package_layout), } ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); assert_eq!( context.rg_command(), canonical_path_dir @@ -423,6 +469,36 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn code_mode_host_program_accepts_symlinks_to_files() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join("codex"); + let executable_target = package_dir.path().join("host-target"); + let executable_path = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&executable_target, "")?; + std::os::unix::fs::symlink(&executable_target, &executable_path)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + Ok(()) + } + #[test] fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { let codex_home = tempfile::tempdir()?; @@ -537,11 +613,14 @@ mod tests { let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); let path_dir = package_dir.path().join(PATH_DIRNAME); fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?; fs::create_dir_all(path_dir.join(default_rg_command()))?; fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; + let fallback_exe_path = package_dir.path().join("fallback-codex"); + fs::write(&fallback_exe_path, "")?; let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, @@ -549,11 +628,41 @@ mod tests { /*method_override*/ None, /*codex_home*/ None, ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&fallback_exe_path)), + package_dir.path().join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); assert_eq!(context.rg_command(), default_rg_command()); assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None); Ok(()) } + #[test] + fn code_mode_host_program_is_next_to_the_executable_even_when_missing() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(Some(Path::new("/opt/codex/bin/codex"))), + PathBuf::from("/opt/codex/bin").join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + } + + #[test] + fn code_mode_host_program_falls_back_to_its_name_when_executable_is_unknown() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(/*current_exe*/ None), + PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + } + #[test] fn package_manager_method_overrides_take_precedence() { let pnpm_context = InstallContext::from_exe(