diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 49a7a3b80..c7f5b9889 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -2178,11 +2178,13 @@ mod tests { let environment_manager = Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"), + Some( + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + ), ) .await, ); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index b62b2bdc4..7b7cbc11c 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -445,9 +445,9 @@ pub async fn run_main_with_transport_options( arg0_paths.codex_linux_sandbox_exe.clone(), )?; let environment_manager = if loader_overrides.ignore_user_config { - EnvironmentManager::from_env(local_runtime_paths).await + EnvironmentManager::from_env(Some(local_runtime_paths)).await } else { - EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await } .map(Arc::new) .map_err(std::io::Error::other)?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4e2c6f38c..fdcd44d01 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; +use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; @@ -165,10 +166,11 @@ pub(crate) struct MessageProcessor { command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, + environment_manager: Arc, environment_processor: EnvironmentRequestProcessor, external_agent_config_processor: ExternalAgentConfigRequestProcessor, feedback_processor: FeedbackRequestProcessor, - fs_processor: FsRequestProcessor, + fs_processor: Option, git_processor: GitRequestProcessor, initialize_processor: InitializeRequestProcessor, marketplace_processor: MarketplaceRequestProcessor, @@ -272,6 +274,23 @@ pub(crate) struct MessageProcessorArgs { } impl MessageProcessor { + fn fs_processor(&self) -> Result<&FsRequestProcessor, JSONRPCErrorError> { + self.fs_processor + .as_ref() + .ok_or_else(|| internal_error("local filesystem is not configured")) + } + + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + // CCA filters these local-only RPCs before they reach app-server, but + // keep a Codex-side backstop so no-local app-server modes fail safely + // if a client still invokes one directly. + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } + /// Create a new `MessageProcessor`, retaining a handle to the outgoing /// `Sender` so handlers can enqueue messages to be written to stdout. pub(crate) fn new(args: MessageProcessorArgs) -> Self { @@ -301,6 +320,7 @@ impl MessageProcessor { // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); + let environment_manager_for_requests = Arc::clone(&environment_manager); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), @@ -443,7 +463,6 @@ impl MessageProcessor { Some(on_effective_plugins_changed), ); } - let fs_watch_manager = FsWatchManager::new(outgoing.clone()); let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), @@ -461,13 +480,17 @@ impl MessageProcessor { ); let environment_processor = EnvironmentRequestProcessor::new(thread_manager.environment_manager()); - let fs_processor = FsRequestProcessor::new( - thread_manager - .environment_manager() - .local_environment() - .get_filesystem(), - fs_watch_manager, - ); + // `fs/*` is a local-host filesystem surface. Do not construct it when + // the manager intentionally has no local environment. + let fs_processor = thread_manager + .environment_manager() + .try_local_environment() + .map(|environment| { + FsRequestProcessor::new( + environment.get_filesystem(), + FsWatchManager::new(outgoing.clone()), + ) + }); let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( outgoing.clone(), Arc::clone(&config), @@ -482,6 +505,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_manager: environment_manager_for_requests, environment_processor, external_agent_config_processor, feedback_processor, @@ -705,7 +729,9 @@ impl MessageProcessor { ) { session_state.rpc_gate.shutdown().await; self.outgoing.connection_closed(connection_id).await; - self.fs_processor.connection_closed(connection_id).await; + if let Some(fs_processor) = &self.fs_processor { + fs_processor.connection_closed(connection_id).await; + } self.command_exec_processor .connection_closed(connection_id) .await; @@ -911,47 +937,47 @@ impl MessageProcessor { self.environment_processor.environment_add(params).await } ClientRequest::FsReadFile { params, .. } => self - .fs_processor + .fs_processor()? .read_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsWriteFile { params, .. } => self - .fs_processor + .fs_processor()? .write_file(params) .await .map(|response| Some(response.into())), ClientRequest::FsCreateDirectory { params, .. } => self - .fs_processor + .fs_processor()? .create_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsGetMetadata { params, .. } => self - .fs_processor + .fs_processor()? .get_metadata(params) .await .map(|response| Some(response.into())), ClientRequest::FsReadDirectory { params, .. } => self - .fs_processor + .fs_processor()? .read_directory(params) .await .map(|response| Some(response.into())), ClientRequest::FsRemove { params, .. } => self - .fs_processor + .fs_processor()? .remove(params) .await .map(|response| Some(response.into())), ClientRequest::FsCopy { params, .. } => self - .fs_processor + .fs_processor()? .copy(params) .await .map(|response| Some(response.into())), ClientRequest::FsWatch { params, .. } => self - .fs_processor + .fs_processor()? .watch(connection_id, params) .await .map(|response| Some(response.into())), ClientRequest::FsUnwatch { params, .. } => self - .fs_processor + .fs_processor()? .unwatch(connection_id, params) .await .map(|response| Some(response.into())), @@ -1280,6 +1306,7 @@ impl MessageProcessor { .await .map(|response| Some(response.into())), ClientRequest::OneOffCommandExec { params, .. } => { + self.require_local_environment()?; self.command_exec_processor .one_off_command_exec(&request_id, params) .await @@ -1299,11 +1326,13 @@ impl MessageProcessor { .command_exec_terminate(request_id.clone(), params) .await } - ClientRequest::ProcessSpawn { params, .. } => self - .process_exec_processor - .process_spawn(request_id.clone(), params) - .await - .map(|()| None), + ClientRequest::ProcessSpawn { params, .. } => { + self.require_local_environment()?; + self.process_exec_processor + .process_spawn(request_id.clone(), params) + .await + .map(|()| None) + } ClientRequest::ProcessWriteStdin { params, .. } => { self.process_exec_processor .process_write_stdin(request_id.clone(), params) diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 548bb5be8..4bb9431f4 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -205,17 +205,14 @@ impl McpRequestProcessor { .await; let auth = self.auth_manager.auth().await; let environment_manager = self.thread_manager.environment_manager(); - let runtime_environment = match environment_manager.default_environment() { - Some(environment) => { - // Status listing has no turn cwd. This fallback is used only - // by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - } - None => McpRuntimeEnvironment::new( - environment_manager.local_environment(), - config.cwd.to_path_buf(), - ), - }; + // Status listing has no turn cwd. Prefer the configured default env, + // then configured local if present; do not manufacture a hidden local + // env in no-local modes. + let runtime_environment = McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ); tokio::spawn(async move { Self::list_mcp_server_status_task( @@ -369,15 +366,15 @@ impl McpRequestProcessor { .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; - let runtime_environment = { - let environment_manager = self.thread_manager.environment_manager(); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - // Resource reads without a thread have no turn cwd. This fallback - // is used only by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - }; + let environment_manager = self.thread_manager.environment_manager(); + // Resource reads without a thread have no turn cwd. Prefer the + // configured default env, then configured local if present; do not + // manufacture a hidden local env in no-local modes. + let runtime_environment = McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ); let request_id = request_id.clone(); tokio::spawn(async move { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index dd2dd6a57..f89f4a650 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1738,6 +1738,16 @@ impl ThreadRequestProcessor { if command.is_empty() { return Err(invalid_request("command must not be empty")); } + // `thread/shellCommand` is app-server's local-host shell escape hatch, + // not the normal turn-selected shell tool path. + if self + .thread_manager + .environment_manager() + .try_local_environment() + .is_none() + { + return Err(internal_error("local environment is not configured")); + } let (_, thread) = self.load_thread(&thread_id).await?; self.submit_core_op( diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index b4038299c..9757767c9 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -400,6 +401,45 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu Ok(()) } +#[tokio::test] +async fn command_exec_returns_error_when_local_environment_is_disabled() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index a780a51e0..3ca511a48 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -119,6 +120,28 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let absolute_file = codex_home.path().join("absolute.txt"); + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(absolute_file), + }) + .await?; + expect_error_message(&mut mcp, read_id, "local filesystem is not configured").await?; + + Ok(()) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_reports_symlink() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index 5dd3e84b4..d273257e6 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::ProcessExitedNotification; use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -102,6 +103,33 @@ async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Resu Ok(()) } +#[tokio::test] +async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_request_id = mcp + .send_process_spawn_request(process_spawn_params( + "disabled-process".to_string(), + codex_home.path(), + vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + )?) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(process_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index b7cfba2f9..4ab677aca 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::shell::default_user_shell; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::FEATURES; use codex_features::Feature; use pretty_assertions::assert_eq; @@ -182,6 +183,49 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut Ok(()) } +#[tokio::test] +async fn thread_shell_command_returns_error_when_local_environment_is_disabled() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let server = create_mock_responses_server_sequence(vec![]).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new_with_env( + codex_home.as_path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id, + command: "pwd".to_string(), + }) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(shell_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let tmp = TempDir::new()?; diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 50b1bf6c1..952bc8207 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -847,6 +847,112 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { assert_eq!(tool.server_origin.as_deref(), Some("https://docs.example")); } +#[tokio::test] +async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let (tx_event, rx_event) = async_channel::unbounded(); + drop(rx_event); + let codex_home = tempdir().expect("tempdir"); + let mcp_servers = HashMap::from([ + ( + "stdio".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ( + "http".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ]); + + let (manager, cancel_token) = McpConnectionManager::new( + &mcp_servers, + OAuthCredentialsStoreMode::default(), + HashMap::new(), + &approval_policy, + String::new(), + tx_event, + PermissionProfile::default(), + McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ), + codex_home.path().to_path_buf(), + CodexAppsToolsCacheKey { + account_id: None, + chatgpt_user_id: None, + is_workspace_account: false, + }, + /*host_owned_codex_apps_enabled*/ false, + ElicitationCapability::default(), + ToolPluginProvenance::default(), + /*auth*/ None, + /*elicitation_reviewer*/ None, + ) + .await; + + assert!(manager.clients.contains_key("stdio")); + assert!(manager.clients.contains_key("http")); + assert!( + !manager + .wait_for_server_ready("stdio", Duration::from_millis(10)) + .await + ); + let failures = manager + .required_startup_failures(&["stdio".to_string()]) + .await; + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].server, "stdio"); + assert_eq!( + failures[0].error, + "local stdio MCP server `stdio` requires a local environment" + ); + cancel_token.cancel(); +} + #[test] fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() { let capability = Some(ElicitationCapability::default()); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index fcc9d1c86..687576dfa 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -566,6 +566,9 @@ async fn make_rmcp_client( let config = match server.launch() { McpServerLaunch::Configured(config) => config.as_ref().clone(), }; + if let Some(reason) = runtime_environment.startup_unavailable_reason(server_name, &config) { + return Err(StartupOutcomeError::from(anyhow!(reason))); + } let McpServerConfig { transport, experimental_environment, @@ -573,14 +576,7 @@ async fn make_rmcp_client( } = config; let remote_environment = match experimental_environment.as_deref() { None | Some("local") => false, - Some("remote") => { - if !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote environment" - ))); - } - true - } + Some("remote") => true, Some(environment) => { return Err(StartupOutcomeError::from(anyhow!( "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" @@ -604,8 +600,15 @@ async fn make_rmcp_client( .collect::>() }); let launcher = if remote_environment { + // Preflight should reject this first, but keep client startup + // defensive if optional runtime placement is mis-threaded. + let Some(environment) = runtime_environment.environment() else { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server requires a runtime environment" + ))); + }; Arc::new(ExecutorStdioServerLauncher::new( - runtime_environment.environment().get_exec_backend(), + environment.get_exec_backend(), runtime_environment.fallback_cwd(), )) } else { @@ -628,7 +631,12 @@ async fn make_rmcp_client( bearer_token_env_var, } => { let http_client: Arc = if remote_environment { - runtime_environment.environment().get_http_client() + let Some(environment) = runtime_environment.environment() else { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server requires a runtime environment" + ))); + }; + environment.get_http_client() } else { Arc::new(ReqwestHttpClient) }; diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 4284c96ff..dc1fcec21 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -30,33 +30,79 @@ pub struct SandboxState { /// Runtime placement information used when starting MCP server transports. /// -/// `McpConfig` describes what servers exist. This value describes where those -/// servers should run for the current caller. Keep it explicit at manager -/// construction time so status/snapshot paths and real sessions make the same -/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is -/// used when a stdio server omits `cwd` and the launcher needs a concrete -/// process working directory. +/// `McpConfig` describes what servers exist. This value describes which +/// selected/default environment MCP servers should use for the current caller. +/// Keep it explicit at manager construction time so status/snapshot paths and +/// real sessions make the same placement decision. `fallback_cwd` is not a +/// per-server override; it is used when a stdio server omits `cwd` and the +/// launcher needs a concrete process working directory. `local_environment` +/// is separate because a remote selected/default environment can coexist with +/// an explicitly configured local environment that may launch local stdio MCPs. #[derive(Clone)] pub struct McpRuntimeEnvironment { - environment: Arc, + environment: Option>, + local_environment: Option>, fallback_cwd: PathBuf, } impl McpRuntimeEnvironment { - pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { + pub fn new( + environment: Option>, + local_environment: Option>, + fallback_cwd: PathBuf, + ) -> Self { Self { environment, + local_environment, fallback_cwd, } } - pub(crate) fn environment(&self) -> Arc { - Arc::clone(&self.environment) + pub(crate) fn environment(&self) -> Option> { + self.environment.as_ref().map(Arc::clone) } pub(crate) fn fallback_cwd(&self) -> PathBuf { self.fallback_cwd.clone() } + + pub(crate) fn startup_unavailable_reason( + &self, + server_name: &str, + config: &codex_config::McpServerConfig, + ) -> Option { + // This is intentionally narrower than "no env means no MCP": local + // stdio needs a local process launcher, while local HTTP can still use + // the ambient HTTP client with no local environment configured. + match config.experimental_environment.as_deref() { + None | Some("local") => { + // Local stdio only needs an explicitly configured local + // launcher. The selected/default MCP environment can be remote + // when both local and remote environments are configured. + if self.local_environment.is_none() + && matches!( + config.transport, + codex_config::McpServerTransportConfig::Stdio { .. } + ) + { + Some(format!( + "local stdio MCP server `{server_name}` requires a local environment" + )) + } else { + None + } + } + Some("remote") => match self.environment.as_ref() { + Some(environment) if environment.is_remote() => None, + _ => Some(format!( + "remote MCP server `{server_name}` requires a remote environment" + )), + }, + Some(environment) => Some(format!( + "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" + )), + } + } } pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { @@ -64,3 +110,156 @@ pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &st let _ = metrics.record_duration(metric, duration, tags); } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use codex_config::McpServerConfig; + use codex_config::McpServerTransportConfig; + use pretty_assertions::assert_eq; + + use super::*; + + fn stdio_server(experimental_environment: Option<&str>) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: experimental_environment.map(str::to_string), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + } + } + + fn http_server(experimental_environment: Option<&str>) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + experimental_environment: experimental_environment.map(str::to_string), + ..stdio_server(/*experimental_environment*/ None) + } + } + + #[test] + fn local_stdio_requires_local_stdio_availability() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ None) + ), + Some("local stdio MCP server `stdio` requires a local environment".to_string()) + ); + } + + #[test] + fn local_http_does_not_require_local_stdio_availability() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "http", + &http_server(/*experimental_environment*/ None) + ), + None + ); + } + + #[test] + fn remote_stdio_requires_remote_environment() { + let runtime_environment = McpRuntimeEnvironment::new( + /*environment*/ None, + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ Some("remote")), + ), + Some("remote MCP server `stdio` requires a remote environment".to_string()) + ); + } + + #[test] + fn remote_stdio_and_http_accept_remote_environment() { + let environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + ); + let runtime_environment = McpRuntimeEnvironment::new( + Some(environment), + /*local_environment*/ None, + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ Some("remote")), + ), + None + ); + assert_eq!( + runtime_environment.startup_unavailable_reason( + "http", + &http_server(/*experimental_environment*/ Some("remote")), + ), + None + ); + } + + #[tokio::test] + async fn local_stdio_accepts_remote_runtime_when_local_environment_exists() { + let remote_environment = Arc::new( + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + ); + let local_environment = Arc::new( + Environment::create_for_tests(/*exec_server_url*/ None).expect("local environment"), + ); + let runtime_environment = McpRuntimeEnvironment::new( + Some(remote_environment), + Some(local_environment), + PathBuf::from("/tmp"), + ); + + assert_eq!( + runtime_environment.startup_unavailable_reason( + "stdio", + &stdio_server(/*experimental_environment*/ None), + ), + None + ); + } +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f4ee42c82..de41919a8 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -198,7 +198,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?; + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, @@ -261,10 +262,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let (tx_event, rx_event) = unbounded(); drop(rx_event); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -273,7 +270,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( INITIAL_SUBMIT_ID.to_owned(), tx_event, PermissionProfile::default(), - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), + // Connector discovery is threadless. Use an actually configured env if + // one exists, but do not reintroduce the old hidden-local fallback. + McpRuntimeEnvironment::new( + environment_manager.default_or_local_environment(), + environment_manager.try_local_environment(), + config.cwd.to_path_buf(), + ), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), host_owned_codex_apps_enabled, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 89808c27e..749212f70 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -106,7 +106,7 @@ mod tests { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); let manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -132,9 +132,10 @@ url = "ws://127.0.0.1:8765" ) .expect("write environments.toml"); let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths()) - .await - .expect("environment manager"); + let manager = + EnvironmentManager::from_codex_home(temp_dir.path(), Some(test_runtime_paths())) + .await + .expect("environment manager"); assert_eq!( default_thread_environment_selections(&manager, &cwd), @@ -154,7 +155,7 @@ url = "ws://127.0.0.1:8765" #[tokio::test] async fn default_thread_environment_selections_empty_when_default_disabled() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); assert_eq!( default_thread_environment_selections(&manager, &cwd), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 25f19c87d..50a6bb4d4 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1235,8 +1235,8 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: let environment = session .services .environment_manager - .default_environment() - .unwrap_or_else(|| session.services.environment_manager.local_environment()); + .default_or_local_environment() + .expect("test session should have an MCP runtime environment"); let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new( &HashMap::new(), turn_context.config.mcp_oauth_credentials_store_mode, @@ -1245,10 +1245,14 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: turn_context.sub_id.clone(), session.get_tx_event(), turn_context.permission_profile(), - codex_mcp::McpRuntimeEnvironment::new(environment, { - #[allow(deprecated)] - turn_context.cwd.to_path_buf() - }), + codex_mcp::McpRuntimeEnvironment::new( + Some(environment), + session.services.environment_manager.try_local_environment(), + { + #[allow(deprecated)] + turn_context.cwd.to_path_buf() + }, + ), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), /*host_owned_codex_apps_enabled*/ true, diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index c6386d7c7..f6bf7d002 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -45,9 +45,12 @@ pub async fn build_prompt_input( Arc::clone(&auth_manager), SessionSource::Exec, Arc::new( - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths) - .await - .map_err(|err| CodexErr::Fatal(err.to_string()))?, + EnvironmentManager::from_codex_home( + config.codex_home.clone(), + Some(local_runtime_paths), + ) + .await + .map_err(|err| CodexErr::Fatal(err.to_string()))?, ), empty_extension_registry(), /*analytics_events_client*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 9f27751f7..47201414d 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -291,14 +291,15 @@ impl Session { compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; let mcp_runtime_environment = match turn_context.environments.primary() { Some(turn_environment) => McpRuntimeEnvironment::new( - Arc::clone(&turn_environment.environment), + Some(Arc::clone(&turn_environment.environment)), + self.services.environment_manager.try_local_environment(), turn_environment.cwd.to_path_buf(), ), None => McpRuntimeEnvironment::new( self.services .environment_manager - .default_environment() - .unwrap_or_else(|| self.services.environment_manager.local_environment()), + .default_or_local_environment(), + self.services.environment_manager.try_local_environment(), #[allow(deprecated)] turn_context.cwd.to_path_buf(), ), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2d96cf916..884c440f2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1050,14 +1050,13 @@ impl Session { .cloned(); let mcp_runtime_environment = match turn_environment { Some(turn_environment) => McpRuntimeEnvironment::new( - Arc::clone(&turn_environment.environment), + Some(Arc::clone(&turn_environment.environment)), + sess.services.environment_manager.try_local_environment(), turn_environment.cwd.to_path_buf(), ), None => McpRuntimeEnvironment::new( - sess.services - .environment_manager - .default_environment() - .unwrap_or_else(|| sess.services.environment_manager.local_environment()), + sess.services.environment_manager.default_or_local_environment(), + sess.services.environment_manager.try_local_environment(), session_configuration.cwd.to_path_buf(), ), }; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 43ec706f1..255a8336b 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -309,7 +309,7 @@ async fn start_thread_rejects_explicit_local_environment_when_default_provider_i let environment_manager = Arc::new( codex_exec_server::EnvironmentManager::create_for_tests( Some("none".to_string()), - runtime_paths, + Some(runtime_paths), ) .await, ); @@ -373,7 +373,7 @@ args = ["dev", "cd /tmp && true"] let environment_manager = Arc::new( codex_exec_server::EnvironmentManager::from_codex_home( config.codex_home.clone(), - runtime_paths, + Some(runtime_paths), ) .await .expect("environment manager"), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a1ba7b8fb..5f5c7ee85 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -431,7 +431,7 @@ impl TestCodexBuilder { } else { codex_exec_server::EnvironmentManager::create_for_tests( exec_server_url, - local_runtime_paths, + Some(local_runtime_paths), ) .await }); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 1d7d6e75f..55dc03127 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -27,12 +27,11 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// /// `EnvironmentManager` is a shared registry for concrete environments. Its /// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior -/// while provider-based construction accepts a provider-supplied snapshot. +/// while configured construction accepts a provider-supplied snapshot. /// /// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving -/// the default environment unset while still keeping an explicit local -/// environment available through `local_environment()`. Callers use -/// `default_environment().is_some()` as the signal for model-facing +/// the default environment unset and omitting the local environment. Callers +/// use `default_environment().is_some()` as the signal for model-facing /// shell/filesystem tool availability. /// /// Remote environments create remote filesystem and execution backends that @@ -42,25 +41,13 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; pub struct EnvironmentManager { default_environment: Option, environments: RwLock>>, - local_environment: Arc, + local_environment: Option>, + local_runtime_paths: Option, } pub const LOCAL_ENVIRONMENT_ID: &str = "local"; pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; -#[derive(Clone, Debug)] -pub struct EnvironmentManagerArgs { - pub local_runtime_paths: ExecServerRuntimePaths, -} - -impl EnvironmentManagerArgs { - pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self { - Self { - local_runtime_paths, - } - } -} - impl EnvironmentManager { /// Builds a test-only manager without configured sandbox helper paths. pub fn default_for_tests() -> Self { @@ -70,37 +57,29 @@ impl EnvironmentManager { LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), )])), - local_environment: Arc::new(Environment::default_for_tests()), + local_environment: Some(Arc::new(Environment::default_for_tests())), + local_runtime_paths: None, } } - /// Builds a test-only manager with environment access disabled. - pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self { + /// Builds a manager with no configured execution environments. + pub fn without_environments() -> Self { Self { default_environment: None, environments: RwLock::new(HashMap::new()), - local_environment: Arc::new(Environment::local(local_runtime_paths)), + local_environment: None, + local_runtime_paths: None, } } /// Builds a test-only manager from a raw exec-server URL value. pub async fn create_for_tests( exec_server_url: Option, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Self { Self::from_default_provider_url(exec_server_url, local_runtime_paths).await } - /// Builds a manager from `CODEX_EXEC_SERVER_URL` and local runtime paths - /// used when creating local filesystem helpers. - pub async fn new(args: EnvironmentManagerArgs) -> Self { - let EnvironmentManagerArgs { - local_runtime_paths, - } = args; - let exec_server_url = std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(); - Self::from_default_provider_url(exec_server_url, local_runtime_paths).await - } - /// Builds a manager from `CODEX_HOME` and local runtime paths used when /// creating local filesystem helpers. /// @@ -109,27 +88,27 @@ impl EnvironmentManager { /// `CODEX_EXEC_SERVER_URL` behavior. pub async fn from_codex_home( codex_home: impl AsRef, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let provider = environment_provider_from_codex_home(codex_home.as_ref())?; - Self::from_provider(provider.as_ref(), local_runtime_paths).await + Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } /// Builds a manager from the legacy environment-variable provider without /// reading user config files from `CODEX_HOME`. pub async fn from_env( - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let provider = DefaultEnvironmentProvider::from_env(); - Self::from_provider(&provider, local_runtime_paths).await + Self::from_snapshot(provider.snapshot().await?, local_runtime_paths) } async fn from_default_provider_url( exec_server_url: Option, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Self { let provider = DefaultEnvironmentProvider::new(exec_server_url); - match Self::from_provider(&provider, local_runtime_paths).await { + match Self::from_snapshot(provider.snapshot_inner(), local_runtime_paths) { Ok(manager) => manager, Err(err) => panic!("default provider should create valid environments: {err}"), } @@ -143,26 +122,15 @@ impl EnvironmentManager { ) -> Self { let mut snapshot = DefaultEnvironmentProvider::new(exec_server_url).snapshot_inner(); snapshot.include_local = true; - match Self::from_provider_snapshot(snapshot, local_runtime_paths) { + match Self::from_snapshot(snapshot, Some(local_runtime_paths)) { Ok(manager) => manager, Err(err) => panic!("test provider with local should create valid environments: {err}"), } } - /// Builds a manager from a provider-supplied startup snapshot. - pub async fn from_provider

( - provider: &P, - local_runtime_paths: ExecServerRuntimePaths, - ) -> Result - where - P: EnvironmentProvider + ?Sized, - { - Self::from_provider_snapshot(provider.snapshot().await?, local_runtime_paths) - } - - fn from_provider_snapshot( + fn from_snapshot( snapshot: EnvironmentProviderSnapshot, - local_runtime_paths: ExecServerRuntimePaths, + local_runtime_paths: Option, ) -> Result { let EnvironmentProviderSnapshot { environments, @@ -171,13 +139,21 @@ impl EnvironmentManager { } = snapshot; let mut environment_map = HashMap::with_capacity(environments.len() + usize::from(include_local)); - let local_environment = Arc::new(Environment::local(local_runtime_paths)); - if include_local { + let local_environment = if include_local { + let local_runtime_paths = local_runtime_paths.clone().ok_or_else(|| { + ExecServerError::Protocol( + "local environment requires configured runtime paths".to_string(), + ) + })?; + let local_environment = Arc::new(Environment::local(local_runtime_paths)); environment_map.insert( LOCAL_ENVIRONMENT_ID.to_string(), Arc::clone(&local_environment), ); - } + Some(local_environment) + } else { + None + }; for (id, environment) in environments { if id.is_empty() { return Err(ExecServerError::Protocol( @@ -213,6 +189,7 @@ impl EnvironmentManager { default_environment, environments: RwLock::new(environment_map), local_environment, + local_runtime_paths, }) } @@ -248,9 +225,15 @@ impl EnvironmentManager { environment_ids } - /// Returns the local environment instance used for internal runtime work. - pub fn local_environment(&self) -> Arc { - Arc::clone(&self.local_environment) + /// Returns the local environment instance when one is configured. + pub fn try_local_environment(&self) -> Option> { + self.local_environment.as_ref().map(Arc::clone) + } + + /// Returns the default environment or local environment when either exists. + pub fn default_or_local_environment(&self) -> Option> { + self.default_environment() + .or_else(|| self.try_local_environment()) } /// Returns a named environment instance. @@ -285,10 +268,8 @@ impl EnvironmentManager { "remote environment requires an exec-server url".to_string(), )); }; - let environment = Environment::remote_inner( - exec_server_url, - self.local_environment.local_runtime_paths.clone(), - ); + let environment = + Environment::remote_inner(exec_server_url, self.local_runtime_paths.clone()); self.environments .write() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -452,25 +433,12 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; - use crate::EnvironmentProvider; - use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; use pretty_assertions::assert_eq; - struct TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot, - } - - #[async_trait::async_trait] - impl EnvironmentProvider for TestEnvironmentProvider { - async fn snapshot(&self) -> Result { - Ok(self.snapshot.clone()) - } - } - fn test_runtime_paths() -> ExecServerRuntimePaths { ExecServerRuntimePaths::new( std::env::current_exe().expect("current exe"), @@ -479,6 +447,10 @@ mod tests { .expect("runtime paths") } + fn assert_local_environment_unavailable(manager: &EnvironmentManager) { + assert!(manager.try_local_environment().is_none()); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) @@ -491,7 +463,8 @@ mod tests { #[tokio::test] async fn environment_manager_normalizes_empty_url() { let manager = - EnvironmentManager::create_for_tests(Some(String::new()), test_runtime_paths()).await; + EnvironmentManager::create_for_tests(Some(String::new()), Some(test_runtime_paths())) + .await; let environment = manager.default_environment().expect("default environment"); assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID)); @@ -501,18 +474,22 @@ mod tests { .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") )); - assert!(Arc::ptr_eq(&environment, &manager.local_environment())); + assert!(Arc::ptr_eq( + &environment, + &manager.try_local_environment().expect("local environment") + )); + assert!(manager.try_local_environment().is_some()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); assert!(!environment.is_remote()); } #[tokio::test] - async fn disabled_environment_manager_has_no_default_but_keeps_explicit_local_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + async fn disabled_environment_manager_has_no_default_or_local_environment() { + let manager = EnvironmentManager::without_environments(); assert!(manager.default_environment().is_none()); assert_eq!(manager.default_environment_id(), None); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -521,7 +498,7 @@ mod tests { async fn environment_manager_reports_remote_url() { let manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -539,7 +516,7 @@ mod tests { .expect("remote environment") )); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); } #[tokio::test] @@ -557,20 +534,17 @@ mod tests { } #[tokio::test] - async fn environment_manager_builds_from_provider() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - REMOTE_ENVIRONMENT_ID.to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), - include_local: false, - }, + async fn environment_manager_builds_from_snapshot() { + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + REMOTE_ENVIRONMENT_ID.to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), + include_local: false, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("environment manager"); assert_eq!( @@ -584,20 +558,17 @@ mod tests { .is_remote() ); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); } #[tokio::test] async fn environment_manager_rejects_empty_environment_id() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![("".to_string(), Environment::default_for_tests())], - default: EnvironmentDefault::Disabled, - include_local: false, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![("".to_string(), Environment::default_for_tests())], + default: EnvironmentDefault::Disabled, + include_local: false, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("empty id should fail"); assert_eq!( @@ -608,18 +579,15 @@ mod tests { #[tokio::test] async fn environment_manager_rejects_provider_supplied_local_environment() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), - )], - default: EnvironmentDefault::Disabled, - include_local: false, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + LOCAL_ENVIRONMENT_ID.to_string(), + Environment::default_for_tests(), + )], + default: EnvironmentDefault::Disabled, + include_local: false, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("local id should fail"); assert_eq!( @@ -630,19 +598,16 @@ mod tests { #[tokio::test] async fn environment_manager_uses_explicit_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId("devbox".to_string()), - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId("devbox".to_string()), + include_local: true, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("manager"); assert_eq!(manager.default_environment_id(), Some("devbox")); @@ -655,19 +620,16 @@ mod tests { #[tokio::test] async fn environment_manager_disables_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::Disabled, - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::Disabled, + include_local: true, }; - let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect("manager"); assert_eq!(manager.default_environment_id(), None); @@ -676,25 +638,22 @@ mod tests { &manager .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment"), - &manager.local_environment() + &manager.try_local_environment().expect("local environment") )); } #[tokio::test] async fn environment_manager_rejects_unknown_provider_default() { - let provider = TestEnvironmentProvider { - snapshot: EnvironmentProviderSnapshot { - environments: vec![( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - )], - default: EnvironmentDefault::EnvironmentId("missing".to_string()), - include_local: true, - }, + let snapshot = EnvironmentProviderSnapshot { + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], + default: EnvironmentDefault::EnvironmentId("missing".to_string()), + include_local: true, }; - let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) - .await + let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths())) .expect_err("unknown default should fail"); assert_eq!( @@ -707,7 +666,7 @@ mod tests { async fn environment_manager_includes_local_for_default_provider_without_url() { let manager = EnvironmentManager::create_for_tests( /*exec_server_url*/ None, - test_runtime_paths(), + Some(test_runtime_paths()), ) .await; @@ -719,7 +678,10 @@ mod tests { .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") )); - assert!(Arc::ptr_eq(&environment, &manager.local_environment())); + assert!(Arc::ptr_eq( + &environment, + &manager.try_local_environment().expect("local environment") + )); assert!(!environment.is_remote()); } @@ -728,44 +690,59 @@ mod tests { let runtime_paths = test_runtime_paths(); let manager = EnvironmentManager::create_for_tests( /*exec_server_url*/ None, - runtime_paths.clone(), + Some(runtime_paths.clone()), ) .await; - let environment = manager.local_environment(); + let environment = manager.try_local_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::create_for_tests( environment.exec_server_url().map(str::to_owned), - environment - .local_runtime_paths() - .expect("local runtime paths") - .clone(), + Some( + environment + .local_runtime_paths() + .expect("local runtime paths") + .clone(), + ), ) .await; - let environment = manager.local_environment(); + let environment = manager.try_local_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } - #[tokio::test] - async fn disabled_environment_manager_has_no_default_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); - - assert!(manager.default_environment().is_none()); - assert_eq!(manager.default_environment_id(), None); - } - #[tokio::test] async fn environment_manager_omits_default_provider_local_lookup_when_default_disabled() { - let manager = - EnvironmentManager::create_for_tests(Some("none".to_string()), test_runtime_paths()) - .await; + let manager = EnvironmentManager::create_for_tests( + Some("none".to_string()), + Some(test_runtime_paths()), + ) + .await; assert!(manager.default_environment().is_none()); assert_eq!(manager.default_environment_id(), None); assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); - assert!(!manager.local_environment().is_remote()); + assert_local_environment_unavailable(&manager); + } + + #[tokio::test] + async fn environment_manager_snapshot_without_local_environment_disables_local_default() { + let mut snapshot = EnvironmentProviderSnapshot { + environments: Vec::new(), + default: EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()), + include_local: true, + }; + snapshot.include_local = false; + snapshot.default = EnvironmentDefault::Disabled; + let manager = + EnvironmentManager::from_snapshot(snapshot, /*local_runtime_paths*/ None) + .expect("environment manager"); + + assert!(manager.default_environment().is_none()); + assert_eq!(manager.default_environment_id(), None); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); + assert_local_environment_unavailable(&manager); } #[tokio::test] @@ -777,7 +754,7 @@ mod tests { #[tokio::test] async fn environment_manager_upserts_named_remote_environment() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); manager .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:8765".to_string()) @@ -802,7 +779,7 @@ mod tests { #[tokio::test] async fn environment_manager_rejects_empty_remote_environment_url() { - let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + let manager = EnvironmentManager::without_environments(); let err = manager .upsert_environment("executor-a".to_string(), String::new()) diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 90f4c7826..26c178b5b 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -27,6 +27,7 @@ const MAX_ENVIRONMENT_ID_LEN: usize = 64; #[serde(deny_unknown_fields)] struct EnvironmentsToml { default: Option, + include_local: Option, #[serde(default)] environments: Vec, @@ -50,6 +51,7 @@ struct EnvironmentToml { #[derive(Clone, Debug, PartialEq, Eq)] struct TomlEnvironmentProvider { default: EnvironmentDefault, + include_local: bool, environments: Vec<(String, ExecServerTransportParams)>, } @@ -63,21 +65,31 @@ impl TomlEnvironmentProvider { config: EnvironmentsToml, config_dir: Option<&Path>, ) -> Result { - let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]); - let mut environments = Vec::with_capacity(config.environments.len()); - for item in config.environments { + let EnvironmentsToml { + default, + include_local, + environments, + } = config; + let include_local = include_local.unwrap_or(true); + let mut ids = HashSet::new(); + if include_local { + ids.insert(LOCAL_ENVIRONMENT_ID.to_string()); + } + let mut parsed_environments = Vec::with_capacity(environments.len()); + for item in environments { let (id, transport) = parse_environment_toml(item, config_dir)?; if !ids.insert(id.clone()) { return Err(ExecServerError::Protocol(format!( "environment id `{id}` is duplicated" ))); } - environments.push((id, transport)); + parsed_environments.push((id, transport)); } - let default = normalize_default_environment_id(config.default.as_deref(), &ids)?; + let default = normalize_default_environment_id(default.as_deref(), include_local, &ids)?; Ok(Self { default, - environments, + include_local, + environments: parsed_environments, }) } } @@ -99,7 +111,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider { Ok(EnvironmentProviderSnapshot { environments, default: self.default.clone(), - include_local: true, + include_local: self.include_local, }) } } @@ -212,12 +224,17 @@ pub(crate) fn environment_provider_from_codex_home( fn normalize_default_environment_id( default: Option<&str>, + include_local: bool, ids: &HashSet, ) -> Result { let Some(default) = default.map(str::trim) else { - return Ok(EnvironmentDefault::EnvironmentId( - LOCAL_ENVIRONMENT_ID.to_string(), - )); + return if include_local { + Ok(EnvironmentDefault::EnvironmentId( + LOCAL_ENVIRONMENT_ID.to_string(), + )) + } else { + Ok(EnvironmentDefault::Disabled) + }; }; if default.is_empty() { return Err(ExecServerError::Protocol( @@ -330,6 +347,7 @@ mod tests { async fn toml_provider_includes_local_and_adds_configured_environments() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -396,6 +414,7 @@ mod tests { async fn toml_provider_default_none_disables_default() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("none".to_string()), + include_local: None, environments: Vec::new(), }) .expect("provider"); @@ -405,6 +424,55 @@ mod tests { assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } + #[tokio::test] + async fn toml_provider_can_disable_local_environment() { + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + default: Some("ssh-dev".to_string()), + include_local: Some(false), + environments: vec![EnvironmentToml { + id: "ssh-dev".to_string(), + program: Some("ssh".to_string()), + ..Default::default() + }], + }) + .expect("provider"); + let snapshot = provider.snapshot().await.expect("environments"); + + assert!(!snapshot.include_local); + assert_eq!( + snapshot.default, + EnvironmentDefault::EnvironmentId("ssh-dev".to_string()) + ); + } + + #[tokio::test] + async fn toml_provider_without_local_and_default_omitted_disables_default() { + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + include_local: Some(false), + ..Default::default() + }) + .expect("provider"); + let snapshot = provider.snapshot().await.expect("environments"); + + assert!(!snapshot.include_local); + assert_eq!(snapshot.default, EnvironmentDefault::Disabled); + } + + #[test] + fn toml_provider_rejects_local_default_when_local_is_disabled() { + let err = TomlEnvironmentProvider::new(EnvironmentsToml { + default: Some(LOCAL_ENVIRONMENT_ID.to_string()), + include_local: Some(false), + environments: Vec::new(), + }) + .expect_err("local default without local environment should fail"); + + assert_eq!( + err.to_string(), + "exec-server protocol error: default environment `local` is not configured" + ); + } + #[test] fn toml_provider_rejects_invalid_environments() { let cases = [ @@ -479,6 +547,7 @@ mod tests { for (item, expected) in cases { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![item], }) .expect_err("invalid item should fail"); @@ -496,6 +565,7 @@ mod tests { let provider = TomlEnvironmentProvider::new_with_config_dir( EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), @@ -525,6 +595,7 @@ mod tests { fn toml_provider_parses_configured_transport_timeouts() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -569,6 +640,7 @@ mod tests { fn toml_provider_rejects_relative_stdio_cwd_without_config_dir() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), @@ -588,6 +660,7 @@ mod tests { fn toml_provider_rejects_duplicate_ids() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), @@ -614,6 +687,7 @@ mod tests { let id = "a".repeat(MAX_ENVIRONMENT_ID_LEN + 1); let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: id.clone(), url: Some("ws://127.0.0.1:8765".to_string()), @@ -634,6 +708,7 @@ mod tests { fn toml_provider_rejects_unknown_default() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("missing".to_string()), + include_local: None, environments: Vec::new(), }) .expect_err("unknown default should fail"); @@ -652,6 +727,7 @@ mod tests { &path, r#" default = "ssh-dev" +include_local = false [[environments]] id = "devbox" @@ -673,6 +749,7 @@ CODEX_LOG = "debug" let environments = load_environments_toml(&path).expect("environments.toml"); assert_eq!(environments.default.as_deref(), Some("ssh-dev")); + assert_eq!(environments.include_local, Some(false)); assert_eq!(environments.environments.len(), 2); assert_eq!( environments.environments[0], @@ -736,6 +813,7 @@ unknown = true fn toml_provider_rejects_malformed_websocket_url() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, + include_local: None, environments: vec![EnvironmentToml { id: "devbox".to_string(), url: Some("ws://".to_string()), @@ -758,6 +836,7 @@ unknown = true codex_home.path().join(ENVIRONMENTS_TOML_FILE), r#" default = "none" +include_local = false "#, ) .expect("write environments.toml"); @@ -772,7 +851,7 @@ default = "none" .map(|(id, _environment)| id) .collect(); - assert!(snapshot.include_local); + assert!(!snapshot.include_local); assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9e54a72ab..3db7a5157 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -522,9 +522,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result )?; let state_db = codex_core::init_state_db(&config).await; let environment_manager = if run_loader_overrides.ignore_user_config { - EnvironmentManager::from_env(local_runtime_paths).await? + EnvironmentManager::from_env(Some(local_runtime_paths)).await? } else { - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await? + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await? }; let in_process_start_args = InProcessClientStartArgs { arg0_paths, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index d82de48f9..4a4ff054b 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -96,10 +96,10 @@ pub async fn run_main( let environment_manager = Arc::new( EnvironmentManager::from_codex_home( config.codex_home.clone(), - ExecServerRuntimePaths::from_optional_paths( + Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?, + )?), ) .await .map_err(std::io::Error::other)?, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index cf499c770..9fdd6db90 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -116,7 +116,8 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; let thread_store = thread_store_from_config(&config, state_db.clone()); let environment_manager = Arc::new( - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?, + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await?, ); let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9ece5e377..4281ad292 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -852,9 +852,9 @@ pub async fn run_main( )?; let environment_manager = if should_load_configured_environments(&loader_overrides, &app_server_target) { - EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await } else { - EnvironmentManager::from_env(local_runtime_paths).await + EnvironmentManager::from_env(Some(local_runtime_paths)).await } .map(Arc::new) .map_err(std::io::Error::other)?; @@ -2291,10 +2291,10 @@ mod tests { let target = AppServerTarget::Embedded; let environment_manager = EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - ExecServerRuntimePaths::new( + Some(ExecServerRuntimePaths::new( std::env::current_exe().expect("current exe"), /*codex_linux_sandbox_exe*/ None, - )?, + )?), ) .await;