Run code mode exclusively through the standalone host (#36217)

## What changed

- Move the V8 implementation into a dedicated `codex-code-mode-runtime` crate used by `codex-code-mode-host`, removing the embedded runtime fallback from the Codex process.
- Resolve the host executable from the active installation layout and check its availability before selecting tools.
- Fall back to direct tools with a one-time warning when optional code mode is unavailable. Keep `code_mode_only` and `disable_in_process_fallback` configurations fail-closed.

## Testing

- Cover host discovery for standalone and package layouts, including missing hosts and symlinks.
- Verify direct-tool fallback, one-time warnings, and fail-closed code-mode-only behavior.

GitOrigin-RevId: 5aa3c6f1db148b2231fc24089a2ee0e2b00dbddb
This commit is contained in:
Channing Conger
2026-07-30 20:24:29 +00:00
committed by copyberry
parent acd540f158
commit 97576b1794
51 changed files with 627 additions and 424 deletions
+18 -3
View File
@@ -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"
+2
View File
@@ -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" }
@@ -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<MockServer>,
_owned_install_dir: Option<TempDir>,
_owned_codex_home: Option<TempDir>,
}
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;
+2 -1
View File
@@ -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 }
+1 -1
View File
@@ -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;
+1
View File
@@ -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;
@@ -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<dyn CodeModeSessionDelegate>,
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "code-mode-runtime",
crate_name = "codex_code_mode_runtime",
)
+31
View File
@@ -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"] }
+12
View File
@@ -0,0 +1,12 @@
mod cell_actor;
mod runtime;
mod service;
mod session_runtime;
mod v8_init;
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
pub use codex_code_mode_protocol::*;
pub use service::InProcessCodeModeSession;
pub use v8_init::V8JitMode;
pub use v8_init::initialize_v8;
@@ -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<dyn CodeModeSessionDelegate>,
) -> CodeModeSessionProviderFuture<'a> {
Box::pin(async move {
let session: Arc<dyn CodeModeSession> =
Arc::new(InProcessCodeModeSession::with_delegate(delegate));
Ok(session)
})
}
}
pub struct InProcessCodeModeSession {
runtime: SessionRuntime<ProtocolDelegate>,
}
@@ -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;
@@ -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;
@@ -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]
+2 -7
View File
@@ -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"] }
+1 -12
View File
@@ -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<dyn Fn(String) + Send + Sync>;
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;
+41 -79
View File
@@ -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<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,
host: Arc<OwnedCodeModeHost>,
}
/// 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<OwnedCodeModeHost>,
}
enum ProviderState {
OwnedProcess(Arc<OwnedCodeModeHost>),
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<Arc<OwnedCodeModeHost>> {
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<OwnedCodeModeHost> {
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<dyn CodeModeSessionDelegate>,
) -> CodeModeSessionProviderFuture<'a> {
Box::pin(async move {
let Some(process_host) = self.process_host() else {
let session: Arc<dyn CodeModeSession> =
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<dyn CodeModeSession> =
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<dyn CodeModeSessionDelegate>,
) -> 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<OsString>,
current_exe: io::Result<PathBuf>,
) -> 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;
@@ -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 {
+5 -92
View File
@@ -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]
+1 -1
View File
@@ -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": {
+1
View File
@@ -1096,6 +1096,7 @@ pub struct Config {
pub struct CodeModeConfig {
pub excluded_tool_namespaces: Vec<String>,
pub direct_only_tool_namespaces: Vec<String>,
/// Keep code mode fail-closed when the standalone host is unavailable.
pub disable_in_process_fallback: bool,
}
+1
View File
@@ -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(),
+5 -5
View File
@@ -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(),
@@ -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,
+19
View File
@@ -115,6 +115,7 @@ pub struct TurnContext {
pub(crate) sub_id: String,
pub(crate) trace_id: Option<String>,
pub(crate) realtime_active: bool,
pub(crate) code_mode_available: bool,
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
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())
{
+11 -34
View File
@@ -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<dyn CodeModeSessionDelegate>,
) -> 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<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 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,
+1 -1
View File
@@ -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<dyn CodeModeSessionProvider> = Arc::new(InProcessCodeModeSessionProvider);
let provider: Arc<dyn CodeModeSessionProvider> = Arc::new(DisabledCodeModeSessionProvider);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
+25 -44
View File
@@ -76,9 +76,11 @@ pub(crate) struct ExecContext {
pub(crate) struct CodeModeService {
session: OnceCell<Arc<dyn CodeModeSession>>,
session_provider: Arc<dyn CodeModeSessionProvider>,
availability: Result<(), String>,
dispatch_broker: Arc<CodeModeDispatchBroker>,
default_exec_yield_time_override_ms: Option<u64>,
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<String> {
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<dyn CodeModeSessionProvider> {
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");
}
}
+13 -1
View File
@@ -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(
@@ -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<ToolInvocation> for TestHandler {
impl CoreToolRuntime for TestHandler {}
struct MissingCellCodeModeSessionProvider;
impl codex_code_mode::CodeModeSessionProvider for MissingCellCodeModeSessionProvider {
fn create_session<'a>(
&'a self,
_delegate: Arc<dyn codex_code_mode::CodeModeSessionDelegate>,
) -> codex_code_mode::CodeModeSessionProviderFuture<'a> {
Box::pin(async {
Ok(Arc::new(MissingCellCodeModeSession) as Arc<dyn codex_code_mode::CodeModeSession>)
})
}
}
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));
+139 -59
View File
@@ -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<String>)> {
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(())
}
@@ -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(()));
+1 -1
View File
@@ -34,7 +34,7 @@ impl FeatureConfig for CodeModeConfigToml {
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.
/// Keep code mode fail-closed when the standalone host is unavailable.
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_in_process_fallback: Option<bool>,
}
+1 -1
View File
@@ -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,
+111 -2
View File
@@ -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<Path>) -> Option<AbsolutePathBuf> {
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(