From a7fa3679ba9389eaf113c54ae4c0507236defd3a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:02:29 -0700 Subject: [PATCH 01/38] Support multiple managed environments Refactor EnvironmentManager to own a keyed environment registry with explicit default and local lookups. Keep remote exec-server connections lazy at environment use sites and preserve disabled agent environment access separately from internal local environment access. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 21 +- .../app-server/src/bespoke_event_handling.rs | 4 +- .../app-server/src/codex_message_processor.rs | 80 ++--- codex-rs/app-server/src/in_process.rs | 4 +- .../src/message_processor/tracing_tests.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 4 +- codex-rs/core/src/agent/control_tests.rs | 28 +- codex-rs/core/src/codex_delegate.rs | 5 +- codex-rs/core/src/memories/tests.rs | 4 +- codex-rs/core/src/session/mod.rs | 7 +- codex-rs/core/src/session/review.rs | 2 +- codex-rs/core/src/session/session.rs | 8 +- codex-rs/core/src/session/tests.rs | 20 +- .../core/src/session/tests/guardian_tests.rs | 4 +- codex-rs/core/src/session/turn_context.rs | 4 +- codex-rs/core/src/state/service.rs | 3 + codex-rs/core/src/thread_manager.rs | 10 +- codex-rs/core/src/thread_manager_tests.rs | 20 +- codex-rs/core/tests/common/test_codex.rs | 14 +- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/skills.rs | 7 +- codex-rs/exec-server/src/environment.rs | 332 +++++++++++------- .../exec-server/src/remote_file_system.rs | 34 +- codex-rs/exec-server/src/remote_process.rs | 11 +- codex-rs/exec-server/tests/exec_process.rs | 4 +- codex-rs/exec-server/tests/file_system.rs | 4 +- codex-rs/tui/src/app/test_support.rs | 4 +- codex-rs/tui/src/app/tests.rs | 8 +- codex-rs/tui/src/lib.rs | 34 +- codex-rs/tui/src/onboarding/auth.rs | 8 +- 30 files changed, 398 insertions(+), 298 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c6f678c2aa22..31184063e921 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -46,6 +46,7 @@ use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; @@ -968,7 +969,9 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, @@ -1969,9 +1972,12 @@ mod tests { #[tokio::test] async fn runtime_start_args_forward_environment_manager() { let config = Arc::new(build_test_config().await); - let environment_manager = Arc::new(EnvironmentManager::new(Some( - "ws://127.0.0.1:8765".to_string(), - ))); + let environment_manager = Arc::new(EnvironmentManager::from_exec_server_url( + EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: None, + }, + )); let runtime_args = InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), @@ -1998,7 +2004,12 @@ mod tests { &runtime_args.environment_manager, &environment_manager )); - assert!(runtime_args.environment_manager.is_remote()); + assert!( + runtime_args + .environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()) + ); } #[tokio::test] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a2ea77900aa9..43a75df6780b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3497,8 +3497,8 @@ mod tests { CodexAuth::create_dummy_chatgpt_auth_for_testing(), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ), ); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3b92d4199f58..08fb559d286d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5694,27 +5694,15 @@ impl CodexMessageProcessor { .await; let auth_manager = Arc::clone(&self.auth_manager); let auth = auth_manager.auth().await; - let runtime_environment = match self.thread_manager.environment_manager().current().await { - Ok(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()) - } - Ok(None) => McpRuntimeEnvironment::new( - Arc::new(codex_exec_server::Environment::default()), - config.cwd.to_path_buf(), - ), - Err(err) => { - // TODO(aibrahim): Investigate degrading MCP status listing when - // executor environment creation fails. - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to create environment: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; - } + let runtime_environment = { + let environment = self + .thread_manager + .environment_manager() + .default_environment() + .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); + // 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()) }; tokio::spawn(async move { @@ -5864,25 +5852,15 @@ impl CodexMessageProcessor { .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; - let runtime_environment = match self.thread_manager.environment_manager().current().await { - Ok(Some(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()) - } - Ok(None) => McpRuntimeEnvironment::new( - Arc::new(codex_exec_server::Environment::default()), - config.cwd.to_path_buf(), - ), - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to create environment: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + let runtime_environment = { + let environment = self + .thread_manager + .environment_manager() + .default_environment() + .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); + // 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()) }; tokio::spawn(async move { @@ -6469,23 +6447,11 @@ impl CodexMessageProcessor { }; let skills_manager = self.thread_manager.skills_manager(); let plugins_manager = self.thread_manager.plugins_manager(); - let fs = match self.thread_manager.environment_manager().current().await { - Ok(Some(environment)) => Some(environment.get_filesystem()), - Ok(None) => None, - Err(err) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to create environment: {err}"), - data: None, - }, - ) - .await; - return; - } - }; + let fs = self + .thread_manager + .environment_manager() + .default_environment() + .map(|environment| environment.get_filesystem()); let mut data = Vec::new(); for cwd in cwds { let extra_roots = extra_roots_by_cwd diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index e73124c0d3e9..c4544a7de75a 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -738,7 +738,9 @@ mod tests { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 83f8bc98d7c6..a6fffce95a11 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -241,7 +241,9 @@ fn build_test_processor( outgoing, arg0_paths: Arg0DispatchPaths::default(), config, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index c26b456fa91c..34b7a55d6aa4 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -204,7 +204,9 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), config_warnings: Vec::new(), session_source: SessionSource::Cli, enable_codex_api_key_env: false, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 10850ef8c74e..473180caa3e0 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -95,8 +95,8 @@ impl AgentControlHarness { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -911,8 +911,8 @@ async fn spawn_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -965,8 +965,8 @@ async fn spawn_agent_releases_slot_after_shutdown() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -1010,8 +1010,8 @@ async fn spawn_agent_limit_shared_across_clones() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -1057,8 +1057,8 @@ async fn resume_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -1115,8 +1115,8 @@ async fn resume_agent_releases_slot_after_resume_failure() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); @@ -1512,8 +1512,8 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let control = manager.agent_control(); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 7247c601f46e..4f4ced4101f4 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; use codex_async_utils::OrCancelExt; -use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -78,9 +77,7 @@ pub(crate) async fn run_codex_thread_interactive( config, auth_manager, models_manager, - environment_manager: Arc::new(EnvironmentManager::from_environment( - parent_ctx.environment.as_deref(), - )), + environment_manager: Arc::clone(&parent_session.services.environment_manager), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index af698e1eaaa3..2b68596fd424 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -491,8 +491,8 @@ mod phase2 { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let (mut session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4b919800cd93..ad392efda0a5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -460,10 +460,7 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let environment = environment_manager - .current() - .await - .map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?; + let environment = environment_manager.default_environment(); let fs = environment .as_ref() .map(|environment| environment.get_filesystem()); @@ -650,7 +647,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, - environment, + environment_manager, analytics_events_client, ) .await diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 94de4617d5a4..af1028686d2f 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -46,7 +46,7 @@ pub(super) async fn spawn_review_thread( ) .with_web_search_config(/*web_search_config*/ None) .with_allow_login_shell(config.permissions.allow_login_shell) - .with_has_environment(parent_turn_context.environment.is_some()) + .with_has_environment(parent_turn_context.tools_config.has_environment) .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 16e86e3aeac8..94cf8ae3f8e2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -228,7 +228,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, - environment: Option>, + environment_manager: Arc, analytics_events_client: Option, ) -> anyhow::Result> { debug!( @@ -641,6 +641,8 @@ impl Session { Arc::clone(&auth_manager), session_configuration.session_source.clone(), )); + let environment = environment_manager.default_environment(); + let allows_agent_environment_access = environment_manager.allows_agent_environment_access(); let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -695,7 +697,9 @@ impl Session { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: environment.clone(), + environment_manager, + environment, + allows_agent_environment_access, }; services .model_client diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f1a11bfcb604..1da7ad13e7cd 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3082,11 +3082,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), - Some(Arc::new( - codex_exec_server::Environment::create(/*exec_server_url*/ None) - .await - .expect("create environment"), - )), + Arc::new(codex_exec_server::EnvironmentManager::default()), /*analytics_events_client*/ None, ) .await; @@ -3184,7 +3180,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) - .await .expect("create environment"), ); @@ -3249,7 +3244,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), environment: Some(Arc::clone(&environment)), + allows_agent_environment_access: true, }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -3283,6 +3280,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_info, &models_manager, /*network*/ None, + /*allows_agent_environment_access*/ true, Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), @@ -3405,11 +3403,7 @@ async fn make_session_with_config_and_rx( mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), - Some(Arc::new( - codex_exec_server::Environment::create(/*exec_server_url*/ None) - .await - .expect("create environment"), - )), + Arc::new(codex_exec_server::EnvironmentManager::default()), /*analytics_events_client*/ None, ) .await?; @@ -4287,7 +4281,6 @@ where let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(/*exec_server_url*/ None) - .await .expect("create environment"), ); @@ -4352,7 +4345,9 @@ where code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), environment: Some(Arc::clone(&environment)), + allows_agent_environment_access: true, }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -4386,6 +4381,7 @@ where model_info, &models_manager, /*network*/ None, + /*allows_agent_environment_access*/ true, Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 9b1172d5788a..9ee27bd20284 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -634,7 +634,9 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config, auth_manager, models_manager, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), skills_manager, plugins_manager, mcp_manager, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index dd86804ee5d6..58fcfc73b8c9 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -345,6 +345,7 @@ impl Session { model_info: ModelInfo, models_manager: &ModelsManager, network: Option, + allows_agent_environment_access: bool, environment: Option>, sub_id: String, js_repl: Arc, @@ -381,7 +382,7 @@ impl Session { ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_has_environment(environment.is_some()) + .with_has_environment(allows_agent_environment_access) .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) @@ -576,6 +577,7 @@ impl Session { ) .then(|| started_proxy.proxy()) }), + self.services.allows_agent_environment_access, self.services.environment.clone(), sub_id, Arc::clone(&self.js_repl), diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 5db38f7b72a0..6e42227b6bbd 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -18,6 +18,7 @@ use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use codex_analytics::AnalyticsEventsClient; use codex_exec_server::Environment; +use codex_exec_server::EnvironmentManager; use codex_hooks::Hooks; use codex_login::AuthManager; use codex_mcp::McpConnectionManager; @@ -64,5 +65,7 @@ pub(crate) struct SessionServices { /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, + pub(crate) environment_manager: Arc, pub(crate) environment: Option>, + pub(crate) allows_agent_environment_access: bool, } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e4da99bb55a0..293423defe91 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -301,7 +301,9 @@ impl ThreadManager { auth, provider, codex_home.clone(), - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager @@ -920,11 +922,7 @@ impl ThreadManagerState { parent_trace: Option, user_shell_override: Option, ) -> CodexResult { - let environment = self - .environment_manager - .current() - .await - .map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?; + let environment = self.environment_manager.default_environment(); let watch_registration = match environment.as_ref() { Some(environment) if !environment.is_remote() => { self.skills_watcher diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index fe2039e89bc4..8c0fbe5e3805 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -246,8 +246,8 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), ); let thread_1 = manager @@ -297,8 +297,8 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { auth_manager, SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, ); @@ -434,8 +434,8 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, ); @@ -537,8 +537,8 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, ); @@ -630,8 +630,8 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5075f91620e5..32f5657b3498 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -76,7 +76,7 @@ impl TestEnv { pub async fn local() -> Result { let local_cwd_temp_dir = Arc::new(TempDir::new()?); let cwd = local_cwd_temp_dir.abs(); - let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None).await?; + let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None)?; Ok(Self { environment, cwd, @@ -115,7 +115,7 @@ pub async fn test_env() -> Result { match get_remote_test_env() { Some(remote_env) => { let websocket_url = remote_exec_server_url()?; - let environment = codex_exec_server::Environment::create(Some(websocket_url)).await?; + let environment = codex_exec_server::Environment::create(Some(websocket_url))?; let cwd = remote_aware_cwd_path(); environment .get_filesystem() @@ -350,9 +350,13 @@ impl TestCodexBuilder { let (config, fallback_cwd) = self .prepare_config(base_url, &home, test_env.cwd().clone()) .await?; - let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new( - test_env.exec_server_url().map(str::to_owned), - )); + let environment_manager = + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url: test_env.exec_server_url().map(str::to_owned), + local_runtime_paths: None, + }, + )); let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2086367b21e7..3bebd2192c70 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,8 +1103,8 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(codex_exec_server::EnvironmentManager::new( - /*exec_server_url*/ None, + Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 1b2ac71643b2..4ff6da00d131 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -234,7 +234,12 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(EnvironmentManager::new(Some("none".to_string()))), + Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: None, + }, + )), /*analytics_events_client*/ None, ); let new_thread = thread_manager.start_thread(config.clone()).await?; diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index afe072019600..d43d2ba2c45e 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,4 +1,6 @@ +use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use tokio::sync::OnceCell; @@ -15,45 +17,64 @@ use crate::remote_process::RemoteProcess; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; -/// Lazily creates and caches the active environment for a session. +/// Owns the execution/filesystem environments available to a session. /// -/// The manager keeps the session's environment selection stable so subagents -/// and follow-up turns preserve an explicit disabled state. +/// The manager keeps the session's default environment selection stable while +/// separately tracking whether model-facing tools may access environments. #[derive(Debug)] pub struct EnvironmentManager { - exec_server_url: Option, - local_runtime_paths: Option, - disabled: bool, - current_environment: OnceCell>>, + default_environment: Option, + environment_disabled_for_agent: bool, + environments: HashMap>, } -impl Default for EnvironmentManager { - fn default() -> Self { - Self::new(/*exec_server_url*/ None) - } +pub const LOCAL_ENVIRONMENT_ID: &str = "local"; +pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; + +#[derive(Clone, Debug, Default)] +pub struct EnvironmentManagerArgs { + pub exec_server_url: Option, + pub local_runtime_paths: Option, } -impl EnvironmentManager { - /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value. - pub fn new(exec_server_url: Option) -> Self { - Self::new_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None) - } +#[derive(Clone, Debug)] +pub(crate) struct LazyRemoteExecServerClient { + websocket_url: String, + client: Arc>, +} - /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local - /// runtime paths used when creating local filesystem helpers. - pub fn new_with_runtime_paths( - exec_server_url: Option, - local_runtime_paths: Option, - ) -> Self { - let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url); +impl LazyRemoteExecServerClient { + fn new(websocket_url: String) -> Self { Self { - exec_server_url, - local_runtime_paths, - disabled, - current_environment: OnceCell::new(), + websocket_url, + client: Arc::new(OnceCell::new()), } } + pub(crate) async fn get(&self) -> Result { + self.client + .get_or_try_init(|| async { + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { + websocket_url: self.websocket_url.clone(), + client_name: "codex-environment".to_string(), + connect_timeout: Duration::from_secs(5), + initialize_timeout: Duration::from_secs(5), + resume_session_id: None, + }) + .await + }) + .await + .cloned() + } +} + +impl Default for EnvironmentManager { + fn default() -> Self { + Self::from_exec_server_url(EnvironmentManagerArgs::default()) + } +} + +impl EnvironmentManager { /// Builds a manager from process environment variables. pub fn from_env() -> Self { Self::from_env_with_runtime_paths(/*local_runtime_paths*/ None) @@ -64,60 +85,81 @@ impl EnvironmentManager { pub fn from_env_with_runtime_paths( local_runtime_paths: Option, ) -> Self { - Self::new_with_runtime_paths( - std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + Self::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths, - ) + }) } - /// Builds a manager from the currently selected environment, or from the - /// disabled mode when no environment is available. - pub fn from_environment(environment: Option<&Environment>) -> Self { - match environment { - Some(environment) => Self { - exec_server_url: environment.exec_server_url().map(str::to_owned), - local_runtime_paths: environment.local_runtime_paths().cloned(), - disabled: false, - current_environment: OnceCell::new(), - }, - None => Self { - exec_server_url: None, - local_runtime_paths: None, - disabled: true, - current_environment: OnceCell::new(), - }, + /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local + /// runtime paths used when creating local filesystem helpers. + pub fn from_exec_server_url(args: EnvironmentManagerArgs) -> Self { + let EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths, + } = args; + let (exec_server_url, environment_disabled_for_agent) = + normalize_exec_server_url(exec_server_url); + let mut environments = HashMap::new(); + environments.insert( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new( + Environment::create_with_runtime_paths( + /*exec_server_url*/ None, + local_runtime_paths.clone(), + ) + .expect("valid local environment"), + ), + ); + + let default_environment = match exec_server_url { + Some(exec_server_url) => { + environments.insert( + REMOTE_ENVIRONMENT_ID.to_string(), + Arc::new( + Environment::create_with_runtime_paths( + Some(exec_server_url), + local_runtime_paths, + ) + .expect("valid remote environment"), + ), + ); + Some(REMOTE_ENVIRONMENT_ID.to_string()) + } + None => Some(LOCAL_ENVIRONMENT_ID.to_string()), + }; + + Self { + default_environment, + environment_disabled_for_agent, + environments, } } - /// Returns the remote exec-server URL when one is configured. - pub fn exec_server_url(&self) -> Option<&str> { - self.exec_server_url.as_deref() + /// Returns true when model-facing tools may access an environment. + pub fn allows_agent_environment_access(&self) -> bool { + !self.environment_disabled_for_agent + && self + .default_environment + .as_deref() + .is_some_and(|environment_id| self.environments.contains_key(environment_id)) } - /// Returns true when this manager is configured to use a remote exec server. - pub fn is_remote(&self) -> bool { - self.exec_server_url.is_some() + /// Returns the default environment instance. + pub fn default_environment(&self) -> Option> { + self.default_environment + .as_deref() + .and_then(|environment_id| self.get_environment(environment_id)) } - /// Returns the cached environment, creating it on first access. - pub async fn current(&self) -> Result>, ExecServerError> { - self.current_environment - .get_or_try_init(|| async { - if self.disabled { - Ok(None) - } else { - Ok(Some(Arc::new( - Environment::create_with_runtime_paths( - self.exec_server_url.clone(), - self.local_runtime_paths.clone(), - ) - .await?, - ))) - } - }) - .await - .map(Option::as_ref) - .map(std::option::Option::<&Arc>::cloned) + /// Returns the local environment instance. + pub fn local_environment(&self) -> Option> { + self.get_environment(LOCAL_ENVIRONMENT_ID) + } + + /// Returns a named environment instance. + pub fn get_environment(&self, environment_id: &str) -> Option> { + self.environments.get(environment_id).cloned() } } @@ -128,7 +170,7 @@ impl EnvironmentManager { #[derive(Clone)] pub struct Environment { exec_server_url: Option, - remote_exec_server_client: Option, + remote_exec_server_client: Option, exec_backend: Arc, local_runtime_paths: Option, } @@ -154,13 +196,13 @@ impl std::fmt::Debug for Environment { impl Environment { /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value. - pub async fn create(exec_server_url: Option) -> Result { - Self::create_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None).await + pub fn create(exec_server_url: Option) -> Result { + Self::create_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None) } /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and /// local runtime paths used when creating local filesystem helpers. - pub async fn create_with_runtime_paths( + pub(crate) fn create_with_runtime_paths( exec_server_url: Option, local_runtime_paths: Option, ) -> Result { @@ -171,17 +213,8 @@ impl Environment { )); } - let remote_exec_server_client = if let Some(exec_server_url) = &exec_server_url { - Some( - ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { - websocket_url: exec_server_url.clone(), - client_name: "codex-environment".to_string(), - connect_timeout: std::time::Duration::from_secs(5), - initialize_timeout: std::time::Duration::from_secs(5), - resume_session_id: None, - }) - .await?, - ) + let remote_exec_server_client = if let Some(exec_server_url) = exec_server_url.clone() { + Some(LazyRemoteExecServerClient::new(exec_server_url)) } else { None }; @@ -242,15 +275,16 @@ mod tests { use super::Environment; use super::EnvironmentManager; + use super::EnvironmentManagerArgs; + use super::REMOTE_ENVIRONMENT_ID; use crate::ExecServerRuntimePaths; use crate::ProcessId; use pretty_assertions::assert_eq; #[tokio::test] async fn create_local_environment_does_not_connect() { - let environment = Environment::create(/*exec_server_url*/ None) - .await - .expect("create environment"); + let environment = + Environment::create(/*exec_server_url*/ None).expect("create environment"); assert_eq!(environment.exec_server_url(), None); assert!(environment.remote_exec_server_client.is_none()); @@ -258,39 +292,63 @@ mod tests { #[test] fn environment_manager_normalizes_empty_url() { - let manager = EnvironmentManager::new(Some(String::new())); + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: Some(String::new()), + local_runtime_paths: None, + }); - assert!(!manager.disabled); - assert_eq!(manager.exec_server_url(), None); - assert!(!manager.is_remote()); + let environment = manager.default_environment().expect("local environment"); + assert!(!environment.is_remote()); + assert!(manager.allows_agent_environment_access()); + assert!(manager.local_environment().is_some()); + assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } #[test] fn environment_manager_treats_none_value_as_disabled() { - let manager = EnvironmentManager::new(Some("none".to_string())); + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: None, + }); - assert!(manager.disabled); - assert_eq!(manager.exec_server_url(), None); - assert!(!manager.is_remote()); + assert!(!manager.allows_agent_environment_access()); + assert!(manager.default_environment().is_some()); + assert!(manager.local_environment().is_some()); + assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } #[test] fn environment_manager_reports_remote_url() { - let manager = EnvironmentManager::new(Some("ws://127.0.0.1:8765".to_string())); + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: None, + }); - assert!(manager.is_remote()); - assert_eq!(manager.exec_server_url(), Some("ws://127.0.0.1:8765")); + let environment = manager.default_environment().expect("remote environment"); + assert!(environment.is_remote()); + assert!(manager.allows_agent_environment_access()); + assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:8765")); + assert!( + !manager + .local_environment() + .expect("local environment") + .is_remote() + ); + assert_eq!( + manager + .get_environment(REMOTE_ENVIRONMENT_ID) + .expect("remote environment") + .exec_server_url(), + Some("ws://127.0.0.1:8765") + ); } #[tokio::test] - async fn environment_manager_current_caches_environment() { - let manager = EnvironmentManager::new(/*exec_server_url*/ None); - - let first = manager.current().await.expect("get current environment"); - let second = manager.current().await.expect("get current environment"); + async fn environment_manager_default_environment_caches_environment() { + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs::default()); - let first = first.expect("local environment"); - let second = second.expect("local environment"); + let first = manager.default_environment().expect("local environment"); + let second = manager.default_environment().expect("local environment"); assert!(Arc::ptr_eq(&first, &second)); } @@ -302,35 +360,51 @@ mod tests { /*codex_linux_sandbox_exe*/ None, ) .expect("runtime paths"); - let manager = EnvironmentManager::new_with_runtime_paths( - /*exec_server_url*/ None, - Some(runtime_paths.clone()), - ); + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: None, + local_runtime_paths: Some(runtime_paths.clone()), + }); - let environment = manager - .current() - .await - .expect("get current environment") - .expect("local environment"); + let environment = manager.default_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); - assert_eq!( - EnvironmentManager::from_environment(Some(&environment)).local_runtime_paths, - Some(runtime_paths) - ); + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: environment.exec_server_url().map(str::to_owned), + local_runtime_paths: environment.local_runtime_paths().cloned(), + }); + let environment = manager.default_environment().expect("local environment"); + assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } #[tokio::test] - async fn disabled_environment_manager_has_no_current_environment() { - let manager = EnvironmentManager::new(Some("none".to_string())); + async fn disabled_environment_manager_has_default_environment_but_no_tool_environment() { + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: None, + }); - assert!( - manager - .current() - .await - .expect("get current environment") - .is_none() - ); + assert!(manager.default_environment().is_some()); + assert!(!manager.allows_agent_environment_access()); + } + + #[tokio::test] + async fn environment_manager_allows_local_lookup_when_disabled() { + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: None, + }); + + assert!(manager.default_environment().is_some()); + assert!(!manager.allows_agent_environment_access()); + assert!(manager.local_environment().is_some()); + assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); + } + + #[tokio::test] + async fn get_environment_returns_none_for_unknown_id() { + let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs::default()); + + assert!(manager.get_environment("does-not-exist").is_none()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index d6a32ba4d532..1b4150fe5ef6 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -7,7 +7,6 @@ use tracing::trace; use crate::CopyOptions; use crate::CreateDirectoryOptions; -use crate::ExecServerClient; use crate::ExecServerError; use crate::ExecutorFileSystem; use crate::FileMetadata; @@ -15,6 +14,7 @@ use crate::FileSystemResult; use crate::FileSystemSandboxContext; use crate::ReadDirectoryEntry; use crate::RemoveOptions; +use crate::environment::LazyRemoteExecServerClient; use crate::protocol::FsCopyParams; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsGetMetadataParams; @@ -28,14 +28,18 @@ const NOT_FOUND_ERROR_CODE: i64 = -32004; #[derive(Clone)] pub(crate) struct RemoteFileSystem { - client: ExecServerClient, + client: LazyRemoteExecServerClient, } impl RemoteFileSystem { - pub(crate) fn new(client: ExecServerClient) -> Self { + pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { trace!("remote fs new"); Self { client } } + + async fn client(&self) -> FileSystemResult { + self.client.get().await.map_err(map_remote_error) + } } #[async_trait] @@ -46,8 +50,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_file"); - let response = self - .client + let client = self.client().await?; + let response = client .fs_read_file(FsReadFileParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -69,7 +73,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs write_file"); - self.client + let client = self.client().await?; + client .fs_write_file(FsWriteFileParams { path: path.clone(), data_base64: STANDARD.encode(contents), @@ -87,7 +92,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs create_directory"); - self.client + let client = self.client().await?; + client .fs_create_directory(FsCreateDirectoryParams { path: path.clone(), recursive: Some(options.recursive), @@ -104,8 +110,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult { trace!("remote fs get_metadata"); - let response = self - .client + let client = self.client().await?; + let response = client .fs_get_metadata(FsGetMetadataParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -127,8 +133,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_directory"); - let response = self - .client + let client = self.client().await?; + let response = client .fs_read_directory(FsReadDirectoryParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -153,7 +159,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs remove"); - self.client + let client = self.client().await?; + client .fs_remove(FsRemoveParams { path: path.clone(), recursive: Some(options.recursive), @@ -173,7 +180,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs copy"); - self.client + let client = self.client().await?; + client .fs_copy(FsCopyParams { source_path: source_path.clone(), destination_path: destination_path.clone(), diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 86786a54f743..19828d9d691d 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -9,15 +9,15 @@ use crate::ExecProcess; use crate::ExecProcessEventReceiver; use crate::ExecServerError; use crate::StartedExecProcess; -use crate::client::ExecServerClient; use crate::client::Session; +use crate::environment::LazyRemoteExecServerClient; use crate::protocol::ExecParams; use crate::protocol::ReadResponse; use crate::protocol::WriteResponse; #[derive(Clone)] pub(crate) struct RemoteProcess { - client: ExecServerClient, + client: LazyRemoteExecServerClient, } struct RemoteExecProcess { @@ -25,7 +25,7 @@ struct RemoteExecProcess { } impl RemoteProcess { - pub(crate) fn new(client: ExecServerClient) -> Self { + pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { trace!("remote process new"); Self { client } } @@ -35,8 +35,9 @@ impl RemoteProcess { impl ExecBackend for RemoteProcess { async fn start(&self, params: ExecParams) -> Result { let process_id = params.process_id.clone(); - let session = self.client.register_session(&process_id).await?; - if let Err(err) = self.client.exec(params).await { + let client = self.client.get().await?; + let session = client.register_session(&process_id).await?; + if let Err(err) = client.exec(params).await { session.unregister().await; return Err(err); } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index d449315c8d6e..4887a0be4de1 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -49,13 +49,13 @@ enum ProcessEventSnapshot { async fn create_process_context(use_remote: bool) -> Result { if use_remote { let server = exec_server().await?; - let environment = Environment::create(Some(server.websocket_url().to_string())).await?; + let environment = Environment::create(Some(server.websocket_url().to_string()))?; Ok(ProcessContext { backend: environment.get_exec_backend(), server: Some(server), }) } else { - let environment = Environment::create(/*exec_server_url*/ None).await?; + let environment = Environment::create(/*exec_server_url*/ None)?; Ok(ProcessContext { backend: environment.get_exec_backend(), server: None, diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index d4f94c7e44c1..4bb654198f91 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -46,7 +46,7 @@ struct FileSystemContext { async fn create_file_system_context(use_remote: bool) -> Result { if use_remote { let server = exec_server().await?; - let environment = Environment::create(Some(server.websocket_url().to_string())).await?; + let environment = Environment::create(Some(server.websocket_url().to_string()))?; Ok(FileSystemContext { file_system: environment.get_filesystem(), _helper_paths: None, @@ -214,7 +214,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result< let helper_path = std::env::join_paths(path_entries)?; let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?; - let environment = Environment::create(Some(server.websocket_url().to_string())).await?; + let environment = Environment::create(Some(server.websocket_url().to_string()))?; let file_system = environment.get_filesystem(); let workspace = tmp.path().join("workspace"); std::fs::create_dir_all(&workspace)?; diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 8b2dfe8d47f8..254fc4cfa7cb 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -38,7 +38,9 @@ pub(super) async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 1749ee9f1eb1..6f4ff6d1aa4e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3576,7 +3576,9 @@ async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, @@ -3633,7 +3635,9 @@ async fn make_test_app_with_channels() -> ( backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7c60b2e38a28..2fb6ace80a45 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -425,7 +425,9 @@ pub(crate) async fn start_embedded_app_server_for_picker( start_app_server_for_picker( config, &AppServerTarget::Embedded, - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), ) .await } @@ -623,7 +625,9 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if environment_manager.is_remote() + if environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()) || matches!(app_server_target, AppServerTarget::Remote { .. }) { return Ok(None); @@ -1771,7 +1775,9 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), ) .await } @@ -1930,7 +1936,9 @@ mod tests { websocket_url: "ws://127.0.0.1:1234/".to_string(), auth_token: None, }; - let environment_manager = EnvironmentManager::new(/*exec_server_url*/ None); + let environment_manager = EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + ); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -1943,7 +1951,9 @@ mod tests { fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::new(/*exec_server_url*/ None); + let environment_manager = EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + ); let config_cwd = config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; @@ -1963,7 +1973,9 @@ mod tests { let temp_dir = TempDir::new()?; let missing = temp_dir.path().join("missing"); let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::new(/*exec_server_url*/ None); + let environment_manager = EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + ); let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager) .expect_err("missing embedded cwd should fail"); @@ -1980,7 +1992,11 @@ mod tests { Path::new("/definitely/not/local/to/this/test") }; let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::new(Some("ws://127.0.0.1:8765".to_string())); + let environment_manager = + EnvironmentManager::from_exec_server_url(codex_exec_server::EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: None, + }); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -2107,7 +2123,9 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::from_exec_server_url( + codex_exec_server::EnvironmentManagerArgs::default(), + )), |_args| async { Err(std::io::Error::other("boom")) }, ) .await; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index aee909cea1fc..ee6f843c8cb2 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -989,9 +989,11 @@ mod tests { ), feedback: codex_feedback::CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(codex_app_server_client::EnvironmentManager::new( - /*exec_server_url*/ None, - )), + environment_manager: Arc::new( + codex_app_server_client::EnvironmentManager::from_exec_server_url( + codex_app_server_client::EnvironmentManagerArgs::default(), + ), + ), config_warnings: Vec::new(), session_source: SessionSource::Cli, enable_codex_api_key_env: false, From 194b9d85713a7869bdac35d93b1a0602a8fa0934 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:06:09 -0700 Subject: [PATCH 02/38] Rename environment manager args constructor Use EnvironmentManager::new for args-struct construction and keep from_env methods as the env-var factory entrypoints. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 12 ++++------ .../app-server/src/bespoke_event_handling.rs | 2 +- codex-rs/app-server/src/in_process.rs | 2 +- .../src/message_processor/tracing_tests.rs | 2 +- .../app-server/tests/suite/v2/mcp_resource.rs | 2 +- codex-rs/core/src/agent/control_tests.rs | 14 +++++------ codex-rs/core/src/memories/tests.rs | 2 +- .../core/src/session/tests/guardian_tests.rs | 2 +- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/src/thread_manager_tests.rs | 10 ++++---- codex-rs/core/tests/common/test_codex.rs | 13 +++++----- codex-rs/core/tests/suite/client.rs | 2 +- codex-rs/core/tests/suite/skills.rs | 2 +- codex-rs/exec-server/src/environment.rs | 24 +++++++++---------- codex-rs/tui/src/app/test_support.rs | 2 +- codex-rs/tui/src/app/tests.rs | 4 ++-- codex-rs/tui/src/lib.rs | 23 ++++++++---------- codex-rs/tui/src/onboarding/auth.rs | 8 +++---- 18 files changed, 60 insertions(+), 68 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 31184063e921..49ed5256f08e 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -969,7 +969,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), config_warnings: Vec::new(), @@ -1972,12 +1972,10 @@ mod tests { #[tokio::test] async fn runtime_start_args_forward_environment_manager() { let config = Arc::new(build_test_config().await); - let environment_manager = Arc::new(EnvironmentManager::from_exec_server_url( - EnvironmentManagerArgs { - exec_server_url: Some("ws://127.0.0.1:8765".to_string()), - local_runtime_paths: None, - }, - )); + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: None, + })); let runtime_args = InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 43a75df6780b..806652a7d6e5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3497,7 +3497,7 @@ mod tests { CodexAuth::create_dummy_chatgpt_auth_for_testing(), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ), diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c4544a7de75a..8c604bcd8bfd 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -738,7 +738,7 @@ mod tests { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), config_warnings: Vec::new(), diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index a6fffce95a11..16d14c841391 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -241,7 +241,7 @@ fn build_test_processor( outgoing, arg0_paths: Arg0DispatchPaths::default(), config, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), cli_overrides: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 34b7a55d6aa4..4bc412d54e8d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -204,7 +204,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), config_warnings: Vec::new(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 473180caa3e0..15eb23d0cdbb 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -95,7 +95,7 @@ impl AgentControlHarness { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -911,7 +911,7 @@ async fn spawn_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -965,7 +965,7 @@ async fn spawn_agent_releases_slot_after_shutdown() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -1010,7 +1010,7 @@ async fn spawn_agent_limit_shared_across_clones() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -1057,7 +1057,7 @@ async fn resume_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -1115,7 +1115,7 @@ async fn resume_agent_releases_slot_after_resume_failure() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -1512,7 +1512,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 2b68596fd424..1048b3da869a 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -491,7 +491,7 @@ mod phase2 { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 9ee27bd20284..117223d429bf 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -634,7 +634,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config, auth_manager, models_manager, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), skills_manager, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 293423defe91..c5da648bf503 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -301,7 +301,7 @@ impl ThreadManager { auth, provider, codex_home.clone(), - Arc::new(EnvironmentManager::from_exec_server_url( + Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 8c0fbe5e3805..76dd62bb9a56 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -246,7 +246,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ); @@ -297,7 +297,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { auth_manager, SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, @@ -434,7 +434,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, @@ -537,7 +537,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, @@ -630,7 +630,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 32f5657b3498..0993b5b0e42f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -350,13 +350,12 @@ impl TestCodexBuilder { let (config, fallback_cwd) = self .prepare_config(base_url, &home, test_env.cwd().clone()) .await?; - let environment_manager = - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( - codex_exec_server::EnvironmentManagerArgs { - exec_server_url: test_env.exec_server_url().map(str::to_owned), - local_runtime_paths: None, - }, - )); + let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url: test_env.exec_server_url().map(str::to_owned), + local_runtime_paths: None, + }, + )); let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3bebd2192c70..3f0a7762ba23 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,7 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(codex_exec_server::EnvironmentManager::from_exec_server_url( + Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), /*analytics_events_client*/ None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 4ff6da00d131..090f7a57903c 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -234,7 +234,7 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(EnvironmentManager::from_exec_server_url( + Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d43d2ba2c45e..4cfcd6f9c009 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -70,7 +70,7 @@ impl LazyRemoteExecServerClient { impl Default for EnvironmentManager { fn default() -> Self { - Self::from_exec_server_url(EnvironmentManagerArgs::default()) + Self::new(EnvironmentManagerArgs::default()) } } @@ -85,7 +85,7 @@ impl EnvironmentManager { pub fn from_env_with_runtime_paths( local_runtime_paths: Option, ) -> Self { - Self::from_exec_server_url(EnvironmentManagerArgs { + Self::new(EnvironmentManagerArgs { exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths, }) @@ -93,7 +93,7 @@ impl EnvironmentManager { /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local /// runtime paths used when creating local filesystem helpers. - pub fn from_exec_server_url(args: EnvironmentManagerArgs) -> Self { + pub fn new(args: EnvironmentManagerArgs) -> Self { let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, @@ -292,7 +292,7 @@ mod tests { #[test] fn environment_manager_normalizes_empty_url() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some(String::new()), local_runtime_paths: None, }); @@ -306,7 +306,7 @@ mod tests { #[test] fn environment_manager_treats_none_value_as_disabled() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); @@ -319,7 +319,7 @@ mod tests { #[test] fn environment_manager_reports_remote_url() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), local_runtime_paths: None, }); @@ -345,7 +345,7 @@ mod tests { #[tokio::test] async fn environment_manager_default_environment_caches_environment() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs::default()); + let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); let first = manager.default_environment().expect("local environment"); let second = manager.default_environment().expect("local environment"); @@ -360,7 +360,7 @@ mod tests { /*codex_linux_sandbox_exe*/ None, ) .expect("runtime paths"); - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: None, local_runtime_paths: Some(runtime_paths.clone()), }); @@ -368,7 +368,7 @@ mod tests { let environment = manager.default_environment().expect("local environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: environment.exec_server_url().map(str::to_owned), local_runtime_paths: environment.local_runtime_paths().cloned(), }); @@ -378,7 +378,7 @@ mod tests { #[tokio::test] async fn disabled_environment_manager_has_default_environment_but_no_tool_environment() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); @@ -389,7 +389,7 @@ mod tests { #[tokio::test] async fn environment_manager_allows_local_lookup_when_disabled() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); @@ -402,7 +402,7 @@ mod tests { #[tokio::test] async fn get_environment_returns_none_for_unknown_id() { - let manager = EnvironmentManager::from_exec_server_url(EnvironmentManagerArgs::default()); + let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); assert!(manager.get_environment("does-not-exist").is_none()); } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 254fc4cfa7cb..8b2c22512ef8 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -38,7 +38,7 @@ pub(super) async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), remote_app_server_url: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6f4ff6d1aa4e..56d093598fac 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3576,7 +3576,7 @@ async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), remote_app_server_url: None, @@ -3635,7 +3635,7 @@ async fn make_test_app_with_channels() -> ( backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::from_exec_server_url( + environment_manager: Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), remote_app_server_url: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fb6ace80a45..bbc6df14bb14 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -425,7 +425,7 @@ pub(crate) async fn start_embedded_app_server_for_picker( start_app_server_for_picker( config, &AppServerTarget::Embedded, - Arc::new(EnvironmentManager::from_exec_server_url( + Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ) @@ -1775,7 +1775,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::from_exec_server_url( + Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), ) @@ -1936,9 +1936,8 @@ mod tests { websocket_url: "ws://127.0.0.1:1234/".to_string(), auth_token: None, }; - let environment_manager = EnvironmentManager::from_exec_server_url( - codex_exec_server::EnvironmentManagerArgs::default(), - ); + let environment_manager = + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -1951,9 +1950,8 @@ mod tests { fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::from_exec_server_url( - codex_exec_server::EnvironmentManagerArgs::default(), - ); + let environment_manager = + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); let config_cwd = config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; @@ -1973,9 +1971,8 @@ mod tests { let temp_dir = TempDir::new()?; let missing = temp_dir.path().join("missing"); let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::from_exec_server_url( - codex_exec_server::EnvironmentManagerArgs::default(), - ); + let environment_manager = + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager) .expect_err("missing embedded cwd should fail"); @@ -1993,7 +1990,7 @@ mod tests { }; let target = AppServerTarget::Embedded; let environment_manager = - EnvironmentManager::from_exec_server_url(codex_exec_server::EnvironmentManagerArgs { + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), local_runtime_paths: None, }); @@ -2123,7 +2120,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::from_exec_server_url( + Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs::default(), )), |_args| async { Err(std::io::Error::other("boom")) }, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index ee6f843c8cb2..2379c2d55dbb 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -989,11 +989,9 @@ mod tests { ), feedback: codex_feedback::CodexFeedback::new(), log_db: None, - environment_manager: Arc::new( - codex_app_server_client::EnvironmentManager::from_exec_server_url( - codex_app_server_client::EnvironmentManagerArgs::default(), - ), - ), + environment_manager: Arc::new(codex_app_server_client::EnvironmentManager::new( + codex_app_server_client::EnvironmentManagerArgs::default(), + )), config_warnings: Vec::new(), session_source: SessionSource::Cli, enable_codex_api_key_env: false, From e589fddaec4dc33681dd3e570e9a254abbc8d1b0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:09:41 -0700 Subject: [PATCH 03/38] Make default environment lookup infallible Return concrete default and local environments from EnvironmentManager now that the manager always installs local and default entries. Keep arbitrary ID lookup optional. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 2 +- .../app-server/src/codex_message_processor.rs | 9 ++-- codex-rs/core/src/session/mod.rs | 6 +-- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/thread_manager.rs | 6 +-- codex-rs/exec-server/src/environment.rs | 52 ++++++++----------- codex-rs/tui/src/lib.rs | 4 +- 7 files changed, 34 insertions(+), 47 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 49ed5256f08e..b767a3c50d5f 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -2006,7 +2006,7 @@ mod tests { runtime_args .environment_manager .default_environment() - .is_some_and(|environment| environment.is_remote()) + .is_remote() ); } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 08fb559d286d..66547e1a9cc7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5698,8 +5698,7 @@ impl CodexMessageProcessor { let environment = self .thread_manager .environment_manager() - .default_environment() - .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); + .default_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()) @@ -5856,8 +5855,7 @@ impl CodexMessageProcessor { let environment = self .thread_manager .environment_manager() - .default_environment() - .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); + .default_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()) @@ -6451,7 +6449,8 @@ impl CodexMessageProcessor { .thread_manager .environment_manager() .default_environment() - .map(|environment| environment.get_filesystem()); + .get_filesystem(); + let fs = Some(fs); let mut data = Vec::new(); for cwd in cwds { let extra_roots = extra_roots_by_cwd diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ad392efda0a5..fcc66da7f402 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -461,9 +461,7 @@ impl Codex { let (tx_event, rx_event) = async_channel::unbounded(); let environment = environment_manager.default_environment(); - let fs = environment - .as_ref() - .map(|environment| environment.get_filesystem()); + let fs = Some(environment.get_filesystem()); let plugin_outcome = plugins_manager.plugins_for_config(&config).await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); @@ -513,7 +511,7 @@ impl Codex { } let user_instructions = AgentsMdManager::new(&config) - .user_instructions(environment.as_deref()) + .user_instructions(Some(environment.as_ref())) .await; let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 94cf8ae3f8e2..1c5c1c956794 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -698,7 +698,7 @@ impl Session { config.js_repl_node_path.clone(), ), environment_manager, - environment, + environment: Some(environment), allows_agent_environment_access, }; services diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index c5da648bf503..da109bbcc7be 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -923,8 +923,8 @@ impl ThreadManagerState { user_shell_override: Option, ) -> CodexResult { let environment = self.environment_manager.default_environment(); - let watch_registration = match environment.as_ref() { - Some(environment) if !environment.is_remote() => { + let watch_registration = match environment.is_remote() { + false => { self.skills_watcher .register_config( &config, @@ -934,7 +934,7 @@ impl ThreadManagerState { ) .await } - Some(_) | None => crate::file_watcher::WatchRegistration::default(), + true => crate::file_watcher::WatchRegistration::default(), }; let CodexSpawnOk { codex, thread_id, .. diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 4cfcd6f9c009..634b2e7c79ef 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -23,7 +23,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// separately tracking whether model-facing tools may access environments. #[derive(Debug)] pub struct EnvironmentManager { - default_environment: Option, + default_environment: String, environment_disabled_for_agent: bool, environments: HashMap>, } @@ -124,9 +124,9 @@ impl EnvironmentManager { .expect("valid remote environment"), ), ); - Some(REMOTE_ENVIRONMENT_ID.to_string()) + REMOTE_ENVIRONMENT_ID.to_string() } - None => Some(LOCAL_ENVIRONMENT_ID.to_string()), + None => LOCAL_ENVIRONMENT_ID.to_string(), }; Self { @@ -139,22 +139,19 @@ impl EnvironmentManager { /// Returns true when model-facing tools may access an environment. pub fn allows_agent_environment_access(&self) -> bool { !self.environment_disabled_for_agent - && self - .default_environment - .as_deref() - .is_some_and(|environment_id| self.environments.contains_key(environment_id)) + && self.environments.contains_key(&self.default_environment) } /// Returns the default environment instance. - pub fn default_environment(&self) -> Option> { - self.default_environment - .as_deref() - .and_then(|environment_id| self.get_environment(environment_id)) + pub fn default_environment(&self) -> Arc { + self.get_environment(&self.default_environment) + .expect("default environment exists") } /// Returns the local environment instance. - pub fn local_environment(&self) -> Option> { + pub fn local_environment(&self) -> Arc { self.get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment exists") } /// Returns a named environment instance. @@ -297,10 +294,10 @@ mod tests { local_runtime_paths: None, }); - let environment = manager.default_environment().expect("local environment"); + let environment = manager.default_environment(); assert!(!environment.is_remote()); assert!(manager.allows_agent_environment_access()); - assert!(manager.local_environment().is_some()); + assert!(!manager.local_environment().is_remote()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -312,8 +309,8 @@ mod tests { }); assert!(!manager.allows_agent_environment_access()); - assert!(manager.default_environment().is_some()); - assert!(manager.local_environment().is_some()); + assert!(!manager.default_environment().is_remote()); + assert!(!manager.local_environment().is_remote()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -324,16 +321,11 @@ mod tests { local_runtime_paths: None, }); - let environment = manager.default_environment().expect("remote environment"); + let environment = manager.default_environment(); assert!(environment.is_remote()); assert!(manager.allows_agent_environment_access()); assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:8765")); - assert!( - !manager - .local_environment() - .expect("local environment") - .is_remote() - ); + assert!(!manager.local_environment().is_remote()); assert_eq!( manager .get_environment(REMOTE_ENVIRONMENT_ID) @@ -347,8 +339,8 @@ mod tests { async fn environment_manager_default_environment_caches_environment() { let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); - let first = manager.default_environment().expect("local environment"); - let second = manager.default_environment().expect("local environment"); + let first = manager.default_environment(); + let second = manager.default_environment(); assert!(Arc::ptr_eq(&first, &second)); } @@ -365,14 +357,14 @@ mod tests { local_runtime_paths: Some(runtime_paths.clone()), }); - let environment = manager.default_environment().expect("local environment"); + let environment = manager.default_environment(); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: environment.exec_server_url().map(str::to_owned), local_runtime_paths: environment.local_runtime_paths().cloned(), }); - let environment = manager.default_environment().expect("local environment"); + let environment = manager.default_environment(); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } @@ -383,7 +375,7 @@ mod tests { local_runtime_paths: None, }); - assert!(manager.default_environment().is_some()); + assert!(!manager.default_environment().is_remote()); assert!(!manager.allows_agent_environment_access()); } @@ -394,9 +386,9 @@ mod tests { local_runtime_paths: None, }); - assert!(manager.default_environment().is_some()); + assert!(!manager.default_environment().is_remote()); assert!(!manager.allows_agent_environment_access()); - assert!(manager.local_environment().is_some()); + assert!(!manager.local_environment().is_remote()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bbc6df14bb14..10ba55de8037 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -625,9 +625,7 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()) + if environment_manager.default_environment().is_remote() || matches!(app_server_target, AppServerTarget::Remote { .. }) { return Ok(None); From 36eb75bc7a682124d056275a89b23b842187ecd3 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:10:30 -0700 Subject: [PATCH 04/38] Move lazy exec-server client handle Keep the lazy remote exec-server client wrapper alongside ExecServerClient and import it from the client module for environment-backed exec and filesystem use. Co-authored-by: Codex --- codex-rs/exec-server/src/client.rs | 32 ++++++++++++++++ codex-rs/exec-server/src/environment.rs | 37 +------------------ .../exec-server/src/remote_file_system.rs | 2 +- codex-rs/exec-server/src/remote_process.rs | 2 +- 4 files changed, 35 insertions(+), 38 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 4e282c8fd3fb..c4526e13bd4f 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -10,6 +10,7 @@ use arc_swap::ArcSwap; use codex_app_server_protocol::JSONRPCNotification; use serde_json::Value; use tokio::sync::Mutex; +use tokio::sync::OnceCell; use tokio::sync::mpsc; use tokio::sync::watch; @@ -174,6 +175,37 @@ pub struct ExecServerClient { inner: Arc, } +#[derive(Clone, Debug)] +pub(crate) struct LazyRemoteExecServerClient { + websocket_url: String, + client: Arc>, +} + +impl LazyRemoteExecServerClient { + pub(crate) fn new(websocket_url: String) -> Self { + Self { + websocket_url, + client: Arc::new(OnceCell::new()), + } + } + + pub(crate) async fn get(&self) -> Result { + self.client + .get_or_try_init(|| async { + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { + websocket_url: self.websocket_url.clone(), + client_name: "codex-environment".to_string(), + connect_timeout: Duration::from_secs(5), + initialize_timeout: Duration::from_secs(5), + resume_session_id: None, + }) + .await + }) + .await + .cloned() + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 634b2e7c79ef..2452144001ba 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,13 +1,9 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; -use tokio::sync::OnceCell; - -use crate::ExecServerClient; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::RemoteExecServerConnectArgs; +use crate::client::LazyRemoteExecServerClient; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; @@ -37,37 +33,6 @@ pub struct EnvironmentManagerArgs { pub local_runtime_paths: Option, } -#[derive(Clone, Debug)] -pub(crate) struct LazyRemoteExecServerClient { - websocket_url: String, - client: Arc>, -} - -impl LazyRemoteExecServerClient { - fn new(websocket_url: String) -> Self { - Self { - websocket_url, - client: Arc::new(OnceCell::new()), - } - } - - pub(crate) async fn get(&self) -> Result { - self.client - .get_or_try_init(|| async { - ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { - websocket_url: self.websocket_url.clone(), - client_name: "codex-environment".to_string(), - connect_timeout: Duration::from_secs(5), - initialize_timeout: Duration::from_secs(5), - resume_session_id: None, - }) - .await - }) - .await - .cloned() - } -} - impl Default for EnvironmentManager { fn default() -> Self { Self::new(EnvironmentManagerArgs::default()) diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 1b4150fe5ef6..02a5e2883686 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -14,7 +14,7 @@ use crate::FileSystemResult; use crate::FileSystemSandboxContext; use crate::ReadDirectoryEntry; use crate::RemoveOptions; -use crate::environment::LazyRemoteExecServerClient; +use crate::client::LazyRemoteExecServerClient; use crate::protocol::FsCopyParams; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsGetMetadataParams; diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 19828d9d691d..d8d06735cdb9 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -9,8 +9,8 @@ use crate::ExecProcess; use crate::ExecProcessEventReceiver; use crate::ExecServerError; use crate::StartedExecProcess; +use crate::client::LazyRemoteExecServerClient; use crate::client::Session; -use crate::environment::LazyRemoteExecServerClient; use crate::protocol::ExecParams; use crate::protocol::ReadResponse; use crate::protocol::WriteResponse; From c215ff4e366d3ba32a871904919d7aa0f6429c65 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:13:20 -0700 Subject: [PATCH 05/38] Remove path-specific environment factory Use EnvironmentManager::new with EnvironmentManagerArgs for runtime-path-aware construction and keep from_env only for the no-args env-var factory. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server/src/lib.rs | 11 +++++++---- codex-rs/exec-server/src/environment.rs | 10 +--------- codex-rs/exec/src/lib.rs | 9 ++++++--- codex-rs/mcp-server/src/lib.rs | 11 +++++++---- codex-rs/tui/src/lib.rs | 11 +++++++---- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index b767a3c50d5f..726939d292fe 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -45,6 +45,7 @@ use codex_config::NoopThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; +pub use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index d4573b267a83..c88c681e27a1 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,6 +8,8 @@ use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; @@ -361,12 +363,13 @@ pub async fn run_main_with_transport( session_source: SessionSource, auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some( - ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - ))); + )?), + })); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 2452144001ba..e77fb564c616 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -42,17 +42,9 @@ impl Default for EnvironmentManager { impl EnvironmentManager { /// Builds a manager from process environment variables. pub fn from_env() -> Self { - Self::from_env_with_runtime_paths(/*local_runtime_paths*/ None) - } - - /// Builds a manager from process environment variables and local runtime - /// paths used when creating local filesystem helpers. - pub fn from_env_with_runtime_paths( - local_runtime_paths: Option, - ) -> Self { Self::new(EnvironmentManagerArgs { exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), - local_runtime_paths, + local_runtime_paths: None, }) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 781e423fde45..4cb80a1543e6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -13,8 +13,10 @@ pub(crate) mod exec_events; pub use cli::Cli; pub use cli::Command; pub use cli::ReviewArgs; +use codex_app_server_client::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::EnvironmentManager; +use codex_app_server_client::EnvironmentManagerArgs; use codex_app_server_client::ExecServerRuntimePaths; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; @@ -497,9 +499,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, - environment_manager: std::sync::Arc::new(EnvironmentManager::from_env_with_runtime_paths( - Some(local_runtime_paths), - )), + environment_manager: std::sync::Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths: Some(local_runtime_paths), + })), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 1320fd1b67e2..2eb93130f135 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_login::default_client::set_default_client_residency_requirement; use codex_utils_cli::CliConfigOverrides; @@ -59,12 +61,13 @@ pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some( - ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - ))); + )?), + })); // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 10ba55de8037..98369a0bbda8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -35,7 +35,9 @@ use codex_config::CloudRequirementsLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; use codex_login::default_client::set_default_client_residency_requirement; @@ -728,12 +730,13 @@ pub async fn run_main( } }; - let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some( - ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - ))); + )?), + })); let cwd = cli.cwd.clone(); let config_cwd = config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?; From 6b8fc183a6b041301ae52fe08a1624a34be5c364 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:15:03 -0700 Subject: [PATCH 06/38] Document environment manager behavior Add high-level EnvironmentManager docs for local/remote initialization, default environment selection, disabled agent access, and lazy remote connection behavior. Co-authored-by: Codex --- codex-rs/exec-server/src/environment.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index e77fb564c616..86fa6b650794 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -15,8 +15,21 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// Owns the execution/filesystem environments available to a session. /// -/// The manager keeps the session's default environment selection stable while -/// separately tracking whether model-facing tools may access environments. +/// `EnvironmentManager` is the session-scoped registry for concrete +/// environments. It always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. +/// When `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a +/// remote environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default +/// environment. Otherwise the local environment is the default. +/// +/// Setting `CODEX_EXEC_SERVER_URL=none` does not remove the local environment: +/// Codex internals may still use `local_environment()` or `default_environment()`. +/// Instead it disables agent/tool access as reported by +/// `allows_agent_environment_access()`, so shell/filesystem tools can be hidden +/// from the model while internal local filesystem access still works. +/// +/// Remote environments hold a lazy exec-server client handle. The websocket is +/// not opened when the manager or environment is constructed; it connects on the +/// first remote exec or filesystem operation. #[derive(Debug)] pub struct EnvironmentManager { default_environment: String, From 405b9dbe1960625db66d756e1d59ed7eee2c1dc0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:15:56 -0700 Subject: [PATCH 07/38] Remove local environment convenience method Drop the unused local_environment helper and keep local lookups on the generic get_environment API. Co-authored-by: Codex --- codex-rs/exec-server/src/environment.rs | 38 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 86fa6b650794..53a737dc5708 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -22,7 +22,8 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// environment. Otherwise the local environment is the default. /// /// Setting `CODEX_EXEC_SERVER_URL=none` does not remove the local environment: -/// Codex internals may still use `local_environment()` or `default_environment()`. +/// Codex internals may still use `default_environment()` or explicit +/// `get_environment()` lookups. /// Instead it disables agent/tool access as reported by /// `allows_agent_environment_access()`, so shell/filesystem tools can be hidden /// from the model while internal local filesystem access still works. @@ -118,12 +119,6 @@ impl EnvironmentManager { .expect("default environment exists") } - /// Returns the local environment instance. - pub fn local_environment(&self) -> Arc { - self.get_environment(LOCAL_ENVIRONMENT_ID) - .expect("local environment exists") - } - /// Returns a named environment instance. pub fn get_environment(&self, environment_id: &str) -> Option> { self.environments.get(environment_id).cloned() @@ -243,6 +238,7 @@ mod tests { use super::Environment; use super::EnvironmentManager; use super::EnvironmentManagerArgs; + use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; use crate::ExecServerRuntimePaths; use crate::ProcessId; @@ -267,7 +263,12 @@ mod tests { let environment = manager.default_environment(); assert!(!environment.is_remote()); assert!(manager.allows_agent_environment_access()); - assert!(!manager.local_environment().is_remote()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -280,7 +281,12 @@ mod tests { assert!(!manager.allows_agent_environment_access()); assert!(!manager.default_environment().is_remote()); - assert!(!manager.local_environment().is_remote()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -295,7 +301,12 @@ mod tests { assert!(environment.is_remote()); assert!(manager.allows_agent_environment_access()); assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:8765")); - assert!(!manager.local_environment().is_remote()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert_eq!( manager .get_environment(REMOTE_ENVIRONMENT_ID) @@ -358,7 +369,12 @@ mod tests { assert!(!manager.default_environment().is_remote()); assert!(!manager.allows_agent_environment_access()); - assert!(!manager.local_environment().is_remote()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } From 64a9a98695d46db56c249fef113b8600a7c197d2 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:27:17 -0700 Subject: [PATCH 08/38] Document shared environment manager handle Clarify that SessionServices carries an Arc handle to the process-level EnvironmentManager rather than owning a session-specific manager. Co-authored-by: Codex --- codex-rs/core/src/state/service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 6e42227b6bbd..29fb5eb1d2df 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -65,6 +65,8 @@ pub(crate) struct SessionServices { /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, + /// Shared process-level environment registry. Sessions carry an `Arc` handle so they can pass + /// the same manager through child-thread spawn paths without reconstructing it. pub(crate) environment_manager: Arc, pub(crate) environment: Option>, pub(crate) allows_agent_environment_access: bool, From 9d3188f79bab155cbf2c1fe5f0a1111fb920a16f Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:57:03 -0700 Subject: [PATCH 09/38] Use optional default environment for disabled mode Restore CODEX_EXEC_SERVER_URL=none semantics by making EnvironmentManager::default_environment return None when environment access is disabled. Remove the separate disabled-for-agent flag and derive tool availability from the optional default environment. Add an end-to-end tool exposure test for CODEX_EXEC_SERVER_URL=none. Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 9 +- codex-rs/core/src/session/mod.rs | 4 +- codex-rs/core/src/session/session.rs | 4 - codex-rs/core/src/session/tests.rs | 6 - codex-rs/core/src/session/turn_context.rs | 11 +- codex-rs/core/src/state/service.rs | 3 - codex-rs/core/src/thread_manager.rs | 6 +- codex-rs/core/tests/common/test_codex.rs | 13 +- codex-rs/core/tests/suite/tools.rs | 60 ++++++++ codex-rs/exec-server/src/environment.rs | 132 ++++++++---------- codex-rs/tui/src/lib.rs | 4 +- 11 files changed, 145 insertions(+), 107 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 66547e1a9cc7..08fb559d286d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5698,7 +5698,8 @@ impl CodexMessageProcessor { let environment = self .thread_manager .environment_manager() - .default_environment(); + .default_environment() + .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); // 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()) @@ -5855,7 +5856,8 @@ impl CodexMessageProcessor { let environment = self .thread_manager .environment_manager() - .default_environment(); + .default_environment() + .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); // 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()) @@ -6449,8 +6451,7 @@ impl CodexMessageProcessor { .thread_manager .environment_manager() .default_environment() - .get_filesystem(); - let fs = Some(fs); + .map(|environment| environment.get_filesystem()); let mut data = Vec::new(); for cwd in cwds { let extra_roots = extra_roots_by_cwd diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fcc66da7f402..9031e776eb0d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -461,7 +461,9 @@ impl Codex { let (tx_event, rx_event) = async_channel::unbounded(); let environment = environment_manager.default_environment(); - let fs = Some(environment.get_filesystem()); + let fs = environment + .as_ref() + .map(|environment| environment.get_filesystem()); let plugin_outcome = plugins_manager.plugins_for_config(&config).await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1c5c1c956794..70cbcb2fd29a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -641,8 +641,6 @@ impl Session { Arc::clone(&auth_manager), session_configuration.session_source.clone(), )); - let environment = environment_manager.default_environment(); - let allows_agent_environment_access = environment_manager.allows_agent_environment_access(); let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -698,8 +696,6 @@ impl Session { config.js_repl_node_path.clone(), ), environment_manager, - environment: Some(environment), - allows_agent_environment_access, }; services .model_client diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1da7ad13e7cd..48814319ebaf 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3245,8 +3245,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.js_repl_node_path.clone(), ), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), - environment: Some(Arc::clone(&environment)), - allows_agent_environment_access: true, }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -3280,7 +3278,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_info, &models_manager, /*network*/ None, - /*allows_agent_environment_access*/ true, Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), @@ -4346,8 +4343,6 @@ where config.js_repl_node_path.clone(), ), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), - environment: Some(Arc::clone(&environment)), - allows_agent_environment_access: true, }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -4381,7 +4376,6 @@ where model_info, &models_manager, /*network*/ None, - /*allows_agent_environment_access*/ true, Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 58fcfc73b8c9..ce6758c442ba 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -345,7 +345,6 @@ impl Session { model_info: ModelInfo, models_manager: &ModelsManager, network: Option, - allows_agent_environment_access: bool, environment: Option>, sub_id: String, js_repl: Arc, @@ -382,7 +381,7 @@ impl Session { ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_has_environment(allows_agent_environment_access) + .with_has_environment(environment.is_some()) .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) @@ -545,9 +544,8 @@ impl Session { .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); - let fs = self - .services - .environment + let environment = self.services.environment_manager.default_environment(); + let fs = environment .as_ref() .map(|environment| environment.get_filesystem()); let skills_outcome = Arc::new( @@ -577,8 +575,7 @@ impl Session { ) .then(|| started_proxy.proxy()) }), - self.services.allows_agent_environment_access, - self.services.environment.clone(), + environment, sub_id, Arc::clone(&self.js_repl), skills_outcome, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 29fb5eb1d2df..94e17eb15723 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -17,7 +17,6 @@ use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use codex_analytics::AnalyticsEventsClient; -use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_hooks::Hooks; use codex_login::AuthManager; @@ -68,6 +67,4 @@ pub(crate) struct SessionServices { /// Shared process-level environment registry. Sessions carry an `Arc` handle so they can pass /// the same manager through child-thread spawn paths without reconstructing it. pub(crate) environment_manager: Arc, - pub(crate) environment: Option>, - pub(crate) allows_agent_environment_access: bool, } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index da109bbcc7be..c5da648bf503 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -923,8 +923,8 @@ impl ThreadManagerState { user_shell_override: Option, ) -> CodexResult { let environment = self.environment_manager.default_environment(); - let watch_registration = match environment.is_remote() { - false => { + let watch_registration = match environment.as_ref() { + Some(environment) if !environment.is_remote() => { self.skills_watcher .register_config( &config, @@ -934,7 +934,7 @@ impl ThreadManagerState { ) .await } - true => crate::file_watcher::WatchRegistration::default(), + Some(_) | None => crate::file_watcher::WatchRegistration::default(), }; let CodexSpawnOk { codex, thread_id, .. diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 0993b5b0e42f..67d1a49968fd 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -204,6 +204,7 @@ pub struct TestCodexBuilder { workspace_setups: Vec>, home: Option>, user_shell_override: Option, + exec_server_url: Option, } impl TestCodexBuilder { @@ -255,6 +256,11 @@ impl TestCodexBuilder { self } + pub fn with_exec_server_url(mut self, exec_server_url: impl Into) -> Self { + self.exec_server_url = Some(exec_server_url.into()); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -350,9 +356,13 @@ impl TestCodexBuilder { let (config, fallback_cwd) = self .prepare_config(base_url, &home, test_env.cwd().clone()) .await?; + let exec_server_url = self + .exec_server_url + .clone() + .or_else(|| test_env.exec_server_url().map(str::to_owned)); let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs { - exec_server_url: test_env.exec_server_url().map(str::to_owned), + exec_server_url, local_runtime_paths: None, }, )); @@ -888,6 +898,7 @@ pub fn test_codex() -> TestCodexBuilder { workspace_setups: vec![], home: None, user_shell_override: None, + exec_server_url: None, } } diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index a995e54431c4..a6166f8403bf 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -75,6 +75,66 @@ fn ev_namespaced_function_call( }) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_none_omits_environment_backed_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_exec_server_url("none") + .with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("unified exec should enable for test"); + config + .features + .enable(Feature::JsRepl) + .expect("js repl should enable for test"); + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn("which tools are available?").await?; + + let tools = tool_names(&response_mock.single_request().body_json()); + assert!( + tools.contains(&"update_plan".to_string()), + "non-environment tool should remain available; got {tools:?}" + ); + for environment_tool in [ + "exec_command", + "write_stdin", + "js_repl", + "js_repl_reset", + "apply_patch", + "view_image", + ] { + assert!( + !tools.contains(&environment_tool.to_string()), + "{environment_tool} should be omitted when CODEX_EXEC_SERVER_URL=none; got {tools:?}" + ); + } + assert!( + test.thread_manager + .environment_manager() + .default_environment() + .is_none() + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 53a737dc5708..d8e532e197e4 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -16,25 +16,22 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// Owns the execution/filesystem environments available to a session. /// /// `EnvironmentManager` is the session-scoped registry for concrete -/// environments. It always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. -/// When `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a -/// remote environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default -/// environment. Otherwise the local environment is the default. +/// environments. It creates a local environment under [`LOCAL_ENVIRONMENT_ID`] +/// unless environment access is disabled. When `CODEX_EXEC_SERVER_URL` is set to +/// a websocket URL, it also creates a remote environment under +/// [`REMOTE_ENVIRONMENT_ID`] and makes that the default environment. Otherwise +/// the local environment is the default. /// -/// Setting `CODEX_EXEC_SERVER_URL=none` does not remove the local environment: -/// Codex internals may still use `default_environment()` or explicit -/// `get_environment()` lookups. -/// Instead it disables agent/tool access as reported by -/// `allows_agent_environment_access()`, so shell/filesystem tools can be hidden -/// from the model while internal local filesystem access still works. +/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving +/// the default environment unset. Callers use `default_environment().is_some()` +/// as the signal for model-facing shell/filesystem tool availability. /// /// Remote environments hold a lazy exec-server client handle. The websocket is /// not opened when the manager or environment is constructed; it connects on the /// first remote exec or filesystem operation. #[derive(Debug)] pub struct EnvironmentManager { - default_environment: String, - environment_disabled_for_agent: bool, + default_environment: Option, environments: HashMap>, } @@ -69,54 +66,50 @@ impl EnvironmentManager { exec_server_url, local_runtime_paths, } = args; - let (exec_server_url, environment_disabled_for_agent) = - normalize_exec_server_url(exec_server_url); + let (exec_server_url, environment_disabled) = normalize_exec_server_url(exec_server_url); let mut environments = HashMap::new(); - environments.insert( - LOCAL_ENVIRONMENT_ID.to_string(), - Arc::new( - Environment::create_with_runtime_paths( - /*exec_server_url*/ None, - local_runtime_paths.clone(), - ) - .expect("valid local environment"), - ), - ); - - let default_environment = match exec_server_url { - Some(exec_server_url) => { - environments.insert( - REMOTE_ENVIRONMENT_ID.to_string(), - Arc::new( - Environment::create_with_runtime_paths( - Some(exec_server_url), - local_runtime_paths, - ) - .expect("valid remote environment"), - ), - ); - REMOTE_ENVIRONMENT_ID.to_string() + let default_environment = if environment_disabled { + None + } else { + environments.insert( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new( + Environment::create_with_runtime_paths( + /*exec_server_url*/ None, + local_runtime_paths.clone(), + ) + .expect("valid local environment"), + ), + ); + match exec_server_url { + Some(exec_server_url) => { + environments.insert( + REMOTE_ENVIRONMENT_ID.to_string(), + Arc::new( + Environment::create_with_runtime_paths( + Some(exec_server_url), + local_runtime_paths, + ) + .expect("valid remote environment"), + ), + ); + Some(REMOTE_ENVIRONMENT_ID.to_string()) + } + None => Some(LOCAL_ENVIRONMENT_ID.to_string()), } - None => LOCAL_ENVIRONMENT_ID.to_string(), }; Self { default_environment, - environment_disabled_for_agent, environments, } } - /// Returns true when model-facing tools may access an environment. - pub fn allows_agent_environment_access(&self) -> bool { - !self.environment_disabled_for_agent - && self.environments.contains_key(&self.default_environment) - } - /// Returns the default environment instance. - pub fn default_environment(&self) -> Arc { - self.get_environment(&self.default_environment) - .expect("default environment exists") + pub fn default_environment(&self) -> Option> { + self.default_environment + .as_deref() + .and_then(|environment_id| self.get_environment(environment_id)) } /// Returns a named environment instance. @@ -260,9 +253,8 @@ mod tests { local_runtime_paths: None, }); - let environment = manager.default_environment(); + let environment = manager.default_environment().expect("default environment"); assert!(!environment.is_remote()); - assert!(manager.allows_agent_environment_access()); assert!( !manager .get_environment(LOCAL_ENVIRONMENT_ID) @@ -279,14 +271,8 @@ mod tests { local_runtime_paths: None, }); - assert!(!manager.allows_agent_environment_access()); - assert!(!manager.default_environment().is_remote()); - assert!( - !manager - .get_environment(LOCAL_ENVIRONMENT_ID) - .expect("local environment") - .is_remote() - ); + assert!(manager.default_environment().is_none()); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -297,9 +283,8 @@ mod tests { local_runtime_paths: None, }); - let environment = manager.default_environment(); + let environment = manager.default_environment().expect("default environment"); assert!(environment.is_remote()); - assert!(manager.allows_agent_environment_access()); assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:8765")); assert!( !manager @@ -320,8 +305,8 @@ mod tests { async fn environment_manager_default_environment_caches_environment() { let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); - let first = manager.default_environment(); - let second = manager.default_environment(); + let first = manager.default_environment().expect("default environment"); + let second = manager.default_environment().expect("default environment"); assert!(Arc::ptr_eq(&first, &second)); } @@ -338,43 +323,36 @@ mod tests { local_runtime_paths: Some(runtime_paths.clone()), }); - let environment = manager.default_environment(); + let environment = manager.default_environment().expect("default environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: environment.exec_server_url().map(str::to_owned), local_runtime_paths: environment.local_runtime_paths().cloned(), }); - let environment = manager.default_environment(); + let environment = manager.default_environment().expect("default environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } #[tokio::test] - async fn disabled_environment_manager_has_default_environment_but_no_tool_environment() { + async fn disabled_environment_manager_has_no_default_environment() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); - assert!(!manager.default_environment().is_remote()); - assert!(!manager.allows_agent_environment_access()); + assert!(manager.default_environment().is_none()); } #[tokio::test] - async fn environment_manager_allows_local_lookup_when_disabled() { + async fn environment_manager_omits_environment_lookup_when_disabled() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); - assert!(!manager.default_environment().is_remote()); - assert!(!manager.allows_agent_environment_access()); - assert!( - !manager - .get_environment(LOCAL_ENVIRONMENT_ID) - .expect("local environment") - .is_remote() - ); + assert!(manager.default_environment().is_none()); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 98369a0bbda8..74d692ebb691 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -627,7 +627,9 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if environment_manager.default_environment().is_remote() + if environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()) || matches!(app_server_target, AppServerTarget::Remote { .. }) { return Ok(None); From a6c81a05fd4bb8e2daa46fe40a8a813c165b86eb Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 16:25:55 -0700 Subject: [PATCH 10/38] Fix environment manager follow-up compile errors Co-authored-by: Codex --- codex-rs/core/src/session/handlers.rs | 4 ++-- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/tests.rs | 4 ++-- codex-rs/exec-server/src/client.rs | 2 +- codex-rs/exec-server/src/environment.rs | 8 ++++---- codex-rs/exec-server/src/lib.rs | 1 + codex-rs/tui/src/lib.rs | 21 ++++++++++++--------- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 71efd2332650..c3503af623ff 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -519,8 +519,8 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec, for let plugins_manager = &sess.services.plugins_manager; let fs = sess .services - .environment - .as_ref() + .environment_manager + .default_environment() .map(|environment| environment.get_filesystem()); let config = sess.get_config().await; let codex_home = sess.codex_home().await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9031e776eb0d..ad392efda0a5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -513,7 +513,7 @@ impl Codex { } let user_instructions = AgentsMdManager::new(&config) - .user_instructions(Some(environment.as_ref())) + .user_instructions(environment.as_deref()) .await; let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 48814319ebaf..78947563c00b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2855,8 +2855,8 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { let skill_fs = session .services - .environment - .as_ref() + .environment_manager + .default_environment() .map(|environment| environment.get_filesystem()) .unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS)); let parent_outcome = session diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index c4526e13bd4f..375571b77930 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -175,7 +175,7 @@ pub struct ExecServerClient { inner: Arc, } -#[derive(Clone, Debug)] +#[derive(Clone)] pub(crate) struct LazyRemoteExecServerClient { websocket_url: String, client: Arc>, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d8e532e197e4..f852148eb52c 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -246,8 +246,8 @@ mod tests { assert!(environment.remote_exec_server_client.is_none()); } - #[test] - fn environment_manager_normalizes_empty_url() { + #[tokio::test] + async fn environment_manager_normalizes_empty_url() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some(String::new()), local_runtime_paths: None, @@ -276,8 +276,8 @@ mod tests { assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } - #[test] - fn environment_manager_reports_remote_url() { + #[tokio::test] + async fn environment_manager_reports_remote_url() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), local_runtime_paths: None, diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 067fa0a7c147..fc6a86f50836 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -25,6 +25,7 @@ pub use client_api::RemoteExecServerConnectArgs; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; pub use environment::Environment; pub use environment::EnvironmentManager; +pub use environment::EnvironmentManagerArgs; pub use file_system::CopyOptions; pub use file_system::CreateDirectoryOptions; pub use file_system::ExecutorFileSystem; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 74d692ebb691..a021f11f236c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1928,8 +1928,9 @@ mod tests { Ok(()) } - #[test] - fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { + #[tokio::test] + async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> + { let remote_only_cwd = if cfg!(windows) { Path::new(r"C:\definitely\not\local\to\this\test") } else { @@ -1949,8 +1950,9 @@ mod tests { Ok(()) } - #[test] - fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> { + #[tokio::test] + async fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> + { let temp_dir = TempDir::new()?; let target = AppServerTarget::Embedded; let environment_manager = @@ -1968,9 +1970,9 @@ mod tests { Ok(()) } - #[test] - fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() -> std::io::Result<()> - { + #[tokio::test] + async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() + -> std::io::Result<()> { let temp_dir = TempDir::new()?; let missing = temp_dir.path().join("missing"); let target = AppServerTarget::Embedded; @@ -1984,8 +1986,9 @@ mod tests { Ok(()) } - #[test] - fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() -> std::io::Result<()> { + #[tokio::test] + async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() + -> std::io::Result<()> { let remote_only_cwd = if cfg!(windows) { Path::new(r"C:\definitely\not\local\to\this\test") } else { From e354201dd8d3b7c153751b00264f01a8075d12eb Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 18:27:18 -0700 Subject: [PATCH 11/38] Fix environment manager hardening issues Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 1 + .../app-server/src/codex_message_processor.rs | 23 ++- codex-rs/core/src/session/session.rs | 5 +- codex-rs/exec-server/src/environment.rs | 174 +++++++++++------- .../exec-server/src/remote_file_system.rs | 24 ++- codex-rs/exec-server/src/remote_process.rs | 6 +- 6 files changed, 138 insertions(+), 95 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 726939d292fe..c1072e566938 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -2007,6 +2007,7 @@ mod tests { runtime_args .environment_manager .default_environment() + .expect("default environment") .is_remote() ); } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 08fb559d286d..ba6cf19a87cf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5694,15 +5694,20 @@ impl CodexMessageProcessor { .await; let auth_manager = Arc::clone(&self.auth_manager); let auth = auth_manager.auth().await; - let runtime_environment = { - let environment = self - .thread_manager - .environment_manager() - .default_environment() - .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); - // 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()) + let runtime_environment = match self + .thread_manager + .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( + Arc::new(codex_exec_server::Environment::default()), + config.cwd.to_path_buf(), + ), }; tokio::spawn(async move { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 70cbcb2fd29a..24ba14ca6da6 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -790,8 +790,9 @@ impl Session { tx_event.clone(), session_configuration.sandbox_policy.get().clone(), McpRuntimeEnvironment::new( - environment - .clone() + sess.services + .environment_manager + .default_environment() .unwrap_or_else(|| Arc::new(Environment::default())), session_configuration.cwd.to_path_buf(), ), diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index f852148eb52c..660880f9ae56 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::client::LazyRemoteExecServerClient; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; @@ -13,22 +12,23 @@ use crate::remote_process::RemoteProcess; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; -/// Owns the execution/filesystem environments available to a session. +/// Owns the execution/filesystem environments available to the Codex runtime. /// -/// `EnvironmentManager` is the session-scoped registry for concrete -/// environments. It creates a local environment under [`LOCAL_ENVIRONMENT_ID`] -/// unless environment access is disabled. When `CODEX_EXEC_SERVER_URL` is set to -/// a websocket URL, it also creates a remote environment under -/// [`REMOTE_ENVIRONMENT_ID`] and makes that the default environment. Otherwise -/// the local environment is the default. +/// `EnvironmentManager` is a shared registry for concrete environments. It +/// always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. When +/// `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a remote +/// environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default +/// environment. Otherwise the local environment is the default. /// /// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving -/// the default environment unset. Callers use `default_environment().is_some()` -/// as the signal for model-facing shell/filesystem tool availability. +/// the default environment unset while still keeping the local environment +/// available for internal callers by id. Callers use +/// `default_environment().is_some()` as the signal for model-facing +/// shell/filesystem tool availability. /// -/// Remote environments hold a lazy exec-server client handle. The websocket is -/// not opened when the manager or environment is constructed; it connects on the -/// first remote exec or filesystem operation. +/// Remote environments create remote filesystem and execution backends that +/// lazy-connect to the configured exec-server on first use. The websocket is +/// not opened when the manager or environment is constructed. #[derive(Debug)] pub struct EnvironmentManager { default_environment: Option, @@ -44,6 +44,15 @@ pub struct EnvironmentManagerArgs { pub local_runtime_paths: Option, } +impl From> for EnvironmentManagerArgs { + fn from(exec_server_url: Option) -> Self { + Self { + exec_server_url, + local_runtime_paths: None, + } + } +} + impl Default for EnvironmentManager { fn default() -> Self { Self::new(EnvironmentManagerArgs::default()) @@ -61,37 +70,30 @@ impl EnvironmentManager { /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local /// runtime paths used when creating local filesystem helpers. - pub fn new(args: EnvironmentManagerArgs) -> Self { + pub fn new(exec_server_url: impl Into) -> Self { + let args = exec_server_url.into(); let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, } = args; let (exec_server_url, environment_disabled) = normalize_exec_server_url(exec_server_url); - let mut environments = HashMap::new(); + let mut environments = HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::local_with_runtime_paths( + local_runtime_paths.clone(), + )), + )]); let default_environment = if environment_disabled { None } else { - environments.insert( - LOCAL_ENVIRONMENT_ID.to_string(), - Arc::new( - Environment::create_with_runtime_paths( - /*exec_server_url*/ None, - local_runtime_paths.clone(), - ) - .expect("valid local environment"), - ), - ); match exec_server_url { Some(exec_server_url) => { environments.insert( REMOTE_ENVIRONMENT_ID.to_string(), - Arc::new( - Environment::create_with_runtime_paths( - Some(exec_server_url), - local_runtime_paths, - ) - .expect("valid remote environment"), - ), + Arc::new(Environment::remote_with_runtime_paths( + exec_server_url, + local_runtime_paths, + )), ); Some(REMOTE_ENVIRONMENT_ID.to_string()) } @@ -120,13 +122,13 @@ impl EnvironmentManager { /// Concrete execution/filesystem environment selected for a session. /// -/// This bundles the selected backend together with the corresponding remote -/// client, if any. +/// This bundles the selected backend metadata together with the local runtime +/// paths used by filesystem helpers. #[derive(Clone)] pub struct Environment { exec_server_url: Option, - remote_exec_server_client: Option, exec_backend: Arc, + filesystem: Arc, local_runtime_paths: Option, } @@ -134,8 +136,8 @@ impl Default for Environment { fn default() -> Self { Self { exec_server_url: None, - remote_exec_server_client: None, exec_backend: Arc::new(LocalProcess::default()), + filesystem: Arc::new(LocalFileSystem::unsandboxed()), local_runtime_paths: None, } } @@ -168,25 +170,43 @@ impl Environment { )); } - let remote_exec_server_client = if let Some(exec_server_url) = exec_server_url.clone() { - Some(LazyRemoteExecServerClient::new(exec_server_url)) - } else { - None + Ok(match exec_server_url { + Some(exec_server_url) => { + Self::remote_with_runtime_paths(exec_server_url, local_runtime_paths) + } + None => Self::local_with_runtime_paths(local_runtime_paths), + }) + } + + fn local_with_runtime_paths(local_runtime_paths: Option) -> Self { + let filesystem: Arc = match local_runtime_paths.clone() { + Some(runtime_paths) => Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)), + None => Arc::new(LocalFileSystem::unsandboxed()), }; + Self { + exec_server_url: None, + exec_backend: Arc::new(LocalProcess::default()), + filesystem, + local_runtime_paths, + } + } + + fn remote_with_runtime_paths( + exec_server_url: String, + local_runtime_paths: Option, + ) -> Self { let exec_backend: Arc = - if let Some(client) = remote_exec_server_client.clone() { - Arc::new(RemoteProcess::new(client)) - } else { - Arc::new(LocalProcess::default()) - }; + Arc::new(RemoteProcess::new(exec_server_url.clone())); + let filesystem: Arc = + Arc::new(RemoteFileSystem::new(exec_server_url.clone())); - Ok(Self { - exec_server_url, - remote_exec_server_client, + Self { + exec_server_url: Some(exec_server_url), exec_backend, + filesystem, local_runtime_paths, - }) + } } pub fn is_remote(&self) -> bool { @@ -207,13 +227,7 @@ impl Environment { } pub fn get_filesystem(&self) -> Arc { - match self.remote_exec_server_client.clone() { - Some(client) => Arc::new(RemoteFileSystem::new(client)), - None => match self.local_runtime_paths.clone() { - Some(runtime_paths) => Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)), - None => Arc::new(LocalFileSystem::unsandboxed()), - }, - } + Arc::clone(&self.filesystem) } } @@ -243,7 +257,7 @@ mod tests { Environment::create(/*exec_server_url*/ None).expect("create environment"); assert_eq!(environment.exec_server_url(), None); - assert!(environment.remote_exec_server_client.is_none()); + assert!(!environment.is_remote()); } #[tokio::test] @@ -264,15 +278,20 @@ mod tests { assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } - #[test] - fn environment_manager_treats_none_value_as_disabled() { + #[tokio::test] + async fn environment_manager_treats_none_value_as_disabled() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); assert!(manager.default_environment().is_none()); - assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } @@ -286,19 +305,27 @@ mod tests { let environment = manager.default_environment().expect("default environment"); assert!(environment.is_remote()); assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:8765")); + assert!(Arc::ptr_eq( + &environment, + &manager + .get_environment(REMOTE_ENVIRONMENT_ID) + .expect("remote environment") + )); assert!( !manager .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") .is_remote() ); - assert_eq!( - manager - .get_environment(REMOTE_ENVIRONMENT_ID) - .expect("remote environment") - .exec_server_url(), - Some("ws://127.0.0.1:8765") - ); + } + + #[test] + fn create_remote_environment_does_not_connect() { + let environment = + Environment::create(Some("ws://127.0.0.1:9".to_string())).expect("create environment"); + + assert!(environment.is_remote()); + assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:9")); } #[tokio::test] @@ -309,6 +336,10 @@ mod tests { let second = manager.default_environment().expect("default environment"); assert!(Arc::ptr_eq(&first, &second)); + assert!(Arc::ptr_eq( + &first.get_filesystem(), + &second.get_filesystem() + )); } #[tokio::test] @@ -345,14 +376,19 @@ mod tests { } #[tokio::test] - async fn environment_manager_omits_environment_lookup_when_disabled() { + async fn environment_manager_keeps_local_lookup_when_default_disabled() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), local_runtime_paths: None, }); assert!(manager.default_environment().is_none()); - assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); } diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 02a5e2883686..d06f9acdd1ba 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -32,13 +32,11 @@ pub(crate) struct RemoteFileSystem { } impl RemoteFileSystem { - pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { + pub(crate) fn new(websocket_url: String) -> Self { trace!("remote fs new"); - Self { client } - } - - async fn client(&self) -> FileSystemResult { - self.client.get().await.map_err(map_remote_error) + Self { + client: LazyRemoteExecServerClient::new(websocket_url), + } } } @@ -50,7 +48,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_file"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; let response = client .fs_read_file(FsReadFileParams { path: path.clone(), @@ -73,7 +71,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs write_file"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; client .fs_write_file(FsWriteFileParams { path: path.clone(), @@ -92,7 +90,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs create_directory"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; client .fs_create_directory(FsCreateDirectoryParams { path: path.clone(), @@ -110,7 +108,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult { trace!("remote fs get_metadata"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; let response = client .fs_get_metadata(FsGetMetadataParams { path: path.clone(), @@ -133,7 +131,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_directory"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; let response = client .fs_read_directory(FsReadDirectoryParams { path: path.clone(), @@ -159,7 +157,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs remove"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; client .fs_remove(FsRemoveParams { path: path.clone(), @@ -180,7 +178,7 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs copy"); - let client = self.client().await?; + let client = self.client.get().await.map_err(map_remote_error)?; client .fs_copy(FsCopyParams { source_path: source_path.clone(), diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index d8d06735cdb9..9de649a274ef 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -25,9 +25,11 @@ struct RemoteExecProcess { } impl RemoteProcess { - pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { + pub(crate) fn new(websocket_url: String) -> Self { trace!("remote process new"); - Self { client } + Self { + client: LazyRemoteExecServerClient::new(websocket_url), + } } } From 154be3fc661ee3832eb59f58e0b78ab4b93c53a0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 15:50:46 -0700 Subject: [PATCH 12/38] codex: remove low-value environment test Co-authored-by: Codex --- codex-rs/exec-server/src/environment.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 660880f9ae56..d7e09d7da9f0 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -319,15 +319,6 @@ mod tests { ); } - #[test] - fn create_remote_environment_does_not_connect() { - let environment = - Environment::create(Some("ws://127.0.0.1:9".to_string())).expect("create environment"); - - assert!(environment.is_remote()); - assert_eq!(environment.exec_server_url(), Some("ws://127.0.0.1:9")); - } - #[tokio::test] async fn environment_manager_default_environment_caches_environment() { let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); From 0642d36ae4a9454f94ab33f2a861d5d3cf606fa7 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 11:13:27 -0700 Subject: [PATCH 13/38] Share remote environment exec-server client Create one lazy exec-server client per remote environment and pass clones into the remote process and filesystem backends. This keeps ExecServerClient as the connected-client type while avoiding duplicate websocket clients for one environment. Co-authored-by: Codex --- codex-rs/exec-server/src/environment.rs | 8 ++++---- codex-rs/exec-server/src/remote_file_system.rs | 6 ++---- codex-rs/exec-server/src/remote_process.rs | 6 ++---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d7e09d7da9f0..9fe2eb530090 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::ExecServerError; use crate::ExecServerRuntimePaths; +use crate::client::LazyRemoteExecServerClient; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; @@ -196,10 +197,9 @@ impl Environment { exec_server_url: String, local_runtime_paths: Option, ) -> Self { - let exec_backend: Arc = - Arc::new(RemoteProcess::new(exec_server_url.clone())); - let filesystem: Arc = - Arc::new(RemoteFileSystem::new(exec_server_url.clone())); + let client = LazyRemoteExecServerClient::new(exec_server_url.clone()); + let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); + let filesystem: Arc = Arc::new(RemoteFileSystem::new(client)); Self { exec_server_url: Some(exec_server_url), diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index d06f9acdd1ba..dc269505a1d4 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -32,11 +32,9 @@ pub(crate) struct RemoteFileSystem { } impl RemoteFileSystem { - pub(crate) fn new(websocket_url: String) -> Self { + pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { trace!("remote fs new"); - Self { - client: LazyRemoteExecServerClient::new(websocket_url), - } + Self { client } } } diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 9de649a274ef..d8d06735cdb9 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -25,11 +25,9 @@ struct RemoteExecProcess { } impl RemoteProcess { - pub(crate) fn new(websocket_url: String) -> Self { + pub(crate) fn new(client: LazyRemoteExecServerClient) -> Self { trace!("remote process new"); - Self { - client: LazyRemoteExecServerClient::new(websocket_url), - } + Self { client } } } From f748352bab4350c97926ec340ef6b2c154c62b21 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 11:21:04 -0700 Subject: [PATCH 14/38] Hide environment manager env parsing Make EnvironmentManagerArgs::default() own CODEX_EXEC_SERVER_URL parsing so production entrypoints can keep using EnvironmentManager::new with struct update syntax for runtime paths. Add explicit test defaults so test managers do not depend on the process environment. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 2 +- .../app-server/src/bespoke_event_handling.rs | 2 +- codex-rs/app-server/src/in_process.rs | 2 +- codex-rs/app-server/src/lib.rs | 3 +- .../src/message_processor/tracing_tests.rs | 2 +- .../app-server/tests/suite/v2/mcp_resource.rs | 2 +- codex-rs/core/src/agent/control_tests.rs | 14 ++++---- codex-rs/core/src/memories/tests.rs | 2 +- codex-rs/core/src/prompt_debug.rs | 3 +- codex-rs/core/src/session/tests.rs | 8 ++--- .../core/src/session/tests/guardian_tests.rs | 2 +- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/src/thread_manager_tests.rs | 10 +++--- codex-rs/core/tests/suite/client.rs | 2 +- codex-rs/exec-server/src/environment.rs | 32 +++++++++++++------ codex-rs/exec/src/lib.rs | 3 +- codex-rs/mcp-server/src/lib.rs | 3 +- codex-rs/tui/src/app/test_support.rs | 2 +- codex-rs/tui/src/app/tests.rs | 4 +-- codex-rs/tui/src/lib.rs | 15 ++++----- codex-rs/tui/src/onboarding/auth.rs | 2 +- 21 files changed, 64 insertions(+), 53 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c1072e566938..7bc5ccca93e1 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -971,7 +971,7 @@ mod tests { feedback: CodexFeedback::new(), log_db: None, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), config_warnings: Vec::new(), session_source, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 806652a7d6e5..8d85fa7000b9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3498,7 +3498,7 @@ mod tests { config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ), ); diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 8c604bcd8bfd..d549956b5f70 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -739,7 +739,7 @@ mod tests { feedback: CodexFeedback::new(), log_db: None, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), config_warnings: Vec::new(), session_source, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c88c681e27a1..0ebf22f4a16e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,7 +8,6 @@ use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; -use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; @@ -364,11 +363,11 @@ pub async fn run_main_with_transport( auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?), + ..EnvironmentManagerArgs::default() })); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 16d14c841391..6f81532cefa8 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -242,7 +242,7 @@ fn build_test_processor( arg0_paths: Arg0DispatchPaths::default(), config, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 4bc412d54e8d..7af4682cadb2 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -205,7 +205,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { feedback: CodexFeedback::new(), log_db: None, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 15eb23d0cdbb..567ee1a029ab 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -96,7 +96,7 @@ impl AgentControlHarness { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -912,7 +912,7 @@ async fn spawn_agent_respects_max_threads_limit() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -966,7 +966,7 @@ async fn spawn_agent_releases_slot_after_shutdown() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -1011,7 +1011,7 @@ async fn spawn_agent_limit_shared_across_clones() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -1058,7 +1058,7 @@ async fn resume_agent_respects_max_threads_limit() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -1116,7 +1116,7 @@ async fn resume_agent_releases_slot_after_resume_failure() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); @@ -1513,7 +1513,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let control = manager.agent_control(); diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 1048b3da869a..18cec7c15714 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -492,7 +492,7 @@ mod phase2 { config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let (mut session, _turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 9717163df2db..73d8c2ae14a9 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -38,7 +39,7 @@ pub async fn build_prompt_input( .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(EnvironmentManager::from_env()), + Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::default())), /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 78947563c00b..0726d6ba06d3 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3082,7 +3082,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), - Arc::new(codex_exec_server::EnvironmentManager::default()), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ) .await; @@ -3244,7 +3244,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -3400,7 +3400,7 @@ async fn make_session_with_config_and_rx( mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), - Arc::new(codex_exec_server::EnvironmentManager::default()), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ) .await?; @@ -4342,7 +4342,7 @@ where code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default()), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 117223d429bf..32b9a5799b1a 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -635,7 +635,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), skills_manager, plugins_manager, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index c5da648bf503..5dc8a2b79ec8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -302,7 +302,7 @@ impl ThreadManager { provider, codex_home.clone(), Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 76dd62bb9a56..8b6f2c3048b0 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -247,7 +247,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { config.model_provider.clone(), config.codex_home.to_path_buf(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ); let thread_1 = manager @@ -298,7 +298,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { SessionSource::Exec, CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), /*analytics_events_client*/ None, ); @@ -435,7 +435,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor SessionSource::Exec, CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), /*analytics_events_client*/ None, ); @@ -538,7 +538,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { SessionSource::Exec, CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), /*analytics_events_client*/ None, ); @@ -631,7 +631,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ SessionSource::Exec, CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3f0a7762ba23..ae8fbc102444 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1104,7 +1104,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .enabled(Feature::DefaultModeRequestUserInput), }, Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), /*analytics_events_client*/ None, ); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9fe2eb530090..2182ad9197b9 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -39,12 +39,30 @@ pub struct EnvironmentManager { pub const LOCAL_ENVIRONMENT_ID: &str = "local"; pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct EnvironmentManagerArgs { pub exec_server_url: Option, pub local_runtime_paths: Option, } +impl Default for EnvironmentManagerArgs { + fn default() -> Self { + Self { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths: None, + } + } +} + +impl EnvironmentManagerArgs { + pub fn default_for_tests() -> Self { + Self { + exec_server_url: None, + local_runtime_paths: None, + } + } +} + impl From> for EnvironmentManagerArgs { fn from(exec_server_url: Option) -> Self { Self { @@ -61,12 +79,8 @@ impl Default for EnvironmentManager { } impl EnvironmentManager { - /// Builds a manager from process environment variables. - pub fn from_env() -> Self { - Self::new(EnvironmentManagerArgs { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), - local_runtime_paths: None, - }) + pub fn default_for_tests() -> Self { + Self::new(EnvironmentManagerArgs::default_for_tests()) } /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local @@ -321,7 +335,7 @@ mod tests { #[tokio::test] async fn environment_manager_default_environment_caches_environment() { - let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); + let manager = EnvironmentManager::new(EnvironmentManagerArgs::default_for_tests()); let first = manager.default_environment().expect("default environment"); let second = manager.default_environment().expect("default environment"); @@ -385,7 +399,7 @@ mod tests { #[tokio::test] async fn get_environment_returns_none_for_unknown_id() { - let manager = EnvironmentManager::new(EnvironmentManagerArgs::default()); + let manager = EnvironmentManager::new(EnvironmentManagerArgs::default_for_tests()); assert!(manager.get_environment("does-not-exist").is_none()); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 4cb80a1543e6..1404348af193 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -13,7 +13,6 @@ pub(crate) mod exec_events; pub use cli::Cli; pub use cli::Command; pub use cli::ReviewArgs; -use codex_app_server_client::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::EnvironmentManager; use codex_app_server_client::EnvironmentManagerArgs; @@ -500,8 +499,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result feedback: CodexFeedback::new(), log_db: None, environment_manager: std::sync::Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths: Some(local_runtime_paths), + ..EnvironmentManagerArgs::default() })), config_warnings, session_source: SessionSource::Exec, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 2eb93130f135..7577818fc4af 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -7,7 +7,6 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; -use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -62,11 +61,11 @@ pub async fn run_main( cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?), + ..EnvironmentManagerArgs::default() })); // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 8b2c22512ef8..a48739dedc43 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -39,7 +39,7 @@ pub(super) async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), remote_app_server_url: None, remote_app_server_auth_token: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 56d093598fac..5237ce7d9325 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3577,7 +3577,7 @@ async fn make_test_app() -> App { feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), remote_app_server_url: None, remote_app_server_auth_token: None, @@ -3636,7 +3636,7 @@ async fn make_test_app_with_channels() -> ( feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), remote_app_server_url: None, remote_app_server_auth_token: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a021f11f236c..930adeb57dba 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -35,7 +35,6 @@ use codex_config::CloudRequirementsLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; -use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -428,7 +427,7 @@ pub(crate) async fn start_embedded_app_server_for_picker( config, &AppServerTarget::Embedded, Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ) .await @@ -733,11 +732,11 @@ pub async fn run_main( }; let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?), + ..EnvironmentManagerArgs::default() })); let cwd = cli.cwd.clone(); let config_cwd = @@ -1779,7 +1778,7 @@ mod tests { codex_feedback::CodexFeedback::new(), /*log_db*/ None, Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), ) .await @@ -1941,7 +1940,7 @@ mod tests { auth_token: None, }; let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -1956,7 +1955,7 @@ mod tests { let temp_dir = TempDir::new()?; let target = AppServerTarget::Embedded; let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); let config_cwd = config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; @@ -1977,7 +1976,7 @@ mod tests { let missing = temp_dir.path().join("missing"); let target = AppServerTarget::Embedded; let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default()); + EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager) .expect_err("missing embedded cwd should fail"); @@ -2127,7 +2126,7 @@ mod tests { codex_feedback::CodexFeedback::new(), /*log_db*/ None, Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default(), + codex_exec_server::EnvironmentManagerArgs::default_for_tests(), )), |_args| async { Err(std::io::Error::other("boom")) }, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2379c2d55dbb..380f13580a85 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -990,7 +990,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), log_db: None, environment_manager: Arc::new(codex_app_server_client::EnvironmentManager::new( - codex_app_server_client::EnvironmentManagerArgs::default(), + codex_app_server_client::EnvironmentManagerArgs::default_for_tests(), )), config_warnings: Vec::new(), session_source: SessionSource::Cli, From 6967e3f100fd2646d6ef91422d165c8b5c836325 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 11:47:30 -0700 Subject: [PATCH 15/38] Remove redundant environment-backed tools test Drop the networked integration test for CODEX_EXEC_SERVER_URL=none omitting environment-backed tools. Lower-level coverage already verifies disabled environments omit those tools. Co-authored-by: Codex --- codex-rs/core/tests/suite/tools.rs | 60 ------------------------------ 1 file changed, 60 deletions(-) diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index a6166f8403bf..a995e54431c4 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -75,66 +75,6 @@ fn ev_namespaced_function_call( }) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_server_none_omits_environment_backed_tools() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let response_mock = mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-1"), - ]), - ) - .await; - - let mut builder = test_codex() - .with_exec_server_url("none") - .with_config(|config| { - config - .features - .enable(Feature::UnifiedExec) - .expect("unified exec should enable for test"); - config - .features - .enable(Feature::JsRepl) - .expect("js repl should enable for test"); - config.include_apply_patch_tool = true; - }); - let test = builder.build(&server).await?; - - test.submit_turn("which tools are available?").await?; - - let tools = tool_names(&response_mock.single_request().body_json()); - assert!( - tools.contains(&"update_plan".to_string()), - "non-environment tool should remain available; got {tools:?}" - ); - for environment_tool in [ - "exec_command", - "write_stdin", - "js_repl", - "js_repl_reset", - "apply_patch", - "view_image", - ] { - assert!( - !tools.contains(&environment_tool.to_string()), - "{environment_tool} should be omitted when CODEX_EXEC_SERVER_URL=none; got {tools:?}" - ); - } - assert!( - test.thread_manager - .environment_manager() - .default_environment() - .is_none() - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { skip_if_no_network!(Ok(())); From fc7a440ec9b8190e90ef3105a79fea62f9432f3c Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 12:44:35 -0700 Subject: [PATCH 16/38] Require runtime paths for environments Make EnvironmentManagerArgs carry ExecServerRuntimePaths for production construction and route test-only unsandboxed setup through explicit _for_tests helpers. Use the manager local environment for MCP and app-server filesystem fallbacks instead of constructing a fresh default environment. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 10 +- .../app-server/src/bespoke_event_handling.rs | 4 +- .../app-server/src/codex_message_processor.rs | 16 +- codex-rs/app-server/src/fs_api.rs | 11 +- codex-rs/app-server/src/in_process.rs | 4 +- codex-rs/app-server/src/lib.rs | 9 +- codex-rs/app-server/src/message_processor.rs | 7 +- .../src/message_processor/tracing_tests.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 4 +- codex-rs/core/src/agent/control_tests.rs | 28 +-- codex-rs/core/src/connectors.rs | 16 +- codex-rs/core/src/memories/tests.rs | 4 +- codex-rs/core/src/prompt_debug.rs | 10 +- codex-rs/core/src/session/mcp.rs | 2 +- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 4 +- .../core/src/session/tests/guardian_tests.rs | 4 +- codex-rs/core/src/thread_manager.rs | 4 +- codex-rs/core/src/thread_manager_tests.rs | 20 +-- codex-rs/core/src/unified_exec/mod_tests.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 11 +- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/skills.rs | 6 +- codex-rs/exec-server/src/environment.rs | 167 +++++++++++------- codex-rs/exec-server/tests/exec_process.rs | 4 +- codex-rs/exec-server/tests/file_system.rs | 4 +- codex-rs/exec/src/lib.rs | 7 +- codex-rs/mcp-server/src/lib.rs | 9 +- codex-rs/tui/src/app/test_support.rs | 4 +- codex-rs/tui/src/app/tests.rs | 8 +- codex-rs/tui/src/lib.rs | 35 ++-- codex-rs/tui/src/onboarding/auth.rs | 6 +- 32 files changed, 216 insertions(+), 214 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 7bc5ccca93e1..fdd90f9281c1 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -970,9 +970,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, @@ -1975,7 +1973,11 @@ mod tests { let config = Arc::new(build_test_config().await); let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), - local_runtime_paths: None, + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), })); let runtime_args = InProcessClientStartArgs { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8d85fa7000b9..56cd688d2a4e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3497,9 +3497,7 @@ mod tests { CodexAuth::create_dummy_chatgpt_auth_for_testing(), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ), ); let codex_core::NewThread { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ba6cf19a87cf..54c10f670cd3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5694,18 +5694,15 @@ impl CodexMessageProcessor { .await; let auth_manager = Arc::clone(&self.auth_manager); let auth = auth_manager.auth().await; - let runtime_environment = match self - .thread_manager - .environment_manager() - .default_environment() - { + 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( - Arc::new(codex_exec_server::Environment::default()), + environment_manager.local_environment(), config.cwd.to_path_buf(), ), }; @@ -5858,11 +5855,10 @@ impl CodexMessageProcessor { .await; let auth = self.auth_manager.auth().await; let runtime_environment = { - let environment = self - .thread_manager - .environment_manager() + let environment_manager = self.thread_manager.environment_manager(); + let environment = environment_manager .default_environment() - .unwrap_or_else(|| Arc::new(codex_exec_server::Environment::default())); + .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()) diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index a2c71871db70..93b4f21c2b3b 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -20,7 +20,6 @@ use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; -use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use std::io; @@ -31,15 +30,11 @@ pub(crate) struct FsApi { file_system: Arc, } -impl Default for FsApi { - fn default() -> Self { - Self { - file_system: Environment::default().get_filesystem(), - } +impl FsApi { + pub(crate) fn new(file_system: Arc) -> Self { + Self { file_system } } -} -impl FsApi { pub(crate) async fn read_file( &self, params: FsReadFileParams, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d549956b5f70..924398037bb5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -738,9 +738,7 @@ mod tests { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0ebf22f4a16e..d3e874c5c44f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -362,13 +362,12 @@ pub async fn run_main_with_transport( session_source: SessionSource, auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( + ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?), - ..EnvironmentManagerArgs::default() - })); + )?, + ))); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 53d9f2df4ce3..7fb0df42f60c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -327,7 +327,12 @@ impl MessageProcessor { ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.to_path_buf()); - let fs_api = FsApi::default(); + let fs_api = FsApi::new( + thread_manager + .environment_manager() + .local_environment() + .get_filesystem(), + ); let fs_watch_manager = FsWatchManager::new(outgoing.clone()); Self { diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 6f81532cefa8..42ac4d85968b 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -241,9 +241,7 @@ fn build_test_processor( outgoing, arg0_paths: Arg0DispatchPaths::default(), config, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 7af4682cadb2..a347d87fc763 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -204,9 +204,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, enable_codex_api_key_env: false, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 567ee1a029ab..1a5c38723f8b 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -95,9 +95,7 @@ impl AgentControlHarness { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); Self { @@ -911,9 +909,7 @@ async fn spawn_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -965,9 +961,7 @@ async fn spawn_agent_releases_slot_after_shutdown() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1010,9 +1004,7 @@ async fn spawn_agent_limit_shared_across_clones() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); let cloned = control.clone(); @@ -1057,9 +1049,7 @@ async fn resume_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1115,9 +1105,7 @@ async fn resume_agent_releases_slot_after_resume_failure() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1512,9 +1500,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); let harness = AgentControlHarness { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 965521ae3d9e..b4576f26af2e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -13,7 +13,9 @@ pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_connectors::AllConnectorsCacheKey; use codex_connectors::DirectoryListResponse; -use codex_exec_server::Environment; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentManagerArgs; +use codex_exec_server::ExecServerRuntimePaths; use codex_login::token_data::TokenData; use codex_protocol::protocol::SandboxPolicy; use codex_tools::DiscoverableTool; @@ -247,6 +249,16 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( let (tx_event, rx_event) = unbounded(); drop(rx_event); + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + config.codex_self_exe.clone(), + config.codex_linux_sandbox_exe.clone(), + )?; + let environment_manager = + EnvironmentManager::new(EnvironmentManagerArgs::from_env(local_runtime_paths)); + let environment = environment_manager + .default_environment() + .unwrap_or_else(|| environment_manager.local_environment()); + let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -255,7 +267,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( INITIAL_SUBMIT_ID.to_owned(), tx_event, SandboxPolicy::new_read_only_policy(), - McpRuntimeEnvironment::new(Arc::new(Environment::default()), config.cwd.to_path_buf()), + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), ToolPluginProvenance::default(), diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 18cec7c15714..1b5614d31451 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -491,9 +491,7 @@ mod phase2 { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let (mut session, _turn_context) = make_session_and_context().await; session.services.state_db = Some(Arc::clone(&state_db)); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 73d8c2ae14a9..1f62c2b08845 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; +use codex_exec_server::ExecServerRuntimePaths; use codex_features::Feature; use codex_login::AuthManager; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -30,6 +31,11 @@ pub async fn build_prompt_input( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + config.codex_self_exe.clone(), + config.codex_linux_sandbox_exe.clone(), + )?; + let thread_manager = ThreadManager::new( &config, Arc::clone(&auth_manager), @@ -39,7 +45,9 @@ pub async fn build_prompt_input( .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::default())), + Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( + local_runtime_paths, + ))), /*analytics_events_client*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index be7504d9e79f..0696d9db0c0c 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -250,7 +250,7 @@ impl Session { turn_context .environment .clone() - .unwrap_or_else(|| Arc::new(Environment::default())), + .unwrap_or_else(|| self.services.environment_manager.local_environment()), turn_context.cwd.to_path_buf(), ), config.codex_home.to_path_buf(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 24ba14ca6da6..0c8ab535f513 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -793,7 +793,7 @@ impl Session { sess.services .environment_manager .default_environment() - .unwrap_or_else(|| Arc::new(Environment::default())), + .unwrap_or_else(|| sess.services.environment_manager.local_environment()), session_configuration.cwd.to_path_buf(), ), config.codex_home.to_path_buf(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0726d6ba06d3..2479daae15ba 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3179,7 +3179,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( - codex_exec_server::Environment::create(/*exec_server_url*/ None) + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) .expect("create environment"), ); @@ -4277,7 +4277,7 @@ where )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( - codex_exec_server::Environment::create(/*exec_server_url*/ None) + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) .expect("create environment"), ); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 32b9a5799b1a..5fb7fe33160c 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -634,9 +634,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config, auth_manager, models_manager, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), skills_manager, plugins_manager, mcp_manager, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 5dc8a2b79ec8..066bfe316525 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -301,9 +301,7 @@ impl ThreadManager { auth, provider, codex_home.clone(), - Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(EnvironmentManager::default_for_tests()), ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 8b6f2c3048b0..4dcc29f562fd 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -246,9 +246,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let thread_1 = manager .start_thread(config.clone()) @@ -297,9 +295,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { auth_manager, SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -434,9 +430,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -537,9 +531,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -630,9 +622,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ auth_manager.clone(), SessionSource::Exec, CollaborationModesConfig::default(), - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 9877b2cb9fcd..1865188d3e7d 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -508,7 +508,7 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { shell_env(), ); - let environment = codex_exec_server::Environment::default(); + let environment = codex_exec_server::Environment::default_for_tests(); let process = UnifiedExecProcessManager::default() .open_session_with_exec_env( /*process_id*/ 1234, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 67d1a49968fd..73219423b5a5 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -76,7 +76,8 @@ impl TestEnv { pub async fn local() -> Result { let local_cwd_temp_dir = Arc::new(TempDir::new()?); let cwd = local_cwd_temp_dir.abs(); - let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None)?; + let environment = + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None)?; Ok(Self { environment, cwd, @@ -115,7 +116,8 @@ pub async fn test_env() -> Result { match get_remote_test_env() { Some(remote_env) => { let websocket_url = remote_exec_server_url()?; - let environment = codex_exec_server::Environment::create(Some(websocket_url))?; + let environment = + codex_exec_server::Environment::create_for_tests(Some(websocket_url))?; let cwd = remote_aware_cwd_path(); environment .get_filesystem() @@ -363,7 +365,10 @@ impl TestCodexBuilder { let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs { exec_server_url, - local_runtime_paths: None, + local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, }, )); let file_system = test_env.environment().get_filesystem(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ae8fbc102444..2ebd49d53e11 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1103,9 +1103,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 090f7a57903c..59d28b61fc62 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -5,6 +5,7 @@ use anyhow::Result; use codex_core::ThreadManager; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::EnvironmentManager; +use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; use codex_login::CodexAuth; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -237,7 +238,10 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { Arc::new(EnvironmentManager::new( codex_exec_server::EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), - local_runtime_paths: None, + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, }, )), /*analytics_events_client*/ None, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 2182ad9197b9..9cc18ea7be22 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -42,51 +42,40 @@ pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; #[derive(Clone, Debug)] pub struct EnvironmentManagerArgs { pub exec_server_url: Option, - pub local_runtime_paths: Option, -} - -impl Default for EnvironmentManagerArgs { - fn default() -> Self { - Self { - exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), - local_runtime_paths: None, - } - } + pub local_runtime_paths: ExecServerRuntimePaths, } impl EnvironmentManagerArgs { - pub fn default_for_tests() -> Self { + pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { exec_server_url: None, - local_runtime_paths: None, + local_runtime_paths, } } -} -impl From> for EnvironmentManagerArgs { - fn from(exec_server_url: Option) -> Self { + pub fn from_env(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { - exec_server_url, - local_runtime_paths: None, + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths, } } } -impl Default for EnvironmentManager { - fn default() -> Self { - Self::new(EnvironmentManagerArgs::default()) - } -} - impl EnvironmentManager { + /// Builds a test-only manager without configured sandbox helper paths. pub fn default_for_tests() -> Self { - Self::new(EnvironmentManagerArgs::default_for_tests()) + Self { + default_environment: Some(LOCAL_ENVIRONMENT_ID.to_string()), + environments: HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::default_for_tests()), + )]), + } } /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local /// runtime paths used when creating local filesystem helpers. - pub fn new(exec_server_url: impl Into) -> Self { - let args = exec_server_url.into(); + pub fn new(args: EnvironmentManagerArgs) -> Self { let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, @@ -94,9 +83,7 @@ impl EnvironmentManager { let (exec_server_url, environment_disabled) = normalize_exec_server_url(exec_server_url); let mut environments = HashMap::from([( LOCAL_ENVIRONMENT_ID.to_string(), - Arc::new(Environment::local_with_runtime_paths( - local_runtime_paths.clone(), - )), + Arc::new(Environment::local(local_runtime_paths.clone())), )]); let default_environment = if environment_disabled { None @@ -105,10 +92,7 @@ impl EnvironmentManager { Some(exec_server_url) => { environments.insert( REMOTE_ENVIRONMENT_ID.to_string(), - Arc::new(Environment::remote_with_runtime_paths( - exec_server_url, - local_runtime_paths, - )), + Arc::new(Environment::remote(exec_server_url, local_runtime_paths)), ); Some(REMOTE_ENVIRONMENT_ID.to_string()) } @@ -129,6 +113,12 @@ impl EnvironmentManager { .and_then(|environment_id| self.get_environment(environment_id)) } + /// Returns the local environment instance used for internal runtime work. + pub fn local_environment(&self) -> Arc { + self.get_environment(LOCAL_ENVIRONMENT_ID) + .expect("EnvironmentManager always has a local environment") + } + /// Returns a named environment instance. pub fn get_environment(&self, environment_id: &str) -> Option> { self.environments.get(environment_id).cloned() @@ -147,8 +137,9 @@ pub struct Environment { local_runtime_paths: Option, } -impl Default for Environment { - fn default() -> Self { +impl Environment { + /// Builds a test-only local environment without configured sandbox helper paths. + pub fn default_for_tests() -> Self { Self { exec_server_url: None, exec_backend: Arc::new(LocalProcess::default()), @@ -168,13 +159,21 @@ impl std::fmt::Debug for Environment { impl Environment { /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value. - pub fn create(exec_server_url: Option) -> Result { - Self::create_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None) + pub fn create( + exec_server_url: Option, + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + Self::create_inner(exec_server_url, Some(local_runtime_paths)) + } + + /// Builds a test-only environment without configured sandbox helper paths. + pub fn create_for_tests(exec_server_url: Option) -> Result { + Self::create_inner(exec_server_url, /*local_runtime_paths*/ None) } /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and /// local runtime paths used when creating local filesystem helpers. - pub(crate) fn create_with_runtime_paths( + fn create_inner( exec_server_url: Option, local_runtime_paths: Option, ) -> Result { @@ -186,28 +185,30 @@ impl Environment { } Ok(match exec_server_url { - Some(exec_server_url) => { - Self::remote_with_runtime_paths(exec_server_url, local_runtime_paths) - } - None => Self::local_with_runtime_paths(local_runtime_paths), + Some(exec_server_url) => Self::remote_inner(exec_server_url, local_runtime_paths), + None => match local_runtime_paths { + Some(local_runtime_paths) => Self::local(local_runtime_paths), + None => Self::default_for_tests(), + }, }) } - fn local_with_runtime_paths(local_runtime_paths: Option) -> Self { - let filesystem: Arc = match local_runtime_paths.clone() { - Some(runtime_paths) => Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)), - None => Arc::new(LocalFileSystem::unsandboxed()), - }; - + fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { exec_server_url: None, exec_backend: Arc::new(LocalProcess::default()), - filesystem, - local_runtime_paths, + filesystem: Arc::new(LocalFileSystem::with_runtime_paths( + local_runtime_paths.clone(), + )), + local_runtime_paths: Some(local_runtime_paths), } } - fn remote_with_runtime_paths( + fn remote(exec_server_url: String, local_runtime_paths: ExecServerRuntimePaths) -> Self { + Self::remote_inner(exec_server_url, Some(local_runtime_paths)) + } + + fn remote_inner( exec_server_url: String, local_runtime_paths: Option, ) -> Self { @@ -265,10 +266,18 @@ mod tests { use crate::ProcessId; use pretty_assertions::assert_eq; + fn test_runtime_paths() -> ExecServerRuntimePaths { + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths") + } + #[tokio::test] async fn create_local_environment_does_not_connect() { - let environment = - Environment::create(/*exec_server_url*/ None).expect("create environment"); + let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) + .expect("create environment"); assert_eq!(environment.exec_server_url(), None); assert!(!environment.is_remote()); @@ -278,7 +287,7 @@ mod tests { async fn environment_manager_normalizes_empty_url() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some(String::new()), - local_runtime_paths: None, + local_runtime_paths: test_runtime_paths(), }); let environment = manager.default_environment().expect("default environment"); @@ -296,7 +305,7 @@ mod tests { async fn environment_manager_treats_none_value_as_disabled() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), - local_runtime_paths: None, + local_runtime_paths: test_runtime_paths(), }); assert!(manager.default_environment().is_none()); @@ -313,7 +322,7 @@ mod tests { async fn environment_manager_reports_remote_url() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), - local_runtime_paths: None, + local_runtime_paths: test_runtime_paths(), }); let environment = manager.default_environment().expect("default environment"); @@ -335,7 +344,7 @@ mod tests { #[tokio::test] async fn environment_manager_default_environment_caches_environment() { - let manager = EnvironmentManager::new(EnvironmentManagerArgs::default_for_tests()); + let manager = EnvironmentManager::default_for_tests(); let first = manager.default_environment().expect("default environment"); let second = manager.default_environment().expect("default environment"); @@ -349,14 +358,10 @@ mod tests { #[tokio::test] async fn environment_manager_carries_local_runtime_paths() { - let runtime_paths = ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"); + let runtime_paths = test_runtime_paths(); let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: None, - local_runtime_paths: Some(runtime_paths.clone()), + local_runtime_paths: runtime_paths.clone(), }); let environment = manager.default_environment().expect("default environment"); @@ -364,7 +369,10 @@ mod tests { assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: environment.exec_server_url().map(str::to_owned), - local_runtime_paths: environment.local_runtime_paths().cloned(), + local_runtime_paths: environment + .local_runtime_paths() + .expect("local runtime paths") + .clone(), }); let environment = manager.default_environment().expect("default environment"); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); @@ -374,7 +382,7 @@ mod tests { async fn disabled_environment_manager_has_no_default_environment() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), - local_runtime_paths: None, + local_runtime_paths: test_runtime_paths(), }); assert!(manager.default_environment().is_none()); @@ -384,7 +392,7 @@ mod tests { async fn environment_manager_keeps_local_lookup_when_default_disabled() { let manager = EnvironmentManager::new(EnvironmentManagerArgs { exec_server_url: Some("none".to_string()), - local_runtime_paths: None, + local_runtime_paths: test_runtime_paths(), }); assert!(manager.default_environment().is_none()); @@ -399,14 +407,14 @@ mod tests { #[tokio::test] async fn get_environment_returns_none_for_unknown_id() { - let manager = EnvironmentManager::new(EnvironmentManagerArgs::default_for_tests()); + let manager = EnvironmentManager::default_for_tests(); assert!(manager.get_environment("does-not-exist").is_none()); } #[tokio::test] async fn default_environment_has_ready_local_executor() { - let environment = Environment::default(); + let environment = Environment::default_for_tests(); let response = environment .get_exec_backend() @@ -425,4 +433,27 @@ mod tests { assert_eq!(response.process.process_id().as_str(), "default-env-proc"); } + + #[tokio::test] + async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() { + let environment = Environment::default_for_tests(); + let path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + std::env::current_exe().expect("current exe").as_path(), + ) + .expect("absolute current exe"); + let sandbox = crate::FileSystemSandboxContext::new( + codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + ); + + let err = environment + .get_filesystem() + .read_file(&path, Some(&sandbox)) + .await + .expect_err("sandboxed read should require runtime paths"); + + assert_eq!( + err.to_string(), + "sandboxed filesystem operations require configured runtime paths" + ); + } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 4887a0be4de1..9972cc004a78 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -49,13 +49,13 @@ enum ProcessEventSnapshot { async fn create_process_context(use_remote: bool) -> Result { if use_remote { let server = exec_server().await?; - let environment = Environment::create(Some(server.websocket_url().to_string()))?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; Ok(ProcessContext { backend: environment.get_exec_backend(), server: Some(server), }) } else { - let environment = Environment::create(/*exec_server_url*/ None)?; + let environment = Environment::create_for_tests(/*exec_server_url*/ None)?; Ok(ProcessContext { backend: environment.get_exec_backend(), server: None, diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index 4bb654198f91..f137be969580 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -46,7 +46,7 @@ struct FileSystemContext { async fn create_file_system_context(use_remote: bool) -> Result { if use_remote { let server = exec_server().await?; - let environment = Environment::create(Some(server.websocket_url().to_string()))?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; Ok(FileSystemContext { file_system: environment.get_filesystem(), _helper_paths: None, @@ -214,7 +214,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result< let helper_path = std::env::join_paths(path_entries)?; let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?; - let environment = Environment::create(Some(server.websocket_url().to_string()))?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; let file_system = environment.get_filesystem(); let workspace = tmp.path().join("workspace"); std::fs::create_dir_all(&workspace)?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1404348af193..1279532daf04 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -498,10 +498,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, - environment_manager: std::sync::Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - local_runtime_paths: Some(local_runtime_paths), - ..EnvironmentManagerArgs::default() - })), + environment_manager: std::sync::Arc::new(EnvironmentManager::new( + EnvironmentManagerArgs::from_env(local_runtime_paths), + )), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 7577818fc4af..1d904e4577a0 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -60,13 +60,12 @@ pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( + ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?), - ..EnvironmentManagerArgs::default() - })); + )?, + ))); // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index a48739dedc43..4dc724ee5e1f 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -38,9 +38,7 @@ pub(super) async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 5237ce7d9325..c498d3d41d78 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3576,9 +3576,7 @@ async fn make_test_app() -> App { backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, @@ -3635,9 +3633,7 @@ async fn make_test_app_with_channels() -> ( backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, - environment_manager: Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 930adeb57dba..7e33f2e8a3f6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -426,9 +426,7 @@ pub(crate) async fn start_embedded_app_server_for_picker( start_app_server_for_picker( config, &AppServerTarget::Embedded, - Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(EnvironmentManager::default_for_tests()), ) .await } @@ -731,13 +729,12 @@ pub async fn run_main( } }; - let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs { - local_runtime_paths: Some(ExecServerRuntimePaths::from_optional_paths( + let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( + ExecServerRuntimePaths::from_optional_paths( arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), - )?), - ..EnvironmentManagerArgs::default() - })); + )?, + ))); let cwd = cli.cwd.clone(); let config_cwd = config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?; @@ -1777,9 +1774,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(EnvironmentManager::default_for_tests()), ) .await } @@ -1939,8 +1934,7 @@ mod tests { websocket_url: "ws://127.0.0.1:1234/".to_string(), auth_token: None, }; - let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); + let environment_manager = EnvironmentManager::default_for_tests(); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -1954,8 +1948,7 @@ mod tests { { let temp_dir = TempDir::new()?; let target = AppServerTarget::Embedded; - let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); + let environment_manager = EnvironmentManager::default_for_tests(); let config_cwd = config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; @@ -1975,8 +1968,7 @@ mod tests { let temp_dir = TempDir::new()?; let missing = temp_dir.path().join("missing"); let target = AppServerTarget::Embedded; - let environment_manager = - EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs::default_for_tests()); + let environment_manager = EnvironmentManager::default_for_tests(); let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager) .expect_err("missing embedded cwd should fail"); @@ -1997,7 +1989,10 @@ mod tests { let environment_manager = EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs { exec_server_url: Some("ws://127.0.0.1:8765".to_string()), - local_runtime_paths: None, + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + )?, }); let config_cwd = @@ -2125,9 +2120,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs::default_for_tests(), - )), + Arc::new(EnvironmentManager::default_for_tests()), |_args| async { Err(std::io::Error::other("boom")) }, ) .await; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 380f13580a85..1e55b5c5d2e1 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -989,9 +989,9 @@ mod tests { ), feedback: codex_feedback::CodexFeedback::new(), log_db: None, - environment_manager: Arc::new(codex_app_server_client::EnvironmentManager::new( - codex_app_server_client::EnvironmentManagerArgs::default_for_tests(), - )), + environment_manager: Arc::new( + codex_app_server_client::EnvironmentManager::default_for_tests(), + ), config_warnings: Vec::new(), session_source: SessionSource::Cli, enable_codex_api_key_env: false, From 2c0a752893f6247579b1b25cedec9c14ec240ae3 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 12:47:17 -0700 Subject: [PATCH 17/38] Drop unused exec-server env var re-export Remove the app-server-client re-export now that environment-manager construction owns CODEX_EXEC_SERVER_URL reading directly in exec-server. Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index fdd90f9281c1..1c3de1208b15 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -45,7 +45,6 @@ use codex_config::NoopThreadConfigLoader; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; -pub use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; From a2e02d93fea24d84c910e8533bd8a546a44b61be Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 13:00:40 -0700 Subject: [PATCH 18/38] Reuse EnvironmentManager for app-server connectors Add a connector loading helper that accepts the existing EnvironmentManager and switch app-server paths to use it. Keep the config-only helper as a temporary fallback for callers such as TUI that do not yet pass the manager through. Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 30 ++++++++++++------- .../plugin_app_helpers.rs | 8 +++-- codex-rs/app-server/src/message_processor.rs | 9 ++++-- codex-rs/chatgpt/src/connectors.rs | 1 + codex-rs/core/src/connectors.rs | 28 +++++++++++++---- 5 files changed, 55 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 54c10f670cd3..49c59cf63077 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6252,13 +6252,17 @@ impl CodexMessageProcessor { let accessible_config = config.clone(); let accessible_tx = tx.clone(); + let environment_manager = self.thread_manager.environment_manager(); tokio::spawn(async move { - let result = connectors::list_accessible_connectors_from_mcp_tools_with_options( - &accessible_config, - force_refetch, - ) - .await - .map_err(|err| format!("failed to load accessible apps: {err}")); + let result = + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &accessible_config, + force_refetch, + &environment_manager, + ) + .await + .map(|status| status.connectors) + .map_err(|err| format!("failed to load accessible apps: {err}")); let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); }); @@ -6768,8 +6772,13 @@ impl CodexMessageProcessor { return; } }; - let app_summaries = - plugin_app_helpers::load_plugin_app_summaries(&config, &outcome.plugin.apps).await; + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = plugin_app_helpers::load_plugin_app_summaries( + &config, + &outcome.plugin.apps, + &environment_manager, + ) + .await; let visible_skills = outcome .plugin .skills @@ -6946,10 +6955,11 @@ impl CodexMessageProcessor { ) { Vec::new() } else { + let environment_manager = self.thread_manager.environment_manager(); let (all_connectors_result, accessible_connectors_result) = tokio::join!( connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( - &config, /*force_refetch*/ true + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, /*force_refetch*/ true, &environment_manager ), ); diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs b/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs index f2ba96d43acf..ad5875608b0f 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs @@ -5,11 +5,13 @@ use codex_app_server_protocol::AppSummary; use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::plugins::AppConnectorId; +use codex_exec_server::EnvironmentManager; use tracing::warn; pub(super) async fn load_plugin_app_summaries( config: &Config, plugin_apps: &[AppConnectorId], + environment_manager: &EnvironmentManager, ) -> Vec { if plugin_apps.is_empty() { return Vec::new(); @@ -29,8 +31,10 @@ pub(super) async fn load_plugin_app_summaries( let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps); let accessible_connectors = - match connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( - config, /*force_refetch*/ false, + match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ false, + environment_manager, ) .await { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7fb0df42f60c..c54db8b55bbb 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1046,11 +1046,14 @@ impl MessageProcessor { } let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); tokio::spawn(async move { let (all_connectors_result, accessible_connectors_result) = tokio::join!( connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_options( - &config, /*force_refetch*/ true, + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + /*force_refetch*/ true, + &environment_manager, ), ); let all_connectors = match all_connectors_result { @@ -1063,7 +1066,7 @@ impl MessageProcessor { } }; let accessible_connectors = match accessible_connectors_result { - Ok(connectors) => connectors, + Ok(status) => status.connectors, Err(err) => { tracing::warn!( "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5f6efbc124c1..c054d1b8df82 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -13,6 +13,7 @@ use codex_connectors::merge::merge_connectors; use codex_connectors::merge::merge_plugin_connectors; use codex_core::config::Config; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b4576f26af2e..2c4d78e8b55a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -192,6 +192,28 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config: &Config, force_refetch: bool, +) -> anyhow::Result { + // TODO: Wire callers that already own an EnvironmentManager into + // list_accessible_connectors_from_mcp_tools_with_environment_manager instead + // of constructing a temporary manager here. + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + config.codex_self_exe.clone(), + config.codex_linux_sandbox_exe.clone(), + )?; + let environment_manager = + EnvironmentManager::new(EnvironmentManagerArgs::from_env(local_runtime_paths)); + list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + force_refetch, + &environment_manager, + ) + .await +} + +pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( + config: &Config, + force_refetch: bool, + environment_manager: &EnvironmentManager, ) -> anyhow::Result { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); @@ -249,12 +271,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( let (tx_event, rx_event) = unbounded(); drop(rx_event); - let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( - config.codex_self_exe.clone(), - config.codex_linux_sandbox_exe.clone(), - )?; - let environment_manager = - EnvironmentManager::new(EnvironmentManagerArgs::from_env(local_runtime_paths)); let environment = environment_manager .default_environment() .unwrap_or_else(|| environment_manager.local_environment()); From a8f10909db83199a437807a55d7467759edf78e8 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 13:11:32 -0700 Subject: [PATCH 19/38] Pass environment manager to app list task Co-authored-by: Codex --- codex-rs/app-server/src/codex_message_processor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 49c59cf63077..960c8219b334 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -260,6 +260,7 @@ use codex_core_plugins::loader::load_plugin_mcp_servers; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_exec_server::EnvironmentManager; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; use codex_features::Feature; @@ -6209,8 +6210,9 @@ impl CodexMessageProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); tokio::spawn(async move { - Self::apps_list_task(outgoing, request, params, config).await; + Self::apps_list_task(outgoing, request, params, config, environment_manager).await; }); } @@ -6219,6 +6221,7 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: AppsListParams, config: Config, + environment_manager: Arc, ) { let AppsListParams { cursor, @@ -6252,7 +6255,6 @@ impl CodexMessageProcessor { let accessible_config = config.clone(); let accessible_tx = tx.clone(); - let environment_manager = self.thread_manager.environment_manager(); tokio::spawn(async move { let result = connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( From 2ec1ad9fa5260828d87d701b3048a2b05afe14fb Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 16:28:56 -0700 Subject: [PATCH 20/38] Add turn-scoped environment selections Co-authored-by: Codex --- .../schema/json/ClientRequest.json | 15 ++ .../codex_app_server_protocol.schemas.json | 15 ++ .../codex_app_server_protocol.v2.schemas.json | 15 ++ .../schema/json/v2/TurnStartParams.json | 15 ++ .../typescript/v2/TurnEnvironmentParams.ts | 6 + .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 48 +++++++ .../app-server/src/codex_message_processor.rs | 11 ++ .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 2 + codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/session/handlers.rs | 11 +- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/tests.rs | 135 ++++++++++++++++++ codex-rs/core/src/session/turn_context.rs | 85 ++++++++++- .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 20 +++ codex-rs/core/tests/suite/abort_tasks.rs | 5 + codex-rs/core/tests/suite/apply_patch_cli.rs | 8 ++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 33 +++++ .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 16 +++ codex-rs/core/tests/suite/compact.rs | 36 +++++ codex-rs/core/tests/suite/compact_remote.rs | 43 ++++++ .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 14 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 ++ .../core/tests/suite/model_visible_layout.rs | 8 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 +++ codex-rs/core/tests/suite/pending_input.rs | 4 + .../core/tests/suite/permissions_messages.rs | 15 ++ codex-rs/core/tests/suite/personality.rs | 13 ++ codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 ++ codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 3 + codex-rs/core/tests/suite/remote_models.rs | 7 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + .../suite/responses_api_proxy_headers.rs | 1 + codex-rs/core/tests/suite/resume.rs | 7 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 12 ++ .../tests/suite/safety_check_downgrade.rs | 4 + codex-rs/core/tests/suite/search_tool.rs | 2 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/tools.rs | 95 ++++++++++++ codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 7 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 ++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/protocol.rs | 18 +++ codex-rs/tui/src/app_command.rs | 2 + codex-rs/tui/src/app_server_session.rs | 1 + 81 files changed, 853 insertions(+), 6 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 352cf0211368..3ddced86c92c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3706,6 +3706,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnInterruptParams": { "properties": { "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d1518ce28f16..a41221a78fd2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16260,6 +16260,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 82c990533ac3..2991a2c3cd6d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14154,6 +14154,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc92..071bc2ac3efe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -377,6 +377,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts new file mode 100644 index 000000000000..bb981b0ac973 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7bddd0f9d636..0b4c13efef1b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -372,6 +372,7 @@ export type { ToolsV2 } from "./ToolsV2"; export type { Turn } from "./Turn"; export type { TurnCompletedNotification } from "./TurnCompletedNotification"; export type { TurnDiffUpdatedNotification } from "./TurnDiffUpdatedNotification"; +export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7162eb4deee..94f5eae30892 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4641,6 +4641,14 @@ pub enum TurnStatus { } // Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -4653,6 +4661,10 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environment selections. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, @@ -9741,13 +9753,27 @@ mod tests { #[test] fn turn_start_params_preserve_explicit_null_service_tier() { + let cwd = test_absolute_path(); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], "serviceTier": null })) .expect("params should deserialize"); assert_eq!(params.service_tier, Some(None)); + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd, + }]) + ); let serialized = serde_json::to_value(¶ms).expect("params should serialize"); assert_eq!( @@ -9759,6 +9785,7 @@ mod tests { thread_id: "thread_123".to_string(), input: vec![], responsesapi_client_metadata: None, + environments: None, cwd: None, approval_policy: None, approvals_reviewer: None, @@ -9775,4 +9802,25 @@ mod tests { serde_json::to_value(&without_override).expect("params should serialize"); assert_eq!(serialized_without_override.get("serviceTier"), None); } + + #[test] + fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 960c8219b334..e5d815cbb460 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -318,6 +318,7 @@ use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -7170,6 +7171,15 @@ impl CodexMessageProcessor { let collaboration_mode = params.collaboration_mode.map(|mode| { self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) }); + let environments = params.environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect() + }); // Map v2 input items to core input items. let mapped_items: Vec = params @@ -7221,6 +7231,7 @@ impl CodexMessageProcessor { thread.as_ref(), Op::UserInput { items: mapped_items, + environments, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, }, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 42ac4d85968b..8ff940667814 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -610,6 +610,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { ClientRequest::TurnStart { request_id: RequestId::Integer(3), params: TurnStartParams { + environments: None, thread_id, input: vec![UserInput::Text { text: "hello".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 65f8442b0de1..b2b1ac1d83c6 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1733,6 +1733,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // first turn with workspace-write sandbox and first_cwd let first_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "first turn".to_string(), @@ -1773,6 +1774,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd let second_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "second turn".to_string(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 1a5c38723f8b..3aa7d6044c8c 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -428,6 +428,7 @@ async fn send_input_submits_user_message() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), @@ -575,6 +576,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "spawned".to_string(), text_elements: Vec::new(), @@ -688,6 +690,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected = ( child_thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "child task".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4f4ced4101f4..858154eb77e3 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -182,6 +182,7 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { + environments: None, items: input, final_output_json_schema, responsesapi_client_metadata: None, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 79d833231b18..921c82ca6593 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -585,6 +585,7 @@ async fn run_review_on_session( review_session .codex .submit(Op::UserTurn { + environments: None, items: prompt_items.items, cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index c3503af623ff..1751e7075db0 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -125,7 +125,7 @@ pub(super) async fn user_input_or_turn_inner( op: Op, mirror_user_text_to_realtime: Option<()>, ) { - let (items, updates, responsesapi_client_metadata) = match op { + let (items, updates, responsesapi_client_metadata, environments) = match op { Op::UserTurn { cwd, approval_policy, @@ -139,6 +139,7 @@ pub(super) async fn user_input_or_turn_inner( items, collaboration_mode, personality, + environments, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -167,10 +168,12 @@ pub(super) async fn user_input_or_turn_inner( app_server_client_version: None, }, None, + environments, ) } Op::UserInput { items, + environments, final_output_json_schema, responsesapi_client_metadata, } => ( @@ -180,11 +183,15 @@ pub(super) async fn user_input_or_turn_inner( ..Default::default() }, responsesapi_client_metadata, + environments, ), _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { + let Ok(current_context) = sess + .new_turn_with_sub_id(sub_id.clone(), updates, environments) + .await + else { // new_turn_with_sub_id already emits the error event. return; }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ad392efda0a5..cd6aca5dfe80 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1081,6 +1081,7 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { + environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index af1028686d2f..62f4c9a87139 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -110,6 +110,7 @@ pub(super) async fn spawn_review_thread( reasoning_summary, session_source, environment: parent_turn_context.environment.clone(), + environments: parent_turn_context.environments.clone(), tools_config, features: parent_turn_context.features.clone(), ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 2479daae15ba..a311e81230e5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -802,6 +802,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow sandbox_policy: Some(SandboxPolicy::DangerFullAccess), ..Default::default() }, + /*environment_selections*/ None, ) .await?; @@ -1632,6 +1633,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "fork seed".into(), text_elements: Vec::new(), @@ -1692,6 +1694,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -3279,6 +3282,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &models_manager, /*network*/ None, Some(environment), + /*environments*/ None, + session_configuration.cwd.clone(), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, @@ -3850,6 +3855,7 @@ fn op_kind_distinguishes_turn_ops() { ); assert_eq!( Op::UserInput { + environments: None, items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -3868,6 +3874,7 @@ async fn user_turn_updates_approvals_reviewer() { &session, "sub-1".to_string(), Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -3894,6 +3901,132 @@ async fn user_turn_updates_approvals_reviewer() { ); } +#[tokio::test] +async fn turn_environment_selection_sets_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let selected_cwd = + AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) + .expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd.clone(), + }]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 1); + assert_eq!(turn_environments[0].environment_id, "local"); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); +} + +#[tokio::test] +async fn multiple_turn_environment_selections_use_first_as_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let session_cwd = session.get_config().await.cwd.clone(); + let first_cwd = + AbsolutePathBuf::try_from(session_cwd.as_path().join("first")).expect("absolute path"); + let second_cwd = + AbsolutePathBuf::try_from(session_cwd.as_path().join("second")).expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![ + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: first_cwd.clone(), + }, + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: second_cwd.clone(), + }, + ]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 2); + assert_eq!(turn_environments[0].cwd, first_cwd); + assert_eq!(turn_environments[1].cwd, second_cwd); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd, first_cwd); + assert_eq!(turn_context.config.cwd, first_cwd); +} + +#[tokio::test] +async fn empty_turn_environment_selection_clears_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![]), + ) + .await + .expect("turn should start"); + + assert!(turn_context.environment.is_none()); + assert_eq!(turn_context.cwd, session.get_config().await.cwd); + assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); + assert_eq!( + turn_context + .environments + .as_ref() + .expect("turn environments should be recorded") + .len(), + 0 + ); +} + +#[tokio::test] +async fn unknown_turn_environment_selection_returns_error() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + let err = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "missing".to_string(), + cwd: session.get_config().await.cwd.clone(), + }]), + ) + .await + .expect_err("unknown environment should fail"); + + assert!(err.to_string().contains("missing")); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { @@ -4377,6 +4510,8 @@ where &models_manager, /*network*/ None, Some(environment), + /*environments*/ None, + session_configuration.cwd.clone(), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index ce6758c442ba..5d70157418d0 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,6 +1,7 @@ use super::*; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::protocol::TurnEnvironmentSelection; pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { matches!( @@ -24,6 +25,14 @@ impl TurnSkillsContext { } } +#[derive(Clone, Debug)] +pub(crate) struct TurnEnvironment { + #[allow(dead_code)] + pub(crate) environment_id: String, + pub(crate) environment: Arc, + pub(crate) cwd: AbsolutePathBuf, +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -39,6 +48,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Option>, + pub(crate) environments: Option>, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -168,6 +178,7 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), environment: self.environment.clone(), + environments: self.environments.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -346,6 +357,8 @@ impl Session { models_manager: &ModelsManager, network: Option, environment: Option>, + environments: Option>, + cwd: AbsolutePathBuf, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -389,8 +402,6 @@ impl Session { &per_turn_config.agent_roles, )); - let cwd = session_configuration.cwd.clone(); - let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), @@ -414,6 +425,7 @@ impl Session { reasoning_summary, session_source, environment, + environments, cwd, current_date: Some(current_date), timezone: Some(timezone), @@ -450,7 +462,22 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, + environment_selections: Option>, ) -> ConstraintResult> { + let turn_environments = match self.resolve_turn_environments(environment_selections) { + Ok(turn_environments) => turn_environments, + Err(err) => { + self.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + return Err(err); + } + }; let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -511,17 +538,50 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + turn_environments, ) .await) } + fn resolve_turn_environments( + &self, + environment_selections: Option>, + ) -> ConstraintResult>> { + let Some(environment_selections) = environment_selections else { + return Ok(None); + }; + + let mut turn_environments = Vec::with_capacity(environment_selections.len()); + for environment_selection in environment_selections { + let environment = self + .services + .environment_manager + .get_environment(&environment_selection.environment_id) + .ok_or_else(|| codex_config::ConstraintError::InvalidValue { + field_name: "environments.environment_id", + candidate: environment_selection.environment_id.clone(), + allowed: "configured environment ids".to_string(), + requirement_source: codex_config::RequirementSource::Unknown, + })?; + let cwd = environment_selection.cwd; + turn_environments.push(TurnEnvironment { + environment_id: environment_selection.environment_id, + environment, + cwd, + }); + } + + Ok(Some(turn_environments)) + } + async fn new_turn_from_configuration( &self, sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + turn_environments: Option>, ) -> Arc { - let per_turn_config = Self::build_per_turn_config(&session_configuration); + let mut per_turn_config = Self::build_per_turn_config(&session_configuration); { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy); @@ -537,6 +597,21 @@ impl Session { &per_turn_config.to_models_manager_config(), ) .await; + let environment = match turn_environments.as_ref() { + Some(turn_environments) => turn_environments + .first() + .map(|turn_environment| Arc::clone(&turn_environment.environment)), + None => self.services.environment_manager.default_environment(), + }; + let cwd = turn_environments + .as_ref() + .and_then(|turn_environments| { + turn_environments + .first() + .map(|turn_environment| turn_environment.cwd.clone()) + }) + .unwrap_or_else(|| session_configuration.cwd.clone()); + per_turn_config.cwd = cwd.clone(); let plugin_outcome = self .services .plugins_manager @@ -544,7 +619,6 @@ impl Session { .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); - let environment = self.services.environment_manager.default_environment(); let fs = environment .as_ref() .map(|environment| environment.get_filesystem()); @@ -576,6 +650,8 @@ impl Session { .then(|| started_proxy.proxy()) }), environment, + turn_environments, + cwd, sub_id, Arc::clone(&self.js_repl), skills_outcome, @@ -619,6 +695,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*turn_environments*/ None, ) .await } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index c46e98bce2c8..754a755abf14 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2240,6 +2240,7 @@ async fn send_input_accepts_structured_items() { .expect("send_input should succeed"); let expected = Op::UserInput { + environments: None, items: vec![ UserInput::Mention { name: "drive".to_string(), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73219423b5a5..74b05f6e1774 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -35,6 +35,7 @@ use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; @@ -605,6 +606,7 @@ impl TestCodex { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Some(service_tier), + /*environments*/ None, ) .await } @@ -620,6 +622,22 @@ impl TestCodex { approval_policy, sandbox_policy, /*service_tier*/ None, + /*environments*/ None, + ) + .await + } + + pub async fn submit_turn_with_environments( + &self, + prompt: &str, + environments: Option>, + ) -> Result<()> { + self.submit_turn_with_context( + prompt, + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + /*service_tier*/ None, + environments, ) .await } @@ -630,10 +648,12 @@ impl TestCodex { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, service_tier: Option>, + environments: Option>, ) -> Result<()> { let session_model = self.session_configured.model.clone(); self.codex .submit(Op::UserTurn { + environments, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index a181c1123f35..c81a1c2f68ac 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -46,6 +46,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start sleep".into(), text_elements: Vec::new(), @@ -101,6 +102,7 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start history recording".into(), text_elements: Vec::new(), @@ -120,6 +122,7 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), @@ -201,6 +204,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start interrupt marker".into(), text_elements: Vec::new(), @@ -220,6 +224,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 45c97d3704a3..a789940b0e10 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -357,6 +357,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "rename without content change".into(), text_elements: Vec::new(), @@ -994,6 +995,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "create streamed file".into(), text_elements: Vec::new(), @@ -1091,6 +1093,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply via shell heredoc with cd".into(), text_elements: Vec::new(), @@ -1175,6 +1178,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via shell".into(), text_elements: Vec::new(), @@ -1330,6 +1334,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "emit diff".into(), text_elements: Vec::new(), @@ -1397,6 +1402,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "rename with change".into(), text_elements: Vec::new(), @@ -1473,6 +1479,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "aggregate diffs".into(), text_elements: Vec::new(), @@ -1549,6 +1556,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch twice with failure".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f189d5db6cbf..3347213bf6b4 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -584,6 +584,7 @@ async fn submit_turn( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2ebd49d53e11..d6091f1e5e19 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -383,6 +383,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -747,6 +748,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1001,6 +1004,7 @@ async fn chatgpt_auth_sends_correct_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1113,6 +1117,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1150,6 +1155,7 @@ async fn includes_user_instructions_message_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1236,6 +1242,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1297,6 +1304,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1354,6 +1362,7 @@ async fn omits_apps_guidance_when_configured_off() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1394,6 +1403,7 @@ async fn omits_environment_context_when_configured_off() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1449,6 +1459,7 @@ async fn skills_append_to_developer_message() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1501,6 +1512,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1541,6 +1553,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1582,6 +1595,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1636,6 +1650,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1692,6 +1707,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1755,6 +1771,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1808,6 +1825,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1865,6 +1883,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1902,6 +1921,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1948,6 +1968,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1993,6 +2014,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2043,6 +2065,7 @@ async fn includes_developer_instructions_message_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2335,6 +2358,7 @@ async fn token_count_includes_rate_limits_snapshot() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2504,6 +2528,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { let submission_id = codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2579,6 +2604,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed turn".into(), text_elements: Vec::new(), @@ -2592,6 +2618,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "trigger context window".into(), text_elements: Vec::new(), @@ -2675,6 +2702,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "trigger incomplete".into(), text_elements: Vec::new(), @@ -2784,6 +2812,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2871,6 +2900,7 @@ async fn env_var_overrides_loaded_auth() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2933,6 +2963,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U1".into(), text_elements: Vec::new(), @@ -2947,6 +2978,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 2: user sends U2; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U2".into(), text_elements: Vec::new(), @@ -2961,6 +2993,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 3: user sends U3; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U3".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index f6cfd0d913e1..56e9f353dc58 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -998,6 +998,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { let submission_id = test .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1085,6 +1086,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { let submission_id = test .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 3371ff45d619..d52a1454e0af 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2609,6 +2609,7 @@ text( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 57ffb35e60e4..3f8d2378ade1 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -79,6 +79,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -139,6 +140,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -174,6 +176,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -238,6 +241,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -291,6 +295,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -362,6 +367,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -390,6 +396,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -475,6 +483,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -534,6 +543,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -565,6 +575,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -625,6 +636,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -656,6 +668,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -720,6 +733,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -734,6 +748,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -791,6 +806,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5468151b47c4..c37326ffc956 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -240,6 +240,7 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), @@ -263,6 +264,7 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), @@ -440,6 +442,7 @@ async fn manual_compact_uses_custom_prompt() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -585,6 +588,7 @@ async fn manual_compact_emits_context_compaction_items() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual compact".into(), text_elements: Vec::new(), @@ -749,6 +753,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_message.into(), text_elements: Vec::new(), @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1263,6 +1269,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1277,6 +1284,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1446,6 +1454,7 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -1525,6 +1534,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1538,6 +1548,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1551,6 +1562,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1665,6 +1677,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: follow_up_user.into(), text_elements: Vec::new(), @@ -1756,6 +1769,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before switch".into(), text_elements: Vec::new(), @@ -1781,6 +1795,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after switch".into(), text_elements: Vec::new(), @@ -1892,6 +1907,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { initial .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before resume".into(), text_elements: Vec::new(), @@ -1941,6 +1957,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -2044,6 +2061,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2057,6 +2075,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2070,6 +2089,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2157,6 +2177,7 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2269,6 +2290,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2362,6 +2384,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2378,6 +2401,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2394,6 +2418,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: final_user_message.into(), text_elements: Vec::new(), @@ -2556,6 +2581,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2659,6 +2685,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), @@ -2858,6 +2885,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2976,6 +3004,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3036,6 +3065,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3067,6 +3097,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -3162,6 +3193,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3187,6 +3219,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3283,6 +3316,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3296,6 +3330,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3367,6 +3402,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 30e3c54c9433..58ed42c288c0 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -246,6 +246,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -261,6 +262,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -392,6 +394,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -467,6 +470,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -479,6 +483,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -595,6 +600,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -607,6 +613,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -625,6 +632,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -723,6 +731,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), @@ -735,6 +744,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -827,6 +837,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -842,6 +853,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -931,6 +943,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1015,6 +1029,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1094,6 +1109,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1177,6 +1193,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "needs compaction".into(), text_elements: Vec::new(), @@ -1319,6 +1336,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1335,6 +1353,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1358,6 +1377,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -1453,6 +1473,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1468,6 +1489,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1538,6 +1560,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1550,6 +1573,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1615,6 +1639,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1674,6 +1699,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1688,6 +1714,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1761,6 +1788,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1776,6 +1804,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1857,6 +1886,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "SETUP_USER".to_string(), text_elements: Vec::new(), @@ -1871,6 +1901,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1960,6 +1991,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1988,6 +2020,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2082,6 +2115,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us } codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -2167,6 +2201,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2194,6 +2229,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2311,6 +2347,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2323,6 +2360,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2406,6 +2444,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2482,6 +2521,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2566,6 +2606,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2581,6 +2622,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2661,6 +2703,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index efa39aaeebed..48d359529b88 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -803,6 +803,7 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3c80fc80d095..8b2654a7205b 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -44,6 +44,7 @@ async fn submit_user_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -125,6 +126,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell command".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index dc7f2151f0d2..bcb7864cf9c0 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -48,6 +48,7 @@ async fn fork_thread_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index b2d8e07b65ca..5c71516f40a1 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1036,6 +1036,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "initial prompt".to_string(), text_elements: Vec::new(), @@ -1053,6 +1054,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index a7ec8318f158..0e2d88237dd9 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -111,6 +111,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::LocalImage { path: abs_path.clone(), @@ -198,6 +199,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 25eb21df9ebb..6cf42b5eedb6 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -85,6 +85,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -139,6 +140,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please summarize results".into(), text_elements: Vec::new(), @@ -198,6 +200,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "explain your reasoning".into(), text_elements: Vec::new(), @@ -258,6 +261,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "find the weather".into(), text_elements: Vec::new(), @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate a tiny blue square".into(), text_elements: Vec::new(), @@ -386,6 +391,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -440,6 +446,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please stream text".into(), text_elements: Vec::new(), @@ -523,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -600,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -709,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan with citations".into(), text_elements: Vec::new(), @@ -896,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1010,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1088,6 +1100,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "reason through it".into(), text_elements: Vec::new(), @@ -1148,6 +1161,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "show raw reasoning".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 755b2694728d..d6728deb0c02 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -73,6 +73,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f5c..cfafdea3f73f 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -48,6 +48,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Text { text: prompt.to_string(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 139ee7a85624..54c2d1035f2f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -121,6 +121,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch models".into(), text_elements: Vec::new(), @@ -218,6 +220,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -255,6 +258,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch model and personality".into(), text_elements: Vec::new(), @@ -392,6 +396,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -526,6 +532,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -547,6 +554,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -658,6 +666,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -679,6 +688,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -792,6 +802,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -821,6 +832,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after rollback".to_string(), text_elements: Vec::new(), @@ -978,6 +990,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use larger model".into(), text_elements: Vec::new(), @@ -1037,6 +1050,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch to smaller model".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 0f41637ea258..c93294121cfb 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -114,6 +114,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -138,6 +139,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn with context updates".into(), text_elements: Vec::new(), @@ -217,6 +219,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn in agents_one".into(), text_elements: Vec::new(), @@ -241,6 +244,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn in agents_two".into(), text_elements: Vec::new(), @@ -317,6 +321,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -352,6 +357,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "resume and change personality".into(), text_elements: Vec::new(), @@ -417,6 +423,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -463,6 +470,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first resumed turn after model override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 2ffce5742165..5728205b6c3b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -90,6 +90,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hi".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 34aaf86deeba..d46214d1f274 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -101,6 +101,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please run a tool".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index f93945e78fff..3893170ed745 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -102,6 +102,7 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -146,6 +147,7 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -190,6 +192,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -236,6 +239,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -364,6 +369,7 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -415,6 +421,7 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -474,6 +481,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -527,6 +535,7 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -600,6 +609,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -685,6 +695,7 @@ async fn record_responses_sets_span_fields_for_response_events() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -770,6 +781,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -844,6 +856,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -928,6 +941,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -997,6 +1011,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1106,6 +1121,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1158,6 +1174,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "approved".into(), text_elements: Vec::new(), @@ -1225,6 +1242,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1292,6 +1310,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "retry".into(), text_elements: Vec::new(), @@ -1359,6 +1378,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), @@ -1426,6 +1446,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1494,6 +1515,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 777103534b80..10907f02637f 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -95,6 +95,7 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -109,6 +110,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -272,6 +274,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".into(), text_elements: Vec::new(), @@ -289,6 +292,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "second prompt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d7ceb25fefe6..fb0acf7519b2 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -49,6 +49,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -87,6 +88,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -115,6 +117,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -159,6 +162,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -215,6 +220,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -243,6 +249,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -300,6 +307,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -330,6 +338,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -344,6 +353,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -402,6 +412,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -432,6 +443,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -452,6 +464,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -485,6 +498,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -538,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 251aad76c087..7c72478acee6 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -96,6 +96,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -147,6 +148,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -205,6 +207,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -269,6 +272,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -321,6 +325,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -359,6 +364,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -426,6 +432,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -544,6 +552,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -582,6 +591,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -701,6 +711,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -822,6 +833,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -860,6 +872,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 49be14a60df0..07602c1be87f 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -192,6 +192,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -268,6 +269,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), @@ -348,6 +350,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 63a21ce1d597..cee43e9820e0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -146,6 +146,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -244,6 +246,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -256,6 +259,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -413,6 +419,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -450,6 +457,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -533,6 +541,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -685,6 +694,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -707,6 +717,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }; codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -820,6 +831,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -841,6 +853,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -967,6 +981,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index c59f2d86bce4..4c0677e69a58 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -41,6 +41,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "quota?".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 60167a15586a..b3d695250d57 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1943,6 +1943,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result let prefixed_user_text = format!("[USER] {user_text}"); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.to_string(), text_elements: Vec::new(), @@ -2072,6 +2073,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re ); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.clone(), text_elements: Vec::new(), @@ -3230,6 +3232,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9b157bde5ff5..e62fa0c3234d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -172,6 +172,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -323,6 +325,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -411,6 +414,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -474,6 +478,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -861,6 +867,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 44d37f311cec..fddb18f15bfb 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -40,6 +40,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "compress me".into(), text_elements: Vec::new(), @@ -88,6 +89,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "do not compress".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 614cd10b9fc8..866df1386384 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -187,6 +187,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 0578441e9918..d31b060c38b3 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -138,6 +138,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c2189..4c59c8e7f3d9 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -131,6 +131,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), @@ -249,6 +250,7 @@ where codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 67f6e86ab0e2..8e5d1e0e3c0a 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -128,6 +128,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 55d7e45f0aaa..febeb926b160 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -86,6 +86,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record some messages".into(), text_elements: text_elements.clone(), @@ -172,6 +173,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record reasoning messages".into(), text_elements: Vec::new(), @@ -262,6 +264,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -303,6 +306,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Resume with different model".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Second turn after resume".into(), text_elements: Vec::new(), @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -434,6 +440,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn after override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f4da7c549b30..faf469c9812b 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -721,6 +721,7 @@ async fn review_history_surfaces_in_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7875f09810ab..cb6553343fc0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -296,6 +296,7 @@ async fn call_cwd_tool( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -429,6 +430,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -797,6 +799,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -929,6 +932,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1028,6 +1032,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1179,6 +1184,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1416,6 +1422,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1524,6 +1531,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1660,6 +1668,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1769,6 +1778,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1893,6 +1903,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -2109,6 +2120,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16af4..f3211e00d63e 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -38,6 +38,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger safety check".to_string(), text_elements: Vec::new(), @@ -137,6 +138,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger response model check".to_string(), text_elements: Vec::new(), @@ -223,6 +225,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger follow-up turn".to_string(), text_elements: Vec::new(), @@ -273,6 +276,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger casing check".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 2f24fba33e81..1e37d5ae10db 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -502,6 +502,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let test = builder.build(&server).await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), @@ -778,6 +779,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Use the automation tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 516bc049f53a..b3a53fde1195 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -157,6 +157,7 @@ async fn run_snapshot_command_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), @@ -248,6 +249,7 @@ async fn run_shell_command_snapshot_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), @@ -319,6 +321,7 @@ async fn run_tool_turn_on_harness( let cwd = test.cwd_path().to_path_buf(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -554,6 +557,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f9487..de4827d6befd 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -44,6 +44,7 @@ async fn submit_turn_with_policies( ) -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 59d28b61fc62..015f4ef0f238 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -99,6 +99,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Text { text: "please use $demo".to_string(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 52f1f4648a36..6a3f9b792728 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -399,6 +399,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "call the rmcp echo tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 950306e97de1..af861412616a 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -94,6 +94,7 @@ async fn continue_after_stream_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -114,6 +115,7 @@ async fn continue_after_stream_error() { // error above, this submission would be rejected/queued indefinitely. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 2dd73e0f63a1..984220a0862b 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -78,6 +78,7 @@ async fn retries_on_early_close() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 67a7275693c0..ca86d3e9fea6 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -78,6 +78,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please run the shell command".into(), text_elements: Vec::new(), @@ -149,6 +150,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), @@ -230,6 +232,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), @@ -326,6 +329,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), @@ -430,6 +434,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 2628136a1543..3158804fad77 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -35,6 +35,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -352,6 +353,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "stream delayed completion".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index a995e54431c4..bc2bf2361dd8 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -20,6 +20,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -75,6 +76,100 @@ fn ev_namespaced_function_call( }) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("unified exec should enable for test"); + config + .features + .enable(Feature::JsRepl) + .expect("js repl should enable for test"); + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_environments("which tools are available?", Some(vec![])) + .await?; + + let tools = tool_names(&response_mock.single_request().body_json()); + assert!( + tools.contains(&"update_plan".to_string()), + "non-environment tool should remain available; got {tools:?}" + ); + for environment_tool in [ + "exec_command", + "write_stdin", + "js_repl", + "js_repl_reset", + "apply_patch", + "view_image", + ] { + assert!( + !tools.contains(&environment_tool.to_string()), + "{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("unified exec should enable for test"); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_environments( + "which tools are available?", + Some(vec![TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test.config.cwd.clone(), + }]), + ) + .await?; + + let tools = tool_names(&response_mock.single_request().body_json()); + assert!( + tools.contains(&"exec_command".to_string()), + "environment tool should remain available with selected local environment; got {tools:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index c12dd5819100..861ec1eed576 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -500,6 +500,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { fixture .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "call the rmcp image tool".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index be6fa73a40ff..58dbad7b1d22 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -166,6 +166,7 @@ async fn submit_unified_exec_turn( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -250,6 +251,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via unified exec".into(), text_elements: Vec::new(), @@ -1740,6 +1742,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), @@ -1833,6 +1836,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), @@ -2305,6 +2309,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "summarize large output".into(), text_elements: Vec::new(), @@ -2414,6 +2419,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "read the fixture files".into(), text_elements: Vec::new(), @@ -2542,6 +2548,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "start python under seatbelt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 3d02c2004dde..5fe08789f438 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -57,6 +57,7 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 807c7469050d..0aa52b7de877 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -170,6 +170,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> fixture .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run model shell command".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 9f53e60d723a..456740344924 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -160,6 +160,7 @@ async fn assert_user_turn_local_image_resizes_to( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], @@ -279,6 +280,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -410,6 +412,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the original screenshot".into(), text_elements: Vec::new(), @@ -509,6 +512,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image at low detail".into(), text_elements: Vec::new(), @@ -599,6 +603,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image with a null detail".into(), text_elements: Vec::new(), @@ -699,6 +704,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -803,6 +809,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -905,6 +912,7 @@ await codex.emitImage(out); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image and attach it".into(), text_elements: Vec::new(), @@ -1025,6 +1033,7 @@ console.log(out.type); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image but do not emit it".into(), text_elements: Vec::new(), @@ -1118,6 +1127,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the folder".into(), text_elements: Vec::new(), @@ -1199,6 +1209,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please use the view_image tool to read the json file".into(), text_elements: Vec::new(), @@ -1285,6 +1296,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the missing image".into(), text_elements: Vec::new(), @@ -1422,6 +1434,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image".into(), text_elements: Vec::new(), @@ -1503,6 +1516,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64bd..6611ebdf5d0f 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -150,6 +150,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index bfcdcf25eb6b..de52821839de 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -104,6 +104,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1279532daf04..11bf1b7a60a3 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -745,6 +745,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { thread_id: primary_thread_id_for_span.clone(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(default_cwd), approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9c9680017f90..5ad6b160b159 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -108,6 +108,7 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { + environments: None, items: vec![UserInput::Text { text: initial_prompt.clone(), // MCP tool prompts are plain text with no UI element ranges. @@ -156,6 +157,7 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), thread_id); if let Err(e) = thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: prompt, // MCP tool prompts are plain text with no UI element ranges. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b9a7a1395f52..615bc5bb8f28 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,12 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct TurnEnvironmentSelection { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "string")] @@ -425,6 +431,9 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, + /// Optional turn-scoped environment selections. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, @@ -488,6 +497,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Optional turn-scoped environment selections. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, }, /// Inter-agent communication that should be recorded as assistant history @@ -715,6 +728,7 @@ pub enum ThreadMemoryMode { impl From> for Op { fn from(value: Vec) -> Self { Op::UserInput { + environments: None, items: value, final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4904,6 +4918,7 @@ mod tests { #[test] fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4922,6 +4937,7 @@ mod tests { assert_eq!( op, Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4942,6 +4958,7 @@ mod tests { "additionalProperties": false }); let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: Some(schema.clone()), responsesapi_client_metadata: None, @@ -4963,6 +4980,7 @@ mod tests { #[test] fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: Some(HashMap::from([( diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index e94dced053a9..45425c3494c5 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -150,6 +150,7 @@ impl AppCommand { ) -> Self { Self(Op::UserTurn { items, + environments: None, cwd, approval_policy, approvals_reviewer: None, @@ -296,6 +297,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + environments: _, } => AppCommandView::UserTurn { items, cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4d8e213ef77b..655947a080b6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -532,6 +532,7 @@ impl AppServerSession { thread_id: thread_id.to_string(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), From 8b6f131cea89d95e2b726f989e093d3b3ce42c75 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:21:35 -0700 Subject: [PATCH 21/38] codex: document turn environments API Co-authored-by: Codex --- .../app-server-protocol/src/protocol/v2.rs | 98 ++++++++++++++++--- codex-rs/app-server/README.md | 6 ++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 94f5eae30892..2ecaa733edc2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -9753,27 +9753,13 @@ mod tests { #[test] fn turn_start_params_preserve_explicit_null_service_tier() { - let cwd = test_absolute_path(); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": cwd - } - ], "serviceTier": null })) .expect("params should deserialize"); assert_eq!(params.service_tier, Some(None)); - assert_eq!( - params.environments, - Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd, - }]) - ); let serialized = serde_json::to_value(¶ms).expect("params should serialize"); assert_eq!( @@ -9803,6 +9789,90 @@ mod tests { assert_eq!(serialized_without_override.get("serviceTier"), None); } + #[test] + fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); + } + + #[test] + fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); + } + + #[test] + fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); + } + #[test] fn turn_start_params_reject_relative_environment_cwd() { let err = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 86221ec801ff..d8170159847b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -519,6 +519,8 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. +`environments` is experimental and requires `initialize.params.capabilities.experimentalApi = true`. When omitted or `null`, Codex uses the thread's default environment behavior. When set to `[]`, the turn runs without an agent-accessible environment. When set to one or more `{ "environmentId", "cwd" }` entries, Codex resolves each id against the configured environments and uses the first entry as the turn's primary environment and cwd. + `approvalsReviewer` accepts: - `"user"` — default. Review approval requests directly in the client. @@ -530,6 +532,10 @@ You can optionally specify config overrides on the new turn. If specified, these "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", + // Experimental: turn-scoped environment selection. + "environments": [ + { "environmentId": "local", "cwd": "/Users/me/project" } + ], "approvalPolicy": "unlessTrusted", "sandboxPolicy": { "type": "workspaceWrite", From bf85976e6e85fce9a8982b92cb25f072dbba4551 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:29:23 -0700 Subject: [PATCH 22/38] codex: gate empty experimental fields Co-authored-by: Codex --- .../src/experimental_api.rs | 23 +++++++++++++++++++ .../codex-experimental-api-macros/src/lib.rs | 7 ++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server-protocol/src/experimental_api.rs b/codex-rs/app-server-protocol/src/experimental_api.rs index 63c3dafce377..af7a1efbe681 100644 --- a/codex-rs/app-server-protocol/src/experimental_api.rs +++ b/codex-rs/app-server-protocol/src/experimental_api.rs @@ -98,6 +98,13 @@ mod tests { inners: HashMap, } + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct ExperimentalFieldShape { + #[experimental("field/optionalCollection")] + optional_collection: Option>, + } + #[test] fn derive_supports_all_enum_variant_shapes() { assert_eq!( @@ -169,4 +176,20 @@ mod tests { None ); } + + #[test] + fn derive_marks_optional_experimental_fields_when_some() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: Some(Vec::new()), + }), + Some("field/optionalCollection") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: None, + }), + None + ); + } } diff --git a/codex-rs/codex-experimental-api-macros/src/lib.rs b/codex-rs/codex-experimental-api-macros/src/lib.rs index c5099e40a5ce..69eb71204a7c 100644 --- a/codex-rs/codex-experimental-api-macros/src/lib.rs +++ b/codex-rs/codex-experimental-api-macros/src/lib.rs @@ -261,11 +261,8 @@ fn presence_expr_for_access( access: proc_macro2::TokenStream, ty: &Type, ) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; + if option_inner(ty).is_some() { + return quote! { #access.is_some() }; } if is_vec_like(ty) || is_map_like(ty) { return quote! { !#access.is_empty() }; From 2d5298849f0d76d610019f91769c9818fd95865a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:39:22 -0700 Subject: [PATCH 23/38] codex: remove dead experimental helper Co-authored-by: Codex --- .../codex-experimental-api-macros/src/lib.rs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/codex-rs/codex-experimental-api-macros/src/lib.rs b/codex-rs/codex-experimental-api-macros/src/lib.rs index 69eb71204a7c..2bca0190eaa2 100644 --- a/codex-rs/codex-experimental-api-macros/src/lib.rs +++ b/codex-rs/codex-experimental-api-macros/src/lib.rs @@ -273,22 +273,6 @@ fn presence_expr_for_access( quote! { true } } -fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; - } - if is_vec_like(ty) || is_map_like(ty) { - return quote! { !#access.is_empty() }; - } - if is_bool(ty) { - return quote! { *#access }; - } - quote! { true } -} - fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(type_path) = ty else { return None; From 3150be409aa1f4b6f8d1e2efa7a148ecb4be0e3e Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 10:55:41 -0700 Subject: [PATCH 24/38] codex: remove verbose environment docs Co-authored-by: Codex --- codex-rs/app-server/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d8170159847b..02ab175fbdd2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -519,8 +519,6 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. -`environments` is experimental and requires `initialize.params.capabilities.experimentalApi = true`. When omitted or `null`, Codex uses the thread's default environment behavior. When set to `[]`, the turn runs without an agent-accessible environment. When set to one or more `{ "environmentId", "cwd" }` entries, Codex resolves each id against the configured environments and uses the first entry as the turn's primary environment and cwd. - `approvalsReviewer` accepts: - `"user"` — default. Review approval requests directly in the client. From 675777cb969b3744100df81e909e45538d1948db Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 11:02:06 -0700 Subject: [PATCH 25/38] codex: tighten turn environment errors Co-authored-by: Codex --- codex-rs/core/src/session/tests.rs | 7 ++- codex-rs/core/src/session/turn_context.rs | 56 ++++++++++++----------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a311e81230e5..5d838e3db56a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3161,7 +3161,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { inherited_shell_snapshot: None, user_shell_override: None, }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); + let per_turn_config = + Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -4024,6 +4025,7 @@ async fn unknown_turn_environment_selection_returns_error() { .await .expect_err("unknown environment should fail"); + assert!(matches!(err, CodexErr::InvalidRequest(_))); assert!(err.to_string().contains("missing")); } @@ -4389,7 +4391,8 @@ where inherited_shell_snapshot: None, user_shell_override: None, }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); + let per_turn_config = + Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 5d70157418d0..998f016c9671 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -311,11 +311,14 @@ fn local_time_context() -> (String, String) { impl Session { /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. - pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + pub(crate) fn build_per_turn_config( + session_configuration: &SessionConfiguration, + cwd: AbsolutePathBuf, + ) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.cwd = session_configuration.cwd.clone(); + per_turn_config.cwd = cwd; per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -463,7 +466,7 @@ impl Session { sub_id: String, updates: SessionSettingsUpdate, environment_selections: Option>, - ) -> ConstraintResult> { + ) -> CodexResult> { let turn_environments = match self.resolve_turn_environments(environment_selections) { Ok(turn_environments) => turn_environments, Err(err) => { @@ -509,15 +512,16 @@ impl Session { ) = match update_result { Ok(update) => update, Err(err) => { + let message = err.to_string(); self.send_event_raw(Event { id: sub_id.clone(), msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), + message: message.clone(), codex_error_info: Some(CodexErrorInfo::BadRequest), }), }) .await; - return Err(err); + return Err(CodexErr::InvalidRequest(message)); } }; @@ -546,7 +550,7 @@ impl Session { fn resolve_turn_environments( &self, environment_selections: Option>, - ) -> ConstraintResult>> { + ) -> CodexResult>> { let Some(environment_selections) = environment_selections else { return Ok(None); }; @@ -557,11 +561,11 @@ impl Session { .services .environment_manager .get_environment(&environment_selection.environment_id) - .ok_or_else(|| codex_config::ConstraintError::InvalidValue { - field_name: "environments.environment_id", - candidate: environment_selection.environment_id.clone(), - allowed: "configured environment ids".to_string(), - requirement_source: codex_config::RequirementSource::Unknown, + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + environment_selection.environment_id + )) })?; let cwd = environment_selection.cwd; turn_environments.push(TurnEnvironment { @@ -581,7 +585,20 @@ impl Session { final_output_json_schema: Option>, turn_environments: Option>, ) -> Arc { - let mut per_turn_config = Self::build_per_turn_config(&session_configuration); + // `None` means use the thread's default environment. `Some([])` is an + // explicit no-environment turn, so do not fall back in that case. + let primary_turn_environment = turn_environments + .as_ref() + .and_then(|turn_environments| turn_environments.first()); + let environment = match primary_turn_environment { + Some(turn_environment) => Some(Arc::clone(&turn_environment.environment)), + None if turn_environments.is_some() => None, + None => self.services.environment_manager.default_environment(), + }; + let cwd = primary_turn_environment + .map(|turn_environment| turn_environment.cwd.clone()) + .unwrap_or_else(|| session_configuration.cwd.clone()); + let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy); @@ -597,21 +614,6 @@ impl Session { &per_turn_config.to_models_manager_config(), ) .await; - let environment = match turn_environments.as_ref() { - Some(turn_environments) => turn_environments - .first() - .map(|turn_environment| Arc::clone(&turn_environment.environment)), - None => self.services.environment_manager.default_environment(), - }; - let cwd = turn_environments - .as_ref() - .and_then(|turn_environments| { - turn_environments - .first() - .map(|turn_environment| turn_environment.cwd.clone()) - }) - .unwrap_or_else(|| session_configuration.cwd.clone()); - per_turn_config.cwd = cwd.clone(); let plugin_outcome = self .services .plugins_manager From b49f5c03361a1829ddf57d2efb82490674c2dca3 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 13:19:38 -0700 Subject: [PATCH 26/38] Avoid expect in local environment lookup Co-authored-by: Codex --- codex-rs/exec-server/src/environment.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9cc18ea7be22..8e10c4a34ebb 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -115,8 +115,10 @@ impl EnvironmentManager { /// Returns the local environment instance used for internal runtime work. pub fn local_environment(&self) -> Arc { - self.get_environment(LOCAL_ENVIRONMENT_ID) - .expect("EnvironmentManager always has a local environment") + match self.get_environment(LOCAL_ENVIRONMENT_ID) { + Some(environment) => environment, + None => unreachable!("EnvironmentManager always has a local environment"), + } } /// Returns a named environment instance. From 2b0f0c2e7e26d8fe1e017a05539a4028f6bce77c Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:13:24 -0700 Subject: [PATCH 27/38] Add sticky thread environment selections Allow thread/start to configure sticky environment selections that are used by turns when no per-turn override is supplied. Per-turn environments continue to take precedence, while omitted thread selections preserve the existing default behavior. Co-authored-by: Codex --- .../app-server-protocol/src/protocol/v2.rs | 12 +++++++ .../app-server/src/codex_message_processor.rs | 14 +++++++++ codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/session/handlers.rs | 3 ++ codex-rs/core/src/session/mod.rs | 4 +++ codex-rs/core/src/session/session.rs | 8 +++++ codex-rs/core/src/session/tests.rs | 9 ++++++ .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 31 ++++++++++--------- codex-rs/core/src/thread_manager.rs | 14 +++++++++ codex-rs/protocol/src/protocol.rs | 4 +++ 11 files changed, 87 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2ecaa733edc2..9df301e878f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3074,6 +3074,14 @@ pub struct ThreadStartParams { pub ephemeral: Option, #[ts(optional = nullable)] pub session_start_source: Option, + /// Optional sticky environment selections for this thread. + /// + /// Omitted uses EnvironmentManager default behavior. Empty disables + /// environment access for turns that do not provide a turn override. + /// Non-empty selects the first environment as the current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, #[experimental("thread/start.dynamicTools")] #[ts(optional = nullable)] pub dynamic_tools: Option>, @@ -4662,6 +4670,10 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, /// Optional turn-scoped environment selections. + /// + /// Omitted uses the thread sticky environment selections. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. #[experimental("turn/start.environments")] #[ts(optional = nullable)] pub environments: Option>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d815cbb460..8ff4de3e76d8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2353,8 +2353,18 @@ impl CodexMessageProcessor { personality, ephemeral, session_start_source, + environments, persist_extended_history, } = params; + let environments = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect() + }); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -2392,6 +2402,7 @@ impl CodexMessageProcessor { typesafe_overrides, dynamic_tools, session_start_source, + environments, persist_extended_history, service_name, experimental_raw_events, @@ -2466,6 +2477,7 @@ impl CodexMessageProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, + environment_selections: Option>, persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, @@ -2613,6 +2625,7 @@ impl CodexMessageProcessor { persist_extended_history, service_name, request_trace, + environment_selections, ) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", @@ -7219,6 +7232,7 @@ impl CodexMessageProcessor { service_tier: params.service_tier, collaboration_mode, personality: params.personality, + environments: None, }, ) .await; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 858154eb77e3..ae7b71756870 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + environment_selections: None, analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), })) .await?; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 1751e7075db0..108bb1cfe923 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -166,6 +166,7 @@ pub(super) async fn user_input_or_turn_inner( personality, app_server_client_name: None, app_server_client_version: None, + environment_selections: None, }, None, environments, @@ -1077,6 +1078,7 @@ pub(super) async fn submission_loop( service_tier, collaboration_mode, personality, + environments, } => { let collaboration_mode = if let Some(collab_mode) = collaboration_mode { collab_mode @@ -1101,6 +1103,7 @@ pub(super) async fn submission_loop( reasoning_summary: summary, service_tier, personality, + environment_selections: environments, ..Default::default() }, ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cd6aca5dfe80..4f27edb5bdfa 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -401,6 +401,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) environment_selections: Option>, pub(crate) analytics_events_client: Option, } @@ -455,6 +456,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + environment_selections, analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -617,6 +619,7 @@ impl Codex { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections, original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -626,6 +629,7 @@ impl Codex { persist_extended_history, inherited_shell_snapshot, user_shell_override, + environment_selections, }; // Generate a unique ID for the lifetime of this Codex session. diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0c8ab535f513..f33993b196b3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -71,6 +71,9 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, + /// Sticky environment selections for turns that do not provide a turn-local override. + pub(super) environment_selections: + Option>, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -181,6 +184,9 @@ impl SessionConfiguration { if let Some(app_server_client_version) = updates.app_server_client_version.clone() { next_configuration.app_server_client_version = Some(app_server_client_version); } + if let Some(environment_selections) = updates.environment_selections.clone() { + next_configuration.environment_selections = Some(environment_selections); + } Ok(next_configuration) } } @@ -199,6 +205,8 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) environment_selections: + Option>, } pub(crate) struct AppServerClientMetadata { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5d838e3db56a..5855b62141c0 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1688,6 +1688,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; @@ -2321,6 +2322,7 @@ async fn set_rate_limits_retains_previous_credits() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2426,6 +2428,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2781,6 +2784,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3051,6 +3055,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3151,6 +3156,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3371,6 +3377,7 @@ async fn make_session_with_config_and_rx( cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3850,6 +3857,7 @@ fn op_kind_distinguishes_turn_ops() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, } .kind(), "override_turn_context" @@ -4381,6 +4389,7 @@ where cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5fb7fe33160c..06c189993c8a 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -651,6 +651,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + environment_selections: None, analytics_events_client: None, }) .await diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 998f016c9671..b44a416520d4 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -467,20 +467,6 @@ impl Session { updates: SessionSettingsUpdate, environment_selections: Option>, ) -> CodexResult> { - let turn_environments = match self.resolve_turn_environments(environment_selections) { - Ok(turn_environments) => turn_environments, - Err(err) => { - self.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }) - .await; - return Err(err); - } - }; let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -524,6 +510,23 @@ impl Session { return Err(CodexErr::InvalidRequest(message)); } }; + let effective_environment_selections = + environment_selections.or_else(|| session_configuration.environment_selections.clone()); + let turn_environments = + match self.resolve_turn_environments(effective_environment_selections) { + Ok(turn_environments) => turn_environments, + Err(err) => { + self.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + return Err(err); + } + }; self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 066bfe316525..8c74cd350609 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -501,6 +501,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, )) .await } @@ -513,6 +514,7 @@ impl ThreadManager { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + environment_selections: Option>, ) -> CodexResult { Box::pin(self.state.spawn_thread( config, @@ -523,6 +525,7 @@ impl ThreadManager { persist_extended_history, metrics_service_name, parent_trace, + environment_selections, /*user_shell_override*/ None, )) .await @@ -563,6 +566,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -582,6 +586,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -604,6 +609,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -712,6 +718,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -813,6 +820,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -840,6 +848,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -868,6 +877,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -885,6 +895,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + environment_selections: Option>, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -899,6 +910,7 @@ impl ThreadManagerState { /*inherited_shell_snapshot*/ None, /*inherited_exec_policy*/ None, parent_trace, + environment_selections, user_shell_override, )) .await @@ -918,6 +930,7 @@ impl ThreadManagerState { inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, parent_trace: Option, + environment_selections: Option>, user_shell_override: Option, ) -> CodexResult { let environment = self.environment_manager.default_environment(); @@ -955,6 +968,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + environment_selections, analytics_events_client: self.analytics_events_client.clone(), }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 615bc5bb8f28..6fe8e37e38df 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -567,6 +567,10 @@ pub enum Op { /// Updated personality preference. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Updated sticky environment selections for future turns. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, }, /// Approve a command execution From b52987cdef410d0da6ed5c507f8eba6cbcd0e050 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:18:19 -0700 Subject: [PATCH 28/38] Add app-server tests for sticky environments Cover sticky thread environment selections and turn-level overrides through the app-server v2 thread/start and turn/start JSON-RPC flow. The matrix mirrors the manual smoke cases for omitted, empty, local, remote, and local plus remote selections. Co-authored-by: Codex --- .../app-server/tests/suite/v2/turn_start.rs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b2b1ac1d83c6..c86227b92009 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -5,6 +5,7 @@ use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; use app_test_support::create_fake_rollout; use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; @@ -42,6 +43,7 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; @@ -1840,6 +1842,177 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let server = create_mock_responses_server_repeating_assistant("done").await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + let mut mcp = McpProcess::new_with_env( + &codex_home, + &[("CODEX_EXEC_SERVER_URL", Some("http://127.0.0.1:1"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + for case in [ + EnvironmentSelectionCase { + name: "sticky_unset_turn_unset", + sticky: None, + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_unset", + sticky: Some(&[]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_unset", + sticky: Some(&["local"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_unset", + sticky: Some(&["remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_remote_turn_unset", + sticky: Some(&["local", "remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_empty", + sticky: Some(&["local"]), + turn: Some(&[]), + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_local", + sticky: Some(&[]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_remote", + sticky: Some(&["local"]), + turn: Some(&["remote"]), + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_local", + sticky: Some(&["remote"]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_unset_turn_local_remote", + sticky: None, + turn: Some(&["local", "remote"]), + }, + ] { + run_environment_selection_case(&mut mcp, &workspace, case).await?; + } + + Ok(()) +} + +struct EnvironmentSelectionCase { + name: &'static str, + sticky: Option<&'static [&'static str]>, + turn: Option<&'static [&'static str]>, +} + +async fn run_environment_selection_case( + mcp: &mut McpProcess, + workspace: &Path, + case: EnvironmentSelectionCase, +) -> Result<()> { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + environments: environment_params(case.sticky, workspace)?, + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: format!("run {}", case.name), + text_elements: Vec::new(), + }], + environments: environment_params(case.turn, workspace)?, + cwd: Some(workspace.to_path_buf()), + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(started_notification.params.expect("turn/started params"))?; + assert_eq!(started.turn.id, turn.id, "{}", case.name); + + let completed_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notification + .params + .expect("turn/completed params"), + )?; + assert_eq!(completed.turn.id, turn.id, "{}", case.name); + assert_eq!( + completed.turn.status, + TurnStatus::Completed, + "{}", + case.name + ); + + mcp.clear_message_buffer(); + + Ok(()) +} + +fn environment_params( + ids: Option<&[&str]>, + cwd: &Path, +) -> Result>> { + ids.map(|ids| { + ids.iter() + .map(|id| { + Ok(TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.to_path_buf().try_into()?, + }) + }) + .collect() + }) + .transpose() +} + #[tokio::test] async fn turn_start_file_change_approval_v2() -> Result<()> { skip_if_no_network!(Ok(())); From ed4ac115c987706affa331a921ccf9371673b88a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:58:01 -0700 Subject: [PATCH 29/38] Add sticky thread environments Carry thread-level environment selections through app-server, core turn context, unified exec, and environment manager plumbing. Update generated schema and existing tests for the new environment fields. Co-authored-by: Codex --- .../schema/json/v2/ThreadStartParams.json | 19 ++ codex-rs/app-server/src/lib.rs | 15 +- .../app-server/tests/suite/v2/skills_list.rs | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/core/src/config/mod.rs | 123 +++++++++++++ .../core/src/context/environment_context.rs | 82 ++++++++- .../src/context/environment_context_tests.rs | 45 +++++ codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/tests.rs | 79 ++++++++ codex-rs/core/src/session/turn_context.rs | 88 ++++++++- .../core/src/tools/handlers/unified_exec.rs | 75 ++++++-- codex-rs/core/src/tools/orchestrator.rs | 5 +- .../core/src/tools/runtimes/unified_exec.rs | 36 ++-- codex-rs/core/src/tools/sandboxing.rs | 4 + codex-rs/core/src/unified_exec/mod.rs | 3 + .../core/src/unified_exec/process_manager.rs | 2 + .../tests/suite/collaboration_instructions.rs | 13 ++ codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/compact_remote.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 + codex-rs/core/tests/suite/model_switching.rs | 3 + .../core/tests/suite/model_visible_layout.rs | 1 + codex-rs/core/tests/suite/override_updates.rs | 3 + .../core/tests/suite/permissions_messages.rs | 4 + codex-rs/core/tests/suite/personality.rs | 4 + codex-rs/core/tests/suite/prompt_caching.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 2 + codex-rs/core/tests/suite/resume.rs | 1 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 94 ++++++++++ codex-rs/exec-server/src/client.rs | 90 ++++++++-- codex-rs/exec-server/src/client_api.rs | 16 ++ codex-rs/exec-server/src/connection.rs | 15 +- codex-rs/exec-server/src/environment.rs | 168 +++++++++++++++--- codex-rs/exec-server/src/lib.rs | 5 + codex-rs/exec-server/src/server/transport.rs | 21 ++- codex-rs/exec/src/lib.rs | 9 +- codex-rs/features/src/lib.rs | 8 + codex-rs/mcp-server/src/lib.rs | 15 +- codex-rs/tools/src/local_tool.rs | 9 + codex-rs/tools/src/local_tool_tests.rs | 2 + codex-rs/tools/src/tool_config.rs | 3 + codex-rs/tools/src/tool_registry_plan.rs | 2 + .../tools/src/tool_registry_plan_tests.rs | 1 + codex-rs/tui/src/app_command.rs | 2 + .../tui/src/chatwidget/tests/permissions.rs | 1 + codex-rs/tui/src/lib.rs | 14 +- 48 files changed, 984 insertions(+), 112 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9b1cba6d1a7f..e40fe65cc759 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -114,6 +118,21 @@ "clear" ], "type": "string" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index d3e874c5c44f..10f6d5f1c807 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -362,12 +362,10 @@ pub async fn run_main_with_transport( session_source: SessionSource, auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( - ExecServerRuntimePaths::from_optional_paths( - arg0_paths.codex_self_exe.clone(), - arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - ))); + let runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -444,6 +442,11 @@ pub async fn run_main_with_transport( })? } }; + let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; + let environment_manager = Arc::new( + EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { let (path, range) = exec_policy_warning_location(&err); diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 8675b3a429b2..7712b54827cf 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -322,6 +322,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( personality: None, ephemeral: None, session_start_source: None, + environments: None, dynamic_tools: None, mock_experimental_field: None, experimental_raw_events: false, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9852a2cd5fa3..e8e997047523 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -424,7 +424,7 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { - /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default). + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default) and `stdio://`. #[arg( long = "listen", value_name = "URL", diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7ae710f83cbc..f2681bafdb49 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,8 +49,13 @@ use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; +use codex_exec_server::EnvironmentManagerArgs; +use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_exec_server::RemoteExecServerTransport; pub use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -833,6 +838,38 @@ impl Config { } } + pub fn environment_manager_args( + &self, + exec_server_url: Option, + local_runtime_paths: ExecServerRuntimePaths, + ) -> std::io::Result { + let environment_config: EnvironmentConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let Some(environments) = environment_config.environments.as_ref() else { + if environment_config.default_environment.is_none() { + return Ok(EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths, + } + .into()); + } + return build_configured_environment_manager_args( + environment_config.default_environment, + &[], + local_runtime_paths, + ); + }; + + build_configured_environment_manager_args( + environment_config.default_environment, + environments, + local_runtime_paths, + ) + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1283,6 +1320,92 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +#[derive(Debug, Deserialize, Default)] +struct EnvironmentConfigToml { + default_environment: Option, + environments: Option>, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentToml { + id: String, + url: Option, + exec_server_command: Option, +} + +fn build_configured_environment_manager_args( + default_environment: Option, + environments: &[EnvironmentToml], + local_runtime_paths: ExecServerRuntimePaths, +) -> std::io::Result { + let mut configured_environments = Vec::with_capacity(environments.len()); + for environment in environments { + let id = environment.id.trim(); + if id.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "environment id must not be empty", + )); + } + if id == "local" || id.eq_ignore_ascii_case("none") { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment id `{id}` is reserved"), + )); + } + + let url = environment + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let command = environment + .exec_server_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let transport = match (url, command) { + (Some(url), None) => { + if !(url.starts_with("ws://") || url.starts_with("wss://")) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` url must start with ws:// or wss://"), + )); + } + RemoteExecServerTransport::WebSocket { + url: url.to_string(), + } + } + (None, Some(command)) => RemoteExecServerTransport::Command { + command: command.to_string(), + }, + (Some(_), Some(_)) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set only one of url or exec_server_command"), + )); + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set url or exec_server_command"), + )); + } + }; + configured_environments.push(ConfiguredEnvironmentSpec { + id: id.to_string(), + transport, + }); + } + + Ok(ConfiguredEnvironmentManagerArgs { + default_environment, + environments: configured_environments, + local_runtime_paths, + } + .into()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index c4e77624f864..d3105a03f2bd 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,5 +1,6 @@ use crate::session::turn_context::TurnContext; use crate::shell::Shell; +use codex_features::Feature; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use std::path::PathBuf; @@ -9,6 +10,7 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] pub(crate) struct EnvironmentContext { pub(crate) cwd: Option, + pub(crate) environments: Option>, pub(crate) shell: String, pub(crate) current_date: Option, pub(crate) timezone: Option, @@ -16,6 +18,13 @@ pub(crate) struct EnvironmentContext { pub(crate) subagents: Option, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct EnvironmentContextEnvironment { + pub(crate) id: String, + pub(crate) cwd: PathBuf, + pub(crate) primary: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct NetworkContext { allowed_domains: Vec, @@ -39,9 +48,30 @@ impl EnvironmentContext { timezone: Option, network: Option, subagents: Option, + ) -> Self { + Self::new_with_environments( + cwd, + /*environments*/ None, + shell, + current_date, + timezone, + network, + subagents, + ) + } + + fn new_with_environments( + cwd: Option, + environments: Option>, + shell: String, + current_date: Option, + timezone: Option, + network: Option, + subagents: Option, ) -> Self { Self { cwd, + environments, shell, current_date, timezone, @@ -56,6 +86,7 @@ impl EnvironmentContext { pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { let EnvironmentContext { cwd, + environments, current_date, timezone, network, @@ -63,6 +94,7 @@ impl EnvironmentContext { shell: _, } = other; self.cwd == *cwd + && self.environments == *environments && self.current_date == *current_date && self.timezone == *timezone && self.network == *network @@ -83,8 +115,9 @@ impl EnvironmentContext { } else { before_network }; - EnvironmentContext::new( + EnvironmentContext::new_with_environments( cwd, + after.environments.clone(), after.shell.clone(), after.current_date.clone(), after.timezone.clone(), @@ -94,8 +127,9 @@ impl EnvironmentContext { } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new( + Self::new_with_environments( Some(turn_context.cwd.to_path_buf()), + Self::environments_from_turn_context(turn_context), shell.name().to_string(), turn_context.current_date.clone(), turn_context.timezone.clone(), @@ -159,6 +193,30 @@ impl EnvironmentContext { denied_domains.clone(), )) } + + fn environments_from_turn_context( + turn_context: &TurnContext, + ) -> Option> { + if !turn_context.features.enabled(Feature::MultiEnvironmentTools) { + return None; + } + let environments = turn_context.environments.as_ref()?; + if environments.is_empty() { + return None; + } + + Some( + environments + .iter() + .enumerate() + .map(|(index, environment)| EnvironmentContextEnvironment { + id: environment.environment_id.clone(), + cwd: environment.cwd.to_path_buf(), + primary: index == 0, + }) + .collect(), + ) + } } impl ContextualUserFragment for EnvironmentContext { @@ -171,6 +229,26 @@ impl ContextualUserFragment for EnvironmentContext { if let Some(cwd) = &self.cwd { lines.push(format!(" {}", cwd.to_string_lossy())); } + if let Some(environments) = &self.environments { + lines.push(" ".to_string()); + for environment in environments { + let primary = if environment.primary { + " primary=\"true\"" + } else { + "" + }; + lines.push(format!( + " ", + environment.id, primary + )); + lines.push(format!( + " {}", + environment.cwd.to_string_lossy() + )); + lines.push(" ".to_string()); + } + lines.push(" ".to_string()); + } lines.push(format!(" {}", self.shell)); if let Some(current_date) = &self.current_date { diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 84f8c0d99f00..3279797fb380 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -72,6 +72,51 @@ fn serialize_environment_context_with_network() { assert_eq!(context.render(), expected); } +#[test] +fn serialize_environment_context_with_selected_environments() { + let context = EnvironmentContext::new_with_environments( + Some(test_path_buf("/repo-primary")), + Some(vec![ + EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: test_path_buf("/repo-primary"), + primary: true, + }, + EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo-local"), + primary: false, + }, + ]), + fake_shell_name(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + /*network*/ None, + /*subagents*/ None, + ); + + let expected = format!( + r#" + {primary} + + + {primary} + + + {local} + + + bash + 2026-02-26 + America/Los_Angeles +"#, + primary = test_path_buf("/repo-primary").display(), + local = test_path_buf("/repo-local").display() + ); + + assert_eq!(context.render(), expected); +} + #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4f27edb5bdfa..da8daf1f2141 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -110,6 +110,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -629,7 +630,6 @@ impl Codex { persist_extended_history, inherited_shell_snapshot, user_shell_override, - environment_selections, }; // Generate a unique ID for the lifetime of this Codex session. diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5855b62141c0..d921d807b8a2 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3944,6 +3944,36 @@ async fn turn_environment_selection_sets_primary_environment() { )); assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve"); + assert_eq!(primary_environment.environment_id.as_deref(), Some("local")); + assert!(std::sync::Arc::ptr_eq( + &primary_environment.environment, + &turn_environments[0].environment + )); + assert_eq!(primary_environment.cwd, selected_cwd); + let tool_environment = turn_context + .environment_for_tool(Some("local")) + .expect("tool environment should resolve by id"); + assert_eq!(tool_environment.environment_id.as_deref(), Some("local")); + assert!(std::sync::Arc::ptr_eq( + &tool_environment.environment, + &turn_environments[0].environment + )); + let default_tool_environment = turn_context + .environment_for_tool(/*environment_id*/ None) + .expect("tool environment should default to primary"); + assert_eq!( + default_tool_environment.environment_id.as_deref(), + Some("local") + ); + assert_eq!( + turn_context + .resolve_path_for_environment(&primary_environment, Some("relative/path".to_string())), + selected_cwd.join("relative/path") + ); } #[tokio::test] @@ -3989,6 +4019,15 @@ async fn multiple_turn_environment_selections_use_first_as_primary_environment() )); assert_eq!(turn_context.cwd, first_cwd); assert_eq!(turn_context.config.cwd, first_cwd); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve"); + assert_eq!(primary_environment.cwd, first_cwd); + let tool_environment = turn_context + .environment_for_tool(Some("local")) + .expect("tool environment should resolve by id"); + assert_eq!(tool_environment.cwd, first_cwd); } #[tokio::test] @@ -4015,6 +4054,46 @@ async fn empty_turn_environment_selection_clears_primary_environment() { .len(), 0 ); + assert!(turn_context.primary_environment().is_none()); + assert!( + turn_context + .environment_for_tool(/*environment_id*/ None) + .is_none() + ); + assert!(turn_context.environment_for_tool(Some("local")).is_none()); +} + +#[tokio::test] +async fn primary_environment_falls_back_to_compatibility_projection() { + let (_session, turn_context, _rx) = make_session_and_context_with_rx().await; + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve from compatibility fields"); + assert_eq!(primary_environment.environment_id.as_deref(), None); + assert!(std::sync::Arc::ptr_eq( + &primary_environment.environment, + turn_context + .environment + .as_ref() + .expect("compatibility environment should be set") + )); + assert_eq!(primary_environment.cwd, turn_context.cwd); + + let default_tool_environment = turn_context + .environment_for_tool(/*environment_id*/ None) + .expect("tool environment should resolve from compatibility fields"); + assert_eq!(default_tool_environment.environment_id.as_deref(), None); + assert!(std::sync::Arc::ptr_eq( + &default_tool_environment.environment, + &primary_environment.environment + )); + assert!(turn_context.environment_for_tool(Some("local")).is_none()); + assert_eq!( + turn_context + .resolve_path_for_environment(&primary_environment, Some("relative/path".to_string())), + turn_context.cwd.join("relative/path") + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b44a416520d4..243f5b932795 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -33,6 +33,24 @@ pub(crate) struct TurnEnvironment { pub(crate) cwd: AbsolutePathBuf, } +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub(crate) struct SelectedTurnEnvironment { + pub(crate) environment_id: Option, + pub(crate) environment: Arc, + pub(crate) cwd: AbsolutePathBuf, +} + +impl TurnEnvironment { + fn selected(&self) -> SelectedTurnEnvironment { + SelectedTurnEnvironment { + environment_id: Some(self.environment_id.clone()), + environment: Arc::clone(&self.environment), + cwd: self.cwd.clone(), + } + } +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -216,14 +234,67 @@ impl TurnContext { .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } + #[allow(dead_code)] + pub(crate) fn primary_environment(&self) -> Option { + self.environments + .as_ref() + .and_then(|environments| environments.first()) + .map(TurnEnvironment::selected) + .or_else(|| { + self.environment + .as_ref() + .map(|environment| SelectedTurnEnvironment { + environment_id: None, + environment: Arc::clone(environment), + cwd: self.cwd.clone(), + }) + }) + } + + #[allow(dead_code)] + pub(crate) fn environment_for_tool( + &self, + environment_id: Option<&str>, + ) -> Option { + match environment_id { + Some(environment_id) => self + .environments + .as_ref()? + .iter() + .find(|environment| environment.environment_id == environment_id) + .map(TurnEnvironment::selected), + None => self.primary_environment(), + } + } + + #[allow(dead_code)] + pub(crate) fn resolve_path_for_environment( + &self, + environment: &SelectedTurnEnvironment, + path: Option, + ) -> AbsolutePathBuf { + path.as_ref().map_or_else( + || environment.cwd.clone(), + |path| environment.cwd.join(path), + ) + } + pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, + ) -> FileSystemSandboxContext { + self.file_system_sandbox_context_for_cwd(&self.cwd, additional_permissions) + } + + pub(crate) fn file_system_sandbox_context_for_cwd( + &self, + cwd: &AbsolutePathBuf, + additional_permissions: Option, ) -> FileSystemSandboxContext { FileSystemSandboxContext { sandbox_policy: self.sandbox_policy.get().clone(), - sandbox_policy_cwd: Some(self.cwd.clone()), - file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), + sandbox_policy_cwd: Some(cwd.clone()), + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(cwd), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -234,15 +305,16 @@ impl TurnContext { } } - fn non_legacy_file_system_sandbox_policy(&self) -> Option { + fn non_legacy_file_system_sandbox_policy( + &self, + cwd: &AbsolutePathBuf, + ) -> Option { // Omit the derived split filesystem policy when it is equivalent to // the legacy sandbox policy. This keeps turn-context payloads stable // while both fields exist; once callers consume only the split policy, // this comparison and the legacy projection should go away. - let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( - self.sandbox_policy.get(), - &self.cwd, - ); + let legacy_file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(self.sandbox_policy.get(), cwd); (self.file_system_sandbox_policy != legacy_file_system_sandbox_policy) .then(|| self.file_system_sandbox_policy.clone()) } @@ -263,7 +335,7 @@ impl TurnContext { approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), - file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(&self.cwd), model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index fd04d42f2ed8..33f9bc6bf3e7 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -64,6 +64,12 @@ pub(crate) struct ExecCommandArgs { prefix_rule: Option>, } +#[derive(Debug, Deserialize)] +struct ToolEnvironmentArgs { + #[serde(default, alias = "environmentId")] + environment_id: Option, +} + #[derive(Debug, Deserialize)] struct WriteStdinArgs { // The model is trained on `session_id`. @@ -181,21 +187,47 @@ impl ToolHandler for UnifiedExecHandler { } }; - let Some(environment) = turn.environment.as_ref() else { + let Some(default_environment) = turn.environment_for_tool(/*environment_id*/ None) else { return Err(FunctionCallError::RespondToModel( "unified exec is unavailable in this session".to_string(), )); }; - let fs = environment.get_filesystem(); let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); let response = match tool_name.name.as_str() { "exec_command" => { - let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?; + let selector: ToolEnvironmentArgs = parse_arguments(&arguments)?; + let requested_environment_id = if context + .turn + .features + .enabled(Feature::MultiEnvironmentTools) + { + selector.environment_id.as_deref() + } else { + None + }; + let should_intercept_apply_patch = requested_environment_id.is_none(); + let tool_environment = context + .turn + .environment_for_tool(requested_environment_id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id { + Some(environment_id) => { + format!( + "environment `{environment_id}` is unavailable in this session" + ) + } + None => "unified exec is unavailable in this session".to_string(), + }) + })?; + let fs = tool_environment.environment.get_filesystem(); + let cwd = resolve_workdir_base_path(&arguments, &tool_environment.cwd)?; let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; - let workdir = context.turn.resolve_path(args.workdir.clone()); + let workdir = context + .turn + .resolve_path_for_environment(&tool_environment, args.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), context.turn.as_ref(), @@ -211,7 +243,8 @@ impl ToolHandler for UnifiedExecHandler { turn.tools_config.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; - let command_for_display = codex_shell_command::parse_command::shlex_join(&command); + let command_for_display = + codex_shell_command::parse_command::shlex_join(&command); let ExecCommandArgs { workdir, @@ -259,7 +292,11 @@ impl ToolHandler for UnifiedExecHandler { let workdir = workdir.filter(|value| !value.is_empty()); - let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir))); + let workdir = workdir.map(|dir| { + context + .turn + .resolve_path_for_environment(&tool_environment, Some(dir)) + }); let cwd = workdir.clone().unwrap_or(cwd); let normalized_additional_permissions = match implicit_granted_permissions( sandbox_permissions, @@ -286,17 +323,18 @@ impl ToolHandler for UnifiedExecHandler { } }; - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - &tool_name.name, - ) - .await? + if should_intercept_apply_patch + && let Some(output) = intercept_apply_patch( + &command, + &cwd, + fs.as_ref(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + &tool_name.name, + ) + .await? { manager.release_process_id(process_id).await; return Ok(ExecCommandToolOutput { @@ -320,6 +358,8 @@ impl ToolHandler for UnifiedExecHandler { command, hook_command: args.cmd, process_id, + environment_id: tool_environment.environment_id.clone(), + environment: tool_environment.environment, yield_time_ms, max_output_tokens, workdir, @@ -363,6 +403,7 @@ impl ToolHandler for UnifiedExecHandler { } } "write_stdin" => { + let _ = default_environment; let args: WriteStdinArgs = parse_arguments(&arguments)?; let response = manager .write_stdin(WriteStdinRequest { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index c1b960bc8dbb..e57bd09d0c6d 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -200,6 +200,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + let sandbox_cwd = tool.sandbox_cwd(req, turn_ctx); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, @@ -207,7 +208,7 @@ impl ToolOrchestrator { network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -348,7 +349,7 @@ impl ToolOrchestrator { network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: None, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index fdd434e5425d..c0f1da7aed32 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -34,6 +34,7 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -45,6 +46,7 @@ use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use std::sync::Arc; /// Request payload used by the unified-exec runtime after approvals and /// sandbox preferences have been resolved for the current turn. @@ -53,6 +55,8 @@ pub struct UnifiedExecRequest { pub command: Vec, pub hook_command: String, pub process_id: i32, + pub environment_id: Option, + pub environment: Arc, pub cwd: AbsolutePathBuf, pub env: HashMap, pub exec_server_env_config: Option, @@ -72,6 +76,7 @@ pub struct UnifiedExecRequest { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, + pub environment_id: Option, pub cwd: AbsolutePathBuf, pub tty: bool, pub sandbox_permissions: SandboxPermissions, @@ -111,6 +116,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { command: canonicalize_command_for_approval(&req.command), + environment_id: req.environment_id.clone(), cwd: req.cwd.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, @@ -198,6 +204,14 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn sandbox_cwd<'b>( + &self, + req: &'b UnifiedExecRequest, + _turn_ctx: &'b crate::session::turn_context::TurnContext, + ) -> &'b AbsolutePathBuf { + &req.cwd + } + fn network_approval_spec( &self, req: &UnifiedExecRequest, @@ -219,11 +233,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let environment_is_remote = ctx - .turn - .environment - .as_ref() - .is_some_and(|environment| environment.is_remote()); + let environment_is_remote = req.environment.is_remote(); let command = if environment_is_remote { base_command.to_vec() } else { @@ -267,12 +277,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .await? { Some(prepared) => { - let Some(environment) = ctx.turn.environment.as_ref() else { - return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; - if environment.is_remote() { + if req.environment.is_remote() { return Err(ToolError::Rejected( "unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(), )); @@ -284,7 +289,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &prepared.exec_request, req.tty, prepared.spawn_lifecycle, - environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { @@ -315,18 +320,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); - let Some(environment) = ctx.turn.environment.as_ref() else { - return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; self.manager .open_session_with_exec_env( req.process_id, &exec_env, req.tty, Box::new(NoopSpawnLifecycle), - environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 87bef4617c31..d6c17118e586 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -329,6 +329,10 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } + fn sandbox_cwd<'a>(&self, _req: &'a Req, turn_ctx: &'a TurnContext) -> &'a AbsolutePathBuf { + &turn_ctx.cwd + } + async fn run( &mut self, req: &Req, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 01c8914bc24c..06dd6fb48a71 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -27,6 +27,7 @@ use std::collections::HashSet; use std::sync::Arc; use std::sync::Weak; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -90,6 +91,8 @@ pub(crate) struct ExecCommandRequest { pub command: Vec, pub hook_command: String, pub process_id: i32, + pub environment_id: Option, + pub environment: Arc, pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5181359698df..d401946819b5 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -753,6 +753,8 @@ impl UnifiedExecProcessManager { command: request.command.clone(), hook_command: request.hook_command.clone(), process_id: request.process_id, + environment_id: request.environment_id.clone(), + environment: Arc::clone(&request.environment), cwd, env, exec_server_env_config: Some(exec_server_env_config), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3f8d2378ade1..610671561b0d 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -135,6 +135,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; @@ -236,6 +237,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; @@ -290,6 +292,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu service_tier: None, collaboration_mode: Some(base_mode), personality: None, + environments: None, }) .await?; @@ -362,6 +365,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), personality: None, + environments: None, }) .await?; @@ -391,6 +395,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), personality: None, + environments: None, }) .await?; @@ -449,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; @@ -478,6 +484,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; @@ -538,6 +545,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang Some(default_text), )), personality: None, + environments: None, }) .await?; @@ -570,6 +578,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang Some(plan_text), )), personality: None, + environments: None, }) .await?; @@ -631,6 +640,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() Some(collab_text), )), personality: None, + environments: None, }) .await?; @@ -663,6 +673,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() Some(collab_text), )), personality: None, + environments: None, }) .await?; @@ -727,6 +738,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; @@ -801,6 +813,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { }, }), personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index c37326ffc956..f37e6f307e14 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3090,6 +3090,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("override turn context"); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 58ed42c288c0..98ef96b199df 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -2110,6 +2110,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; } @@ -2225,6 +2226,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; codex diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 48d359529b88..01a710526424 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -589,6 +589,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { }, }), personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f7fe7cc32224..aba3ed3bfb9a 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -37,6 +37,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("submit override"); @@ -75,6 +76,7 @@ async fn override_turn_context_does_not_create_config_file() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 54c2d1035f2f..793b0f5e7ed9 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -154,6 +154,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -253,6 +254,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; @@ -1045,6 +1047,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index c93294121cfb..175979725a1a 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -465,6 +465,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; resumed diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 13ace1fe6df9..193accbccb6e 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -125,6 +125,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -167,6 +168,7 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -206,6 +208,7 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index fb0acf7519b2..8c7bc22e8ab6 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -112,6 +112,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -244,6 +245,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -332,6 +334,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -437,6 +440,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 7c72478acee6..944eba13af3f 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -359,6 +359,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + environments: None, }) .await?; @@ -466,6 +467,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; @@ -586,6 +588,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; @@ -867,6 +870,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index cee43e9820e0..7f2d090e30c4 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -451,6 +451,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -536,6 +537,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index e62fa0c3234d..d54e4b8b6e70 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -602,6 +602,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -847,6 +848,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index febeb926b160..6b6a30fd30d5 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -435,6 +435,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; resumed diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index faf469c9812b..9baae7c48f97 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -846,6 +846,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 58dbad7b1d22..d4612b6d403f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::process::process_is_alive; @@ -339,6 +340,99 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_environment_id_targets_non_primary_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_exec_server_url("ws://127.0.0.1:9") + .with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow unified exec"); + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("test config should allow multi-environment tools"); + }); + let test = builder.build(&server).await?; + let selected_local_cwd = create_workspace_directory(&test, "selected-local").await?; + let primary_remote_cwd = test.config.cwd.join("primary-remote"); + + let call_id = "uexec-env-local"; + let args = json!({ + "cmd": "pwd", + "environment_id": "local", + "yield_time_ms": 5_000, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + let request_log = mount_sse_sequence(&server, responses).await; + + test.submit_turn_with_environments( + "print the selected local cwd", + Some(vec![ + TurnEnvironmentSelection { + environment_id: "remote".to_string(), + cwd: primary_remote_cwd, + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_local_cwd.clone().try_into()?, + }, + ]), + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let bodies = request_log + .requests() + .into_iter() + .map(|request| request.body_json()) + .collect::>(); + let request_text = serde_json::to_string(&bodies)?; + assert!( + request_text.contains(""), + "environment list should be visible to the model: {request_text}" + ); + assert!( + request_text.contains("id=\"local\""), + "local environment id should be visible to the model: {request_text}" + ); + assert!( + request_text.contains("\"environment_id\""), + "exec_command schema should expose environment_id when gated: {request_text}" + ); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs + .get(call_id) + .expect("missing exec_command environment-id output"); + assert_eq!( + output.output.trim(), + selected_local_cwd.display().to_string() + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 375571b77930..a79171f438eb 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -14,13 +14,17 @@ use tokio::sync::OnceCell; use tokio::sync::mpsc; use tokio::sync::watch; +use tokio::process::Child; +use tokio::process::Command; use tokio::time::timeout; use tokio_tungstenite::connect_async; use tracing::debug; use crate::ProcessId; +use crate::client_api::CommandExecServerConnectArgs; use crate::client_api::ExecServerClientConnectOptions; use crate::client_api::RemoteExecServerConnectArgs; +use crate::client_api::RemoteExecServerTransport; use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; @@ -102,6 +106,16 @@ impl From for ExecServerClientConnectOptions { } } +impl From for ExecServerClientConnectOptions { + fn from(value: CommandExecServerConnectArgs) -> Self { + Self { + client_name: value.client_name, + initialize_timeout: value.initialize_timeout, + resume_session_id: value.resume_session_id, + } + } +} + impl RemoteExecServerConnectArgs { pub fn new(websocket_url: String, client_name: String) -> Self { Self { @@ -162,11 +176,15 @@ struct Inner { http_body_stream_next_id: AtomicU64, session_id: std::sync::RwLock>, reader_task: tokio::task::JoinHandle<()>, + child: Option, } impl Drop for Inner { fn drop(&mut self) { self.reader_task.abort(); + if let Some(child) = &mut self.child { + let _ = child.start_kill(); + } } } @@ -177,14 +195,14 @@ pub struct ExecServerClient { #[derive(Clone)] pub(crate) struct LazyRemoteExecServerClient { - websocket_url: String, + transport: RemoteExecServerTransport, client: Arc>, } impl LazyRemoteExecServerClient { - pub(crate) fn new(websocket_url: String) -> Self { + pub(crate) fn new(transport: RemoteExecServerTransport) -> Self { Self { - websocket_url, + transport, client: Arc::new(OnceCell::new()), } } @@ -192,14 +210,27 @@ impl LazyRemoteExecServerClient { pub(crate) async fn get(&self) -> Result { self.client .get_or_try_init(|| async { - ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { - websocket_url: self.websocket_url.clone(), - client_name: "codex-environment".to_string(), - connect_timeout: Duration::from_secs(5), - initialize_timeout: Duration::from_secs(5), - resume_session_id: None, - }) - .await + match &self.transport { + RemoteExecServerTransport::WebSocket { url } => { + ExecServerClient::connect_websocket(RemoteExecServerConnectArgs { + websocket_url: url.clone(), + client_name: "codex-environment".to_string(), + connect_timeout: Duration::from_secs(5), + initialize_timeout: Duration::from_secs(5), + resume_session_id: None, + }) + .await + } + RemoteExecServerTransport::Command { command } => { + ExecServerClient::connect_command(CommandExecServerConnectArgs { + command: command.clone(), + client_name: "codex-environment".to_string(), + initialize_timeout: Duration::from_secs(5), + resume_session_id: None, + }) + .await + } + } }) .await .cloned() @@ -255,6 +286,39 @@ impl ExecServerClient { format!("exec-server websocket {websocket_url}"), ), args.into(), + /*child*/ None, + ) + .await + } + + pub async fn connect_command( + args: CommandExecServerConnectArgs, + ) -> Result { + let command = args.command.clone(); + let mut child = Command::new("sh") + .arg("-lc") + .arg(&command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .map_err(ExecServerError::Spawn)?; + let stdout = child.stdout.take().ok_or_else(|| { + ExecServerError::Protocol(format!("exec-server command `{command}` has no stdout")) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + ExecServerError::Protocol(format!("exec-server command `{command}` has no stdin")) + })?; + + Self::connect( + JsonRpcConnection::from_stdio( + stdout, + stdin, + format!("exec-server command `{command}`"), + ), + args.into(), + Some(child), ) .await } @@ -410,6 +474,7 @@ impl ExecServerClient { async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, + child: Option, ) -> Result { let (rpc_client, mut events_rx) = RpcClient::new(connection); let inner = Arc::new_cyclic(|weak| { @@ -455,6 +520,7 @@ impl ExecServerClient { http_body_stream_next_id: AtomicU64::new(1), session_id: std::sync::RwLock::new(None), reader_task, + child, } }); @@ -949,6 +1015,7 @@ mod tests { "test-exec-server-client".to_string(), ), ExecServerClientConnectOptions::default(), + /*child*/ None, ) .await .expect("client should connect"); @@ -1092,6 +1159,7 @@ mod tests { "test-exec-server-client".to_string(), ), ExecServerClientConnectOptions::default(), + /*child*/ None, ) .await .expect("client should connect"); diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index ac4371e2ea46..e2aa614e0782 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -8,6 +8,13 @@ pub struct ExecServerClientConnectOptions { pub resume_session_id: Option, } +/// Transport used to connect to a remote exec-server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteExecServerTransport { + WebSocket { url: String }, + Command { command: String }, +} + /// WebSocket connection arguments for a remote exec-server. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteExecServerConnectArgs { @@ -17,3 +24,12 @@ pub struct RemoteExecServerConnectArgs { pub initialize_timeout: Duration, pub resume_session_id: Option, } + +/// Command-spawn connection arguments for a remote exec-server over stdio. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandExecServerConnectArgs { + pub command: String, + pub client_name: String, + pub initialize_timeout: Duration, + pub resume_session_id: Option, +} diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index 21eac6b4c529..b8e361058312 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -1,22 +1,17 @@ use codex_app_server_protocol::JSONRPCMessage; use futures::SinkExt; use futures::StreamExt; +use tokio::io::AsyncBufReadExt; use tokio::io::AsyncRead; use tokio::io::AsyncWrite; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::io::BufWriter; use tokio::sync::mpsc; use tokio::sync::watch; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Message; -#[cfg(test)] -use tokio::io::AsyncBufReadExt; -#[cfg(test)] -use tokio::io::AsyncWriteExt; -#[cfg(test)] -use tokio::io::BufReader; -#[cfg(test)] -use tokio::io::BufWriter; - pub(crate) const CHANNEL_CAPACITY: usize = 128; #[derive(Debug)] @@ -34,7 +29,6 @@ pub(crate) struct JsonRpcConnection { } impl JsonRpcConnection { - #[cfg(test)] pub(crate) fn from_stdio(reader: R, writer: W, connection_label: String) -> Self where R: AsyncRead + Unpin + Send + 'static, @@ -298,7 +292,6 @@ async fn send_malformed_message( .await; } -#[cfg(test)] async fn write_jsonrpc_line_message( writer: &mut BufWriter, message: &JSONRPCMessage, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 8e10c4a34ebb..922c65f89f2d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::ExecServerError; use crate::ExecServerRuntimePaths; +use crate::RemoteExecServerTransport; use crate::client::LazyRemoteExecServerClient; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; @@ -16,10 +17,13 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; /// Owns the execution/filesystem environments available to the Codex runtime. /// /// `EnvironmentManager` is a shared registry for concrete environments. It -/// always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. When -/// `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a remote -/// environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default -/// environment. Otherwise the local environment is the default. +/// always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. Additional +/// environments are remote exec-server endpoints that can connect over either a +/// websocket URL or a command-backed stdio JSON-RPC transport. +/// +/// In legacy mode, when `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it +/// also creates a remote environment under [`REMOTE_ENVIRONMENT_ID`] and makes +/// that the default environment. Otherwise the local environment is the default. /// /// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving /// the default environment unset while still keeping the local environment @@ -61,6 +65,37 @@ impl EnvironmentManagerArgs { } } +#[derive(Clone, Debug)] +pub struct ConfiguredEnvironmentManagerArgs { + pub default_environment: Option, + pub environments: Vec, + pub local_runtime_paths: ExecServerRuntimePaths, +} + +#[derive(Clone, Debug)] +pub struct ConfiguredEnvironmentSpec { + pub id: String, + pub transport: RemoteExecServerTransport, +} + +#[derive(Clone, Debug)] +pub enum EnvironmentManagerConfig { + ExecServerUrl(EnvironmentManagerArgs), + Configured(ConfiguredEnvironmentManagerArgs), +} + +impl From for EnvironmentManagerConfig { + fn from(args: EnvironmentManagerArgs) -> Self { + Self::ExecServerUrl(args) + } +} + +impl From for EnvironmentManagerConfig { + fn from(args: ConfiguredEnvironmentManagerArgs) -> Self { + Self::Configured(args) + } +} + impl EnvironmentManager { /// Builds a test-only manager without configured sandbox helper paths. pub fn default_for_tests() -> Self { @@ -75,7 +110,15 @@ impl EnvironmentManager { /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local /// runtime paths used when creating local filesystem helpers. - pub fn new(args: EnvironmentManagerArgs) -> Self { + pub fn new(config: impl Into) -> Self { + match config.into() { + EnvironmentManagerConfig::ExecServerUrl(args) => Self::from_exec_server_url_args(args), + EnvironmentManagerConfig::Configured(args) => Self::from_configured_args(args) + .unwrap_or_else(|err| panic!("invalid environment manager config: {err}")), + } + } + + fn from_exec_server_url_args(args: EnvironmentManagerArgs) -> Self { let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, @@ -92,7 +135,12 @@ impl EnvironmentManager { Some(exec_server_url) => { environments.insert( REMOTE_ENVIRONMENT_ID.to_string(), - Arc::new(Environment::remote(exec_server_url, local_runtime_paths)), + Arc::new(Environment::remote_with_runtime_paths( + RemoteExecServerTransport::WebSocket { + url: exec_server_url, + }, + Some(local_runtime_paths), + )), ); Some(REMOTE_ENVIRONMENT_ID.to_string()) } @@ -106,6 +154,73 @@ impl EnvironmentManager { } } + pub fn try_new(config: impl Into) -> Result { + match config.into() { + EnvironmentManagerConfig::ExecServerUrl(args) => { + Ok(Self::from_exec_server_url_args(args)) + } + EnvironmentManagerConfig::Configured(args) => Self::from_configured_args(args), + } + } + + fn from_configured_args( + args: ConfiguredEnvironmentManagerArgs, + ) -> Result { + let ConfiguredEnvironmentManagerArgs { + default_environment, + environments: configured_environments, + local_runtime_paths, + } = args; + let mut environments = HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::local(local_runtime_paths.clone())), + )]); + + for environment in configured_environments { + let id = environment.id.trim(); + if id.is_empty() { + return Err(ExecServerError::Protocol( + "environment id must not be empty".to_string(), + )); + } + if id == LOCAL_ENVIRONMENT_ID || id.eq_ignore_ascii_case("none") { + return Err(ExecServerError::Protocol(format!( + "environment id `{id}` is reserved" + ))); + } + if environments.contains_key(id) { + return Err(ExecServerError::Protocol(format!( + "duplicate environment id `{id}`" + ))); + } + environments.insert( + id.to_string(), + Arc::new(Environment::remote_with_runtime_paths( + environment.transport, + Some(local_runtime_paths.clone()), + )), + ); + } + + let default_environment = match default_environment.as_deref().map(str::trim) { + None | Some("") => Some(LOCAL_ENVIRONMENT_ID.to_string()), + Some(default_environment) if default_environment.eq_ignore_ascii_case("none") => None, + Some(default_environment) => { + if !environments.contains_key(default_environment) { + return Err(ExecServerError::Protocol(format!( + "default_environment `{default_environment}` is not a configured environment id" + ))); + } + Some(default_environment.to_string()) + } + }; + + Ok(Self { + default_environment, + environments, + }) + } + /// Returns the default environment instance. pub fn default_environment(&self) -> Option> { self.default_environment @@ -133,7 +248,7 @@ impl EnvironmentManager { /// paths used by filesystem helpers. #[derive(Clone)] pub struct Environment { - exec_server_url: Option, + remote_transport: Option, exec_backend: Arc, filesystem: Arc, local_runtime_paths: Option, @@ -143,7 +258,7 @@ impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { - exec_server_url: None, + remote_transport: None, exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), local_runtime_paths: None, @@ -154,7 +269,7 @@ impl Environment { impl std::fmt::Debug for Environment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Environment") - .field("exec_server_url", &self.exec_server_url) + .field("remote_transport", &self.remote_transport) .finish_non_exhaustive() } } @@ -187,7 +302,12 @@ impl Environment { } Ok(match exec_server_url { - Some(exec_server_url) => Self::remote_inner(exec_server_url, local_runtime_paths), + Some(exec_server_url) => Self::remote_with_runtime_paths( + RemoteExecServerTransport::WebSocket { + url: exec_server_url, + }, + local_runtime_paths, + ), None => match local_runtime_paths { Some(local_runtime_paths) => Self::local(local_runtime_paths), None => Self::default_for_tests(), @@ -197,7 +317,7 @@ impl Environment { fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { - exec_server_url: None, + remote_transport: None, exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -206,20 +326,16 @@ impl Environment { } } - fn remote(exec_server_url: String, local_runtime_paths: ExecServerRuntimePaths) -> Self { - Self::remote_inner(exec_server_url, Some(local_runtime_paths)) - } - - fn remote_inner( - exec_server_url: String, + fn remote_with_runtime_paths( + transport: RemoteExecServerTransport, local_runtime_paths: Option, ) -> Self { - let client = LazyRemoteExecServerClient::new(exec_server_url.clone()); + let client = LazyRemoteExecServerClient::new(transport.clone()); let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); let filesystem: Arc = Arc::new(RemoteFileSystem::new(client)); Self { - exec_server_url: Some(exec_server_url), + remote_transport: Some(transport), exec_backend, filesystem, local_runtime_paths, @@ -227,12 +343,22 @@ impl Environment { } pub fn is_remote(&self) -> bool { - self.exec_server_url.is_some() + self.remote_transport.is_some() } /// Returns the remote exec-server URL when this environment is remote. pub fn exec_server_url(&self) -> Option<&str> { - self.exec_server_url.as_deref() + match self.remote_transport.as_ref() { + Some(RemoteExecServerTransport::WebSocket { url }) => Some(url.as_str()), + Some(RemoteExecServerTransport::Command { .. }) | None => None, + } + } + + pub fn exec_server_command(&self) -> Option<&str> { + match self.remote_transport.as_ref() { + Some(RemoteExecServerTransport::Command { command }) => Some(command.as_str()), + Some(RemoteExecServerTransport::WebSocket { .. }) | None => None, + } } pub fn local_runtime_paths(&self) -> Option<&ExecServerRuntimePaths> { diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index fc6a86f50836..4fde4de4f755 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -20,12 +20,17 @@ mod server; pub use client::ExecServerClient; pub use client::ExecServerError; +pub use client_api::CommandExecServerConnectArgs; pub use client_api::ExecServerClientConnectOptions; pub use client_api::RemoteExecServerConnectArgs; +pub use client_api::RemoteExecServerTransport; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; +pub use environment::ConfiguredEnvironmentManagerArgs; +pub use environment::ConfiguredEnvironmentSpec; pub use environment::Environment; pub use environment::EnvironmentManager; pub use environment::EnvironmentManagerArgs; +pub use environment::EnvironmentManagerConfig; pub use file_system::CopyOptions; pub use file_system::CreateDirectoryOptions; pub use file_system::ExecutorFileSystem; diff --git a/codex-rs/exec-server/src/server/transport.rs b/codex-rs/exec-server/src/server/transport.rs index b8a5a086b64a..9b0da965d99f 100644 --- a/codex-rs/exec-server/src/server/transport.rs +++ b/codex-rs/exec-server/src/server/transport.rs @@ -1,5 +1,6 @@ use std::io::Write as _; use std::net::SocketAddr; +use tokio::io; use tokio::net::TcpListener; use tokio_tungstenite::accept_async; use tracing::warn; @@ -21,7 +22,7 @@ impl std::fmt::Display for ExecServerListenUrlParseError { match self { ExecServerListenUrlParseError::UnsupportedListenUrl(listen_url) => write!( f, - "unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT`" + "unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT` or `stdio://`" ), ExecServerListenUrlParseError::InvalidWebSocketListenUrl(listen_url) => write!( f, @@ -51,10 +52,28 @@ pub(crate) async fn run_transport( listen_url: &str, runtime_paths: ExecServerRuntimePaths, ) -> Result<(), Box> { + if listen_url == "stdio://" { + return run_stdio(runtime_paths).await; + } + let bind_address = parse_listen_url(listen_url)?; run_websocket_listener(bind_address, runtime_paths).await } +async fn run_stdio( + runtime_paths: ExecServerRuntimePaths, +) -> Result<(), Box> { + let processor = ConnectionProcessor::new(runtime_paths); + processor + .run_connection(JsonRpcConnection::from_stdio( + io::stdin(), + io::stdout(), + "exec-server stdio".to_string(), + )) + .await; + Ok(()) +} + async fn run_websocket_listener( bind_address: SocketAddr, runtime_paths: ExecServerRuntimePaths, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 11bf1b7a60a3..d5cd4769a4e7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -498,9 +498,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, - environment_manager: std::sync::Arc::new(EnvironmentManager::new( - EnvironmentManagerArgs::from_env(local_runtime_paths), - )), + environment_manager: std::sync::Arc::new(EnvironmentManager::try_new( + config.environment_manager_args( + EnvironmentManagerArgs::from_env(local_runtime_paths.clone()).exec_server_url, + local_runtime_paths, + )?, + )?), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 046063a554e4..7d836406af05 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -91,6 +91,8 @@ pub enum Feature { ShellZshFork, /// Include the freeform apply_patch tool. ApplyPatchFreeform, + /// Expose selected turn environments to the model and allow tools to target them by id. + MultiEnvironmentTools, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -728,6 +730,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::MultiEnvironmentTools, + key: "multi_environment_tools", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ApplyPatchStreamingEvents, key: "apply_patch_streaming_events", diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 1d904e4577a0..c027350d23e4 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -60,12 +60,10 @@ pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { - let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( - ExecServerRuntimePaths::from_optional_paths( - arg0_paths.codex_self_exe.clone(), - arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - ))); + let runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { @@ -79,6 +77,11 @@ pub async fn run_main( .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; + let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; + let environment_manager = Arc::new( + EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); set_default_client_residency_requirement(config.enforce_residency.value()); let otel = codex_core::otel_init::build_provider( diff --git a/codex-rs/tools/src/local_tool.rs b/codex-rs/tools/src/local_tool.rs index 3e369ab1e331..8148ad60ddb3 100644 --- a/codex-rs/tools/src/local_tool.rs +++ b/codex-rs/tools/src/local_tool.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; pub struct CommandToolOptions { pub allow_login_shell: bool, pub exec_permission_approvals_enabled: bool, + pub multi_environment_tools: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,6 +64,14 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { )), ); } + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to run the command in; omit to use the primary environment.".to_string(), + )), + ); + } properties.extend(create_approval_parameters( options.exec_permission_approvals_enabled, )); diff --git a/codex-rs/tools/src/local_tool_tests.rs b/codex-rs/tools/src/local_tool_tests.rs index b751545b3ace..a14cfd1d6289 100644 --- a/codex-rs/tools/src/local_tool_tests.rs +++ b/codex-rs/tools/src/local_tool_tests.rs @@ -96,6 +96,7 @@ fn exec_command_tool_matches_expected_spec() { let tool = create_exec_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }); let description = if cfg!(windows) { @@ -332,6 +333,7 @@ fn shell_command_tool_matches_expected_spec() { let tool = create_shell_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }); let description = if cfg!(windows) { diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 20c812ce9629..2ff0e064fe3a 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -97,6 +97,7 @@ pub struct ToolsConfig { pub tool_suggest: bool, pub exec_permission_approvals_enabled: bool, pub request_permissions_tool_enabled: bool, + pub multi_environment_tools: bool, pub code_mode_enabled: bool, pub code_mode_only_enabled: bool, pub js_repl_enabled: bool, @@ -161,6 +162,7 @@ impl ToolsConfig { && supports_image_generation(model_info); let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); + let multi_environment_tools = features.enabled(Feature::MultiEnvironmentTools); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -218,6 +220,7 @@ impl ToolsConfig { tool_suggest: include_tool_suggest, exec_permission_approvals_enabled, request_permissions_tool_enabled, + multi_environment_tools, code_mode_enabled: include_code_mode, code_mode_only_enabled: include_code_mode_only, js_repl_enabled: include_js_repl, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index d3b075f5abc3..3a4b25679e19 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -157,6 +157,7 @@ pub fn build_tool_registry_plan( create_exec_command_tool(CommandToolOptions { allow_login_shell: config.allow_login_shell, exec_permission_approvals_enabled, + multi_environment_tools: config.multi_environment_tools, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, @@ -175,6 +176,7 @@ pub fn build_tool_registry_plan( create_shell_command_tool(CommandToolOptions { allow_login_shell: config.allow_login_shell, exec_permission_approvals_enabled, + multi_environment_tools: false, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 0b94ef64ca58..2b81dee155fc 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -85,6 +85,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_exec_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }), create_write_stdin_tool(), create_update_plan_tool(), diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 45425c3494c5..4893f7a9fe62 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -181,6 +181,7 @@ impl AppCommand { ) -> Self { Self(Op::OverrideTurnContext { cwd, + environments: None, approval_policy, approvals_reviewer, sandbox_policy, @@ -314,6 +315,7 @@ impl AppCommand { }, Op::OverrideTurnContext { cwd, + environments: _, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index c1e142c4e149..8b19bc753801 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -629,6 +629,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context op, Op::OverrideTurnContext { cwd: None, + environments: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7e33f2e8a3f6..96cba2578565 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -729,11 +729,12 @@ pub async fn run_main( } }; + let runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( - ExecServerRuntimePaths::from_optional_paths( - arg0_paths.codex_self_exe.clone(), - arg0_paths.codex_linux_sandbox_exe.clone(), - )?, + runtime_paths.clone(), ))); let cwd = cli.cwd.clone(); let config_cwd = @@ -848,6 +849,11 @@ pub async fn run_main( cloud_requirements.clone(), ) .await; + let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; + let environment_manager = Arc::new( + EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?, + ); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { From bbad6a804bd9520b5c22cef768d2f02e8e191280 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 15:23:54 -0700 Subject: [PATCH 30/38] Fix sticky environment CI fallout Shrink app.rs below the blob policy threshold, clean up dead-code fallout, and update stale exec-server listen URL test expectations. Co-authored-by: Codex --- codex-rs/core/src/apply_patch.rs | 3 +- .../core/src/context/environment_context.rs | 42 +++++- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 8 ++ .../session/rollout_reconstruction_tests.rs | 8 ++ codex-rs/core/src/session/tests.rs | 88 ++++++++++++ codex-rs/core/src/session/turn_context.rs | 30 +++- .../core/src/tools/handlers/apply_patch.rs | 131 +++++++++++++++--- .../src/tools/handlers/apply_patch_tests.rs | 25 ++++ .../core/src/tools/handlers/unified_exec.rs | 8 +- .../core/src/tools/handlers/view_image.rs | 37 +++-- .../core/src/tools/runtimes/apply_patch.rs | 39 ++++-- .../src/tools/runtimes/apply_patch_tests.rs | 17 +++ codex-rs/core/tests/suite/resume_warning.rs | 1 + .../exec-server/src/server/transport_tests.rs | 2 +- .../exec-server/tests/command_transport.rs | 50 +++++++ codex-rs/protocol/src/protocol.rs | 51 ++++++- codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 3 + codex-rs/tools/BUILD.bazel | 1 + codex-rs/tools/src/apply_patch_tool.rs | 37 ++++- codex-rs/tools/src/apply_patch_tool_tests.rs | 36 ++++- codex-rs/tools/src/lib.rs | 1 + .../src/tool_apply_patch_environment.lark | 21 +++ codex-rs/tools/src/tool_config.rs | 5 + codex-rs/tools/src/tool_registry_plan.rs | 10 +- .../tools/src/tool_registry_plan_tests.rs | 36 ++++- codex-rs/tools/src/view_image.rs | 9 ++ codex-rs/tui/src/lib.rs | 1 + 29 files changed, 631 insertions(+), 71 deletions(-) create mode 100644 codex-rs/exec-server/tests/command_transport.rs create mode 100644 codex-rs/tools/src/tool_apply_patch_environment.lark diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index c05a459049bf..0788ae216c3b 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -32,6 +32,7 @@ pub(crate) struct ApplyPatchRuntimeInvocation { pub(crate) async fn apply_patch( turn_context: &TurnContext, + cwd: &codex_utils_absolute_path::AbsolutePathBuf, file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { @@ -40,7 +41,7 @@ pub(crate) async fn apply_patch( turn_context.approval_policy.value(), turn_context.sandbox_policy.get(), file_system_sandbox_policy, - &turn_context.cwd, + cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index d3105a03f2bd..046f1725377b 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,6 +1,5 @@ use crate::session::turn_context::TurnContext; use crate::shell::Shell; -use codex_features::Feature; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use std::path::PathBuf; @@ -41,6 +40,7 @@ impl NetworkContext { } impl EnvironmentContext { + #[cfg(test)] pub(crate) fn new( cwd: Option, shell: String, @@ -117,7 +117,7 @@ impl EnvironmentContext { }; EnvironmentContext::new_with_environments( cwd, - after.environments.clone(), + Self::environments_for_diff(before, after), after.shell.clone(), after.current_date.clone(), after.timezone.clone(), @@ -142,8 +142,9 @@ impl EnvironmentContext { turn_context_item: &TurnContextItem, shell: String, ) -> Self { - Self::new( + Self::new_with_environments( Some(turn_context_item.cwd.clone()), + Self::environments_from_turn_context_item(turn_context_item), shell, turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), @@ -197,7 +198,7 @@ impl EnvironmentContext { fn environments_from_turn_context( turn_context: &TurnContext, ) -> Option> { - if !turn_context.features.enabled(Feature::MultiEnvironmentTools) { + if !turn_context.tools_config.multi_environment_tools { return None; } let environments = turn_context.environments.as_ref()?; @@ -217,6 +218,39 @@ impl EnvironmentContext { .collect(), ) } + + fn environments_from_turn_context_item( + turn_context_item: &TurnContextItem, + ) -> Option> { + let environments = turn_context_item.environments.as_ref()?; + if environments.is_empty() { + return None; + } + + Some( + environments + .iter() + .enumerate() + .map(|(index, environment)| EnvironmentContextEnvironment { + id: environment.environment_id.clone(), + cwd: environment.cwd.to_path_buf(), + primary: index == 0, + }) + .collect(), + ) + } + + fn environments_for_diff( + before: &TurnContextItem, + after: &EnvironmentContext, + ) -> Option> { + let before_environments = Self::environments_from_turn_context_item(before); + if before_environments == after.environments { + None + } else { + after.environments.clone() + } + } } impl ContextualUserFragment for EnvironmentContext { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8bcd1..af7f620c28e2 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -129,6 +129,7 @@ fn reference_context_item() -> TurnContextItem { turn_id: Some("reference-turn".to_string()), trace_id: None, cwd: PathBuf::from("/tmp/reference-cwd"), + environments: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index da8daf1f2141..ac75741fffb5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1257,11 +1257,19 @@ impl Session { .reconstruct_history_from_rollout(turn_context, rollout_items) .await; let previous_turn_settings = reconstructed_rollout.previous_turn_settings.clone(); + let environment_selections = reconstructed_rollout + .reference_context_item + .as_ref() + .and_then(|context_item| context_item.environments.clone()); self.replace_history( reconstructed_rollout.history, reconstructed_rollout.reference_context_item, ) .await; + if let Some(environment_selections) = environment_selections { + let mut state = self.state.lock().await; + state.session_configuration.environment_selections = Some(environment_selections); + } self.set_previous_turn_settings(previous_turn_settings.clone()) .await; previous_turn_settings diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 3dca7f0ce6d6..86a8f65bd965 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -64,6 +64,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -104,6 +105,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -899,6 +901,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -975,6 +978,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1005,6 +1009,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1118,6 +1123,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1229,6 +1235,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1379,6 +1386,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d921d807b8a2..72c2ef82d8bc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1733,10 +1733,15 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; let previous_model = "forked-rollout-model"; + let previous_environments = vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test_path_buf("/tmp/forked-local").abs(), + }]; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: Some(previous_environments.clone()), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1798,6 +1803,15 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { realtime_active: Some(turn_context.realtime_active), }) ); + assert_eq!( + session + .state + .lock() + .await + .session_configuration + .environment_selections, + Some(previous_environments) + ); assert_eq!(history.raw_items(), &[]); assert_eq!( serde_json::to_value(session.reference_context_item().await) @@ -3974,6 +3988,14 @@ async fn turn_environment_selection_sets_primary_environment() { .resolve_path_for_environment(&primary_environment, Some("relative/path".to_string())), selected_cwd.join("relative/path") ); + + assert_eq!( + turn_context.to_turn_context_item().environments, + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd, + }]) + ); } #[tokio::test] @@ -4028,6 +4050,20 @@ async fn multiple_turn_environment_selections_use_first_as_primary_environment() .environment_for_tool(Some("local")) .expect("tool environment should resolve by id"); assert_eq!(tool_environment.cwd, first_cwd); + + assert_eq!( + turn_context.to_turn_context_item().environments, + Some(vec![ + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: first_cwd, + }, + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: second_cwd, + }, + ]) + ); } #[tokio::test] @@ -4096,6 +4132,58 @@ async fn primary_environment_falls_back_to_compatibility_projection() { ); } +#[tokio::test] +async fn multi_environment_feature_without_selections_uses_single_environment_behavior() { + let (_session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| config.features.enable(Feature::MultiEnvironmentTools), + ) + .await; + + assert!( + turn_context + .features + .enabled(Feature::MultiEnvironmentTools) + ); + assert!(turn_context.environments.is_none()); + assert!(!turn_context.tools_config.multi_environment_tools); + assert_eq!(turn_context.to_turn_context_item().environments, None); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve from compatibility fields"); + assert_eq!(primary_environment.environment_id, None); + assert_eq!(primary_environment.cwd, turn_context.cwd); +} + +#[tokio::test] +async fn multi_environment_tools_enable_only_with_feature_and_selection() { + let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| config.features.enable(Feature::MultiEnvironmentTools), + ) + .await; + let selected_cwd = + AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) + .expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd, + }]), + ) + .await + .expect("turn should start"); + + assert!(turn_context.tools_config.multi_environment_tools); +} + #[tokio::test] async fn unknown_turn_environment_selection_returns_error() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 243f5b932795..6f15d9ba1fae 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -10,6 +10,14 @@ pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManag ) } +fn multi_environment_tools_enabled( + features: &codex_features::Features, + environments: Option<&[TurnEnvironment]>, +) -> bool { + features.enabled(codex_features::Feature::MultiEnvironmentTools) + && environments.is_some_and(|environments| !environments.is_empty()) +} + #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, @@ -155,6 +163,8 @@ impl TurnContext { /*developer_instructions*/ None, ); let features = self.features.clone(); + let multi_environment_tools = + multi_environment_tools_enabled(&features, self.environments.as_deref()); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &models_manager @@ -173,6 +183,7 @@ impl TurnContext { .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) .with_has_environment(self.tools_config.has_environment) + .with_multi_environment_tools(multi_environment_tools) .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) @@ -279,13 +290,6 @@ impl TurnContext { ) } - pub(crate) fn file_system_sandbox_context( - &self, - additional_permissions: Option, - ) -> FileSystemSandboxContext { - self.file_system_sandbox_context_for_cwd(&self.cwd, additional_permissions) - } - pub(crate) fn file_system_sandbox_context_for_cwd( &self, cwd: &AbsolutePathBuf, @@ -330,6 +334,15 @@ impl TurnContext { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), cwd: self.cwd.to_path_buf(), + environments: self.environments.as_ref().map(|environments| { + environments + .iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.clone(), + }) + .collect() + }), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), @@ -449,6 +462,8 @@ impl Session { let session_source = session_configuration.session_source.clone(); let image_generation_tool_auth_allowed = image_generation_tool_auth_allowed(auth_manager.as_deref()); + let multi_environment_tools = + multi_environment_tools_enabled(&per_turn_config.features, environments.as_deref()); let auth_manager_for_context = auth_manager.clone(); let provider_for_context = create_model_provider(provider, auth_manager); let session_telemetry_for_context = session_telemetry; @@ -470,6 +485,7 @@ impl Session { .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_has_environment(environment.is_some()) + .with_multi_environment_tools(multi_environment_tools) .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 033315a69489..c6c32e00bf10 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -47,6 +47,7 @@ use codex_tools::ApplyPatchToolArgs; use codex_utils_absolute_path::AbsolutePathBuf; const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); +const APPLY_PATCH_ENVIRONMENT_PREFIX: &str = "*** Environment: "; pub struct ApplyPatchHandler; @@ -239,9 +240,48 @@ fn write_permissions_for_paths( normalize_additional_permissions(permissions).ok() } +fn split_freeform_apply_patch_environment( + input: String, +) -> Result<(String, Option), FunctionCallError> { + let Some(rest) = input.strip_prefix(APPLY_PATCH_ENVIRONMENT_PREFIX) else { + return Ok((input, None)); + }; + let Some((environment_id, patch_input)) = rest.split_once('\n') else { + return Err(FunctionCallError::RespondToModel( + "apply_patch environment header must be followed by a patch body".to_string(), + )); + }; + let environment_id = environment_id.trim(); + if environment_id.is_empty() { + return Err(FunctionCallError::RespondToModel( + "apply_patch environment header must include an environment id".to_string(), + )); + } + Ok((patch_input.to_string(), Some(environment_id.to_string()))) +} + +fn merge_apply_patch_environment_ids( + argument_environment_id: Option, + header_environment_id: Option, +) -> Result, FunctionCallError> { + match (argument_environment_id, header_environment_id) { + (Some(argument_environment_id), Some(header_environment_id)) + if argument_environment_id != header_environment_id => + { + Err(FunctionCallError::RespondToModel(format!( + "apply_patch environment id mismatch: argument requested `{argument_environment_id}` but patch header requested `{header_environment_id}`" + ))) + } + (Some(environment_id), Some(_)) | (Some(environment_id), None) => Ok(Some(environment_id)), + (None, Some(environment_id)) => Ok(Some(environment_id)), + (None, None) => Ok(None), + } +} + async fn effective_patch_permissions( session: &Session, turn: &TurnContext, + cwd: &AbsolutePathBuf, action: &ApplyPatchAction, ) -> ( Vec, @@ -261,7 +301,7 @@ async fn effective_patch_permissions( session, turn.cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, &turn.cwd), + write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), ) .await; @@ -305,12 +345,29 @@ impl ToolHandler for ApplyPatchHandler { .. } = invocation; - let patch_input = match payload { + let multi_environment_tools = turn.tools_config.multi_environment_tools; + let (patch_input, requested_environment_id) = match payload { ToolPayload::Function { arguments } => { let args: ApplyPatchToolArgs = parse_arguments(&arguments)?; - args.input + if multi_environment_tools { + let (patch_input, header_environment_id) = + split_freeform_apply_patch_environment(args.input)?; + let environment_id = merge_apply_patch_environment_ids( + args.environment_id, + header_environment_id, + )?; + (patch_input, environment_id) + } else { + (args.input, None) + } + } + ToolPayload::Custom { input } => { + if multi_environment_tools { + split_freeform_apply_patch_environment(input)? + } else { + (input, None) + } } - ToolPayload::Custom { input } => input, _ => { return Err(FunctionCallError::RespondToModel( "apply_patch handler received unsupported payload".to_string(), @@ -320,17 +377,22 @@ impl ToolHandler for ApplyPatchHandler { // Re-parse and verify the patch so we can compute changes and approval. // Avoid building temporary ExecParams/command vectors; derive directly from inputs. - let cwd = turn.cwd.clone(); + let tool_environment = turn + .environment_for_tool(requested_environment_id.as_deref()) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id.as_deref() { + Some(environment_id) => { + format!("environment `{environment_id}` is unavailable in this session") + } + None => "apply_patch is unavailable in this session".to_string(), + }) + })?; + let cwd = tool_environment.cwd.clone(); let command = vec!["apply_patch".to_string(), patch_input.clone()]; - let Some(environment) = turn.environment.as_ref() else { - return Err(FunctionCallError::RespondToModel( - "apply_patch is unavailable in this session".to_string(), - )); - }; - let fs = environment.get_filesystem(); - let sandbox = environment - .is_remote() - .then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + let fs = tool_environment.environment.get_filesystem(); + let sandbox = tool_environment.environment.is_remote().then(|| { + turn.file_system_sandbox_context_for_cwd(&cwd, /*additional_permissions*/ None) + }); match codex_apply_patch::maybe_parse_apply_patch_verified( &command, &cwd, @@ -341,9 +403,15 @@ impl ToolHandler for ApplyPatchHandler { { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; - match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) - .await + effective_patch_permissions(session.as_ref(), turn.as_ref(), &cwd, &changes) + .await; + match apply_patch::apply_patch( + turn.as_ref(), + &cwd, + &file_system_sandbox_policy, + changes, + ) + .await { InternalApplyPatchInvocation::Output(item) => { let content = item?; @@ -362,6 +430,9 @@ impl ToolHandler for ApplyPatchHandler { emitter.begin(event_ctx).await; let req = ApplyPatchRequest { + environment_id: tool_environment.environment_id.clone(), + environment: tool_environment.environment, + cwd, action: apply.action, file_paths, changes, @@ -432,11 +503,13 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn - .environment + let tool_environment = turn.environment_for_tool(/*environment_id*/ None); + let sandbox = tool_environment .as_ref() - .filter(|env| env.is_remote()) - .map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + .filter(|environment| environment.environment.is_remote()) + .map(|_| { + turn.file_system_sandbox_context_for_cwd(cwd, /*additional_permissions*/ None) + }); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref()) .await { @@ -450,8 +523,8 @@ pub(crate) async fn intercept_apply_patch( ) .await; let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; - match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) + effective_patch_permissions(session.as_ref(), turn.as_ref(), cwd, &changes).await; + match apply_patch::apply_patch(turn.as_ref(), cwd, &file_system_sandbox_policy, changes) .await { InternalApplyPatchInvocation::Output(item) => { @@ -470,6 +543,18 @@ pub(crate) async fn intercept_apply_patch( emitter.begin(event_ctx).await; let req = ApplyPatchRequest { + environment_id: tool_environment + .as_ref() + .and_then(|environment| environment.environment_id.clone()), + environment: tool_environment + .as_ref() + .map(|environment| Arc::clone(&environment.environment)) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "apply_patch is unavailable in this session".to_string(), + ) + })?, + cwd: cwd.clone(), action: apply.action, file_paths: approval_keys, changes, diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 39c8029e67d4..d159c0b7618d 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -108,6 +108,31 @@ fn diff_consumer_sends_next_update_after_buffer_interval() { ); } +#[test] +fn split_freeform_apply_patch_environment_extracts_environment_header() { + let input = + "*** Environment: local\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n" + .to_string(); + + let (patch, environment_id) = + split_freeform_apply_patch_environment(input).expect("header should parse"); + + assert_eq!(environment_id.as_deref(), Some("local")); + assert_eq!( + patch, + "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n" + ); +} + +#[test] +fn merge_apply_patch_environment_ids_rejects_mismatch() { + let err = + merge_apply_patch_environment_ids(Some("local".to_string()), Some("remote".to_string())) + .expect_err("mismatched environment ids should fail"); + + assert!(err.to_string().contains("environment id mismatch")); +} + #[tokio::test] async fn approval_keys_include_move_destination() { let tmp = TempDir::new().expect("tmp"); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 33f9bc6bf3e7..728f183ab07c 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -199,10 +199,7 @@ impl ToolHandler for UnifiedExecHandler { let response = match tool_name.name.as_str() { "exec_command" => { let selector: ToolEnvironmentArgs = parse_arguments(&arguments)?; - let requested_environment_id = if context - .turn - .features - .enabled(Feature::MultiEnvironmentTools) + let requested_environment_id = if context.turn.tools_config.multi_environment_tools { selector.environment_id.as_deref() } else { @@ -243,8 +240,7 @@ impl ToolHandler for UnifiedExecHandler { turn.tools_config.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; - let command_for_display = - codex_shell_command::parse_command::shlex_join(&command); + let command_for_display = codex_shell_command::parse_command::shlex_join(&command); let ExecCommandArgs { workdir, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 8f3f69701f9c..b0c8355e9983 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -29,6 +29,8 @@ const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str = struct ViewImageArgs { path: String, detail: Option, + #[serde(default, alias = "environmentId")] + environment_id: Option, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -87,17 +89,31 @@ impl ToolHandler for ViewImageHandler { } }; - let abs_path = turn.resolve_path(Some(args.path)); - let Some(environment) = turn.environment.as_ref() else { - return Err(FunctionCallError::RespondToModel( - "view_image is unavailable in this session".to_string(), - )); + let requested_environment_id = if turn.tools_config.multi_environment_tools { + args.environment_id.as_deref() + } else { + None }; - let sandbox = environment - .is_remote() - .then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + let tool_environment = turn + .environment_for_tool(requested_environment_id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id { + Some(environment_id) => { + format!("environment `{environment_id}` is unavailable in this session") + } + None => "view_image is unavailable in this session".to_string(), + }) + })?; + let abs_path = turn.resolve_path_for_environment(&tool_environment, Some(args.path)); + let sandbox = tool_environment.environment.is_remote().then(|| { + turn.file_system_sandbox_context_for_cwd( + &tool_environment.cwd, + /*additional_permissions*/ None, + ) + }); - let metadata = environment + let metadata = tool_environment + .environment .get_filesystem() .get_metadata(&abs_path, sandbox.as_ref()) .await @@ -114,7 +130,8 @@ impl ToolHandler for ViewImageHandler { abs_path.display() ))); } - let file_bytes = environment + let file_bytes = tool_environment + .environment .get_filesystem() .read_file(&abs_path, sandbox.as_ref()) .await diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index aac8e6bc1340..f4de1cda44ef 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -16,6 +16,7 @@ use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::with_cached_approval; use codex_apply_patch::ApplyPatchAction; +use codex_exec_server::Environment; use codex_exec_server::FileSystemSandboxContext; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -35,10 +36,14 @@ use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::path::PathBuf; +use std::sync::Arc; use std::time::Instant; #[derive(Debug)] pub struct ApplyPatchRequest { + pub environment_id: Option, + pub environment: Arc, + pub cwd: AbsolutePathBuf, pub action: ApplyPatchAction, pub file_paths: Vec, pub changes: std::collections::HashMap, @@ -47,6 +52,12 @@ pub struct ApplyPatchRequest { pub permissions_preapproved: bool, } +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)] +pub struct ApplyPatchApprovalKey { + pub environment_id: Option, + pub path: AbsolutePathBuf, +} + #[derive(Default)] pub struct ApplyPatchRuntime; @@ -61,7 +72,7 @@ impl ApplyPatchRuntime { ) -> GuardianApprovalRequest { GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), - cwd: req.action.cwd.clone(), + cwd: req.cwd.clone(), files: req.file_paths.clone(), patch: req.action.patch.clone(), } @@ -121,10 +132,17 @@ impl Sandboxable for ApplyPatchRuntime { } impl Approvable for ApplyPatchRuntime { - type ApprovalKey = AbsolutePathBuf; + type ApprovalKey = ApplyPatchApprovalKey; fn approval_keys(&self, req: &ApplyPatchRequest) -> Vec { - req.file_paths.clone() + req.file_paths + .iter() + .cloned() + .map(|path| ApplyPatchApprovalKey { + environment_id: req.environment_id.clone(), + path, + }) + .collect() } fn start_approval_async<'a>( @@ -201,23 +219,28 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { + fn sandbox_cwd<'a>( + &self, + req: &'a ApplyPatchRequest, + _turn_ctx: &'a crate::session::turn_context::TurnContext, + ) -> &'a AbsolutePathBuf { + &req.cwd + } + async fn run( &mut self, req: &ApplyPatchRequest, attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, ) -> Result { - let environment = ctx.turn.environment.as_ref().ok_or_else(|| { - ToolError::Rejected("apply_patch is unavailable in this session".to_string()) - })?; let started_at = Instant::now(); - let fs = environment.get_filesystem(); + let fs = req.environment.get_filesystem(); let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let result = codex_apply_patch::apply_patch( &req.action.patch, - &req.action.cwd, + &req.cwd, &mut stdout, &mut stderr, fs.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 7f3641c03c51..c8e77a0a2829 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -15,6 +15,11 @@ use codex_sandboxing::SandboxType; use core_test_support::PathBufExt; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::sync::Arc; + +fn local_environment() -> Arc { + Arc::new(Environment::create(/*exec_server_url*/ None).expect("local environment")) +} #[test] fn wants_no_sandbox_approval_granular_respects_sandbox_flag() { @@ -49,6 +54,9 @@ fn guardian_review_request_includes_patch_context() { let expected_cwd = action.cwd.clone(); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: expected_cwd.clone(), action, file_paths: vec![path.clone()], changes: HashMap::from([( @@ -91,6 +99,9 @@ fn file_system_sandbox_context_uses_active_attempt() { )), }; let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), @@ -147,6 +158,9 @@ fn file_system_sandbox_context_omits_legacy_equivalent_policy() { .join("apply-patch-runtime-legacy-equivalent.txt") .abs(); let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), @@ -188,6 +202,9 @@ fn no_sandbox_attempt_has_no_file_system_context() { .join("apply-patch-runtime-none.txt") .abs(); let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 8ea2be3147a3..2368b7c5e3e7 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -29,6 +29,7 @@ fn resume_history( turn_id: Some(turn_id.clone()), trace_id: None, cwd: config.cwd.to_path_buf(), + environments: None, current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/exec-server/src/server/transport_tests.rs b/codex-rs/exec-server/src/server/transport_tests.rs index bec91c936ee8..dc30421dacdc 100644 --- a/codex-rs/exec-server/src/server/transport_tests.rs +++ b/codex-rs/exec-server/src/server/transport_tests.rs @@ -45,6 +45,6 @@ fn parse_listen_url_rejects_unsupported_url() { parse_listen_url("http://127.0.0.1:1234").expect_err("unsupported scheme should fail"); assert_eq!( err.to_string(), - "unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT`" + "unsupported --listen URL `http://127.0.0.1:1234`; expected `ws://IP:PORT` or `stdio://`" ); } diff --git a/codex-rs/exec-server/tests/command_transport.rs b/codex-rs/exec-server/tests/command_transport.rs new file mode 100644 index 000000000000..35eb73ca84c5 --- /dev/null +++ b/codex-rs/exec-server/tests/command_transport.rs @@ -0,0 +1,50 @@ +mod common; + +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::RemoteExecServerTransport; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn configured_command_environment_connects_lazily_over_stdio() -> anyhow::Result<()> { + let helper_paths = common::exec_server::test_codex_helper_paths()?; + let temp_dir = tempfile::tempdir()?; + let target_path = temp_dir.path().join("target.txt"); + let marker_path = temp_dir.path().join("spawned.txt"); + tokio::fs::write(&target_path, "ok").await?; + + let manager = EnvironmentManager::try_new(ConfiguredEnvironmentManagerArgs { + default_environment: Some("remote".to_string()), + environments: vec![ConfiguredEnvironmentSpec { + id: "remote".to_string(), + transport: RemoteExecServerTransport::Command { + command: format!( + "echo spawned > {marker_path:?}; exec {codex_exe:?} exec-server --listen stdio://", + marker_path = marker_path, + codex_exe = helper_paths.codex_exe, + ), + }, + }], + local_runtime_paths: None, + })?; + let environment = manager.default_environment().expect("default environment"); + + assert!( + tokio::fs::metadata(&marker_path).await.is_err(), + "command transport should not connect before the first remote operation" + ); + + let metadata = environment + .get_filesystem() + .get_metadata( + &AbsolutePathBuf::from_absolute_path(&target_path)?, + /*sandbox*/ None, + ) + .await?; + + assert_eq!(metadata.is_file, true); + assert_eq!(tokio::fs::read_to_string(&marker_path).await?, "spawned\n"); + Ok(()) +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6fe8e37e38df..37504a8781e9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,7 +103,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: AbsolutePathBuf, @@ -2932,6 +2932,8 @@ pub struct TurnContextItem { pub trace_id: Option, pub cwd: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] + pub environments: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, @@ -5082,17 +5084,64 @@ mod tests { }))?; assert_eq!(item.trace_id, None); + assert_eq!(item.environments, None); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); Ok(()) } + #[test] + fn turn_context_item_serializes_environments_when_present() -> Result<()> { + let item = TurnContextItem { + turn_id: None, + trace_id: None, + cwd: test_path_buf("/tmp/primary"), + environments: Some(vec![ + TurnEnvironmentSelection { + environment_id: "remote".to_string(), + cwd: test_path_buf("/tmp/primary").abs(), + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test_path_buf("/tmp/local").abs(), + }, + ]), + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: ReasoningSummaryConfig::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }; + + let value = serde_json::to_value(item)?; + assert_eq!( + value["environments"], + json!([ + { "environment_id": "remote", "cwd": "/tmp/primary" }, + { "environment_id": "local", "cwd": "/tmp/local" }, + ]) + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, trace_id: None, cwd: test_path_buf("/tmp"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index fa789d3a7e90..01cda8f20abb 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -543,6 +543,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re turn_id: Some("turn-1".to_string()), trace_id: None, cwd: latest_cwd.clone(), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 64d7f5bbb71a..586101d89766 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -299,6 +299,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/parent/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, @@ -338,6 +339,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, @@ -371,6 +373,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/tools/BUILD.bazel b/codex-rs/tools/BUILD.bazel index 7b1541e4e84b..d37bbcfde087 100644 --- a/codex-rs/tools/BUILD.bazel +++ b/codex-rs/tools/BUILD.bazel @@ -5,5 +5,6 @@ codex_rust_crate( crate_name = "codex_tools", compile_data = [ "src/tool_apply_patch.lark", + "src/tool_apply_patch_environment.lark", ], ) diff --git a/codex-rs/tools/src/apply_patch_tool.rs b/codex-rs/tools/src/apply_patch_tool.rs index 469bb5236769..3e5bc12a3c30 100644 --- a/codex-rs/tools/src/apply_patch_tool.rs +++ b/codex-rs/tools/src/apply_patch_tool.rs @@ -8,6 +8,13 @@ use serde::Serialize; use std::collections::BTreeMap; const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark"); +const APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR: &str = + include_str!("tool_apply_patch_environment.lark"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApplyPatchToolOptions { + pub multi_environment_tools: bool, +} const APPLY_PATCH_JSON_TOOL_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: @@ -82,30 +89,50 @@ It is important to remember: #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApplyPatchToolArgs { pub input: String, + #[serde(default, alias = "environmentId")] + pub environment_id: Option, } /// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models /// https://platform.openai.com/docs/guides/function-calling#custom-tools -pub fn create_apply_patch_freeform_tool() -> ToolSpec { +pub fn create_apply_patch_freeform_tool(options: ApplyPatchToolOptions) -> ToolSpec { + let description = if options.multi_environment_tools { + "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. To target a non-primary selected environment, add a first line `*** Environment: ` before `*** Begin Patch`." + } else { + "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON." + }; + let definition = if options.multi_environment_tools { + APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR + } else { + APPLY_PATCH_LARK_GRAMMAR + }; ToolSpec::Freeform(FreeformTool { name: "apply_patch".to_string(), - description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(), + description: description.to_string(), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), - definition: APPLY_PATCH_LARK_GRAMMAR.to_string(), + definition: definition.to_string(), }, }) } /// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models -pub fn create_apply_patch_json_tool() -> ToolSpec { - let properties = BTreeMap::from([( +pub fn create_apply_patch_json_tool(options: ApplyPatchToolOptions) -> ToolSpec { + let mut properties = BTreeMap::from([( "input".to_string(), JsonSchema::string(Some( "The entire contents of the apply_patch command".to_string(), )), )]); + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to apply the patch in; omit to use the primary environment.".to_string(), + )), + ); + } ToolSpec::Function(ResponsesApiTool { name: "apply_patch".to_string(), diff --git a/codex-rs/tools/src/apply_patch_tool_tests.rs b/codex-rs/tools/src/apply_patch_tool_tests.rs index c128594587a3..9c623b9c23ce 100644 --- a/codex-rs/tools/src/apply_patch_tool_tests.rs +++ b/codex-rs/tools/src/apply_patch_tool_tests.rs @@ -6,7 +6,9 @@ use std::collections::BTreeMap; #[test] fn create_apply_patch_freeform_tool_matches_expected_spec() { assert_eq!( - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::Freeform(FreeformTool { name: "apply_patch".to_string(), description: @@ -24,7 +26,9 @@ fn create_apply_patch_freeform_tool_matches_expected_spec() { #[test] fn create_apply_patch_json_tool_matches_expected_spec() { assert_eq!( - create_apply_patch_json_tool(), + create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::Function(ResponsesApiTool { name: "apply_patch".to_string(), description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(), @@ -44,3 +48,31 @@ fn create_apply_patch_json_tool_matches_expected_spec() { }) ); } + +#[test] +fn create_apply_patch_freeform_tool_with_environment_matches_expected_spec() { + let ToolSpec::Freeform(tool) = create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: true, + }) else { + panic!("apply_patch should be a freeform tool"); + }; + assert!( + tool.description + .contains("*** Environment: ") + ); + assert_eq!(tool.format.definition, APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR); +} + +#[test] +fn create_apply_patch_json_tool_with_environment_includes_environment_id() { + let ToolSpec::Function(tool) = create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: true, + }) else { + panic!("apply_patch should be a function tool"); + }; + let properties = tool + .parameters + .properties + .expect("apply_patch parameters should include properties"); + assert!(properties.contains_key("environment_id")); +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2dc7c165d52c..83789a8092a2 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -41,6 +41,7 @@ pub use agent_tool::create_spawn_agent_tool_v2; pub use agent_tool::create_wait_agent_tool_v1; pub use agent_tool::create_wait_agent_tool_v2; pub use apply_patch_tool::ApplyPatchToolArgs; +pub use apply_patch_tool::ApplyPatchToolOptions; pub use apply_patch_tool::create_apply_patch_freeform_tool; pub use apply_patch_tool::create_apply_patch_json_tool; pub use code_mode::augment_tool_spec_for_code_mode; diff --git a/codex-rs/tools/src/tool_apply_patch_environment.lark b/codex-rs/tools/src/tool_apply_patch_environment.lark new file mode 100644 index 000000000000..709e6b6ce9e9 --- /dev/null +++ b/codex-rs/tools/src/tool_apply_patch_environment.lark @@ -0,0 +1,21 @@ +start: environment? begin_patch hunk+ end_patch +environment: "*** Environment: " environment_id LF +environment_id: /[A-Za-z0-9_.:-]+/ +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? + +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change_move? change? + +filename: /(.+)/ +add_line: "+" /(.*)/ LF -> line + +change_move: "*** Move to: " filename LF +change: (change_context | change_line)+ eof_line? +change_context: ("@@" | "@@ " /(.+)/) LF +change_line: ("+" | "-" | " ") /(.*)/ LF +eof_line: "*** End of File" LF + +%import common.LF diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 2ff0e064fe3a..28ca10e34f9d 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -272,6 +272,11 @@ impl ToolsConfig { self } + pub fn with_multi_environment_tools(mut self, multi_environment_tools: bool) -> Self { + self.multi_environment_tools = multi_environment_tools; + self + } + pub fn with_unified_exec_shell_mode( mut self, unified_exec_shell_mode: UnifiedExecShellMode, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 3a4b25679e19..5ffa91896aa4 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -1,3 +1,4 @@ +use crate::ApplyPatchToolOptions; use crate::CommandToolOptions; use crate::REQUEST_USER_INPUT_TOOL_NAME; use crate::ResponsesApiNamespace; @@ -317,14 +318,18 @@ pub fn build_tool_registry_plan( match apply_patch_tool_type { ApplyPatchToolType::Freeform => { plan.push_spec( - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: config.multi_environment_tools, + }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); } ApplyPatchToolType::Function => { plan.push_spec( - create_apply_patch_json_tool(), + create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: config.multi_environment_tools, + }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -384,6 +389,7 @@ pub fn build_tool_registry_plan( plan.push_spec( create_view_image_tool(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, + multi_environment_tools: config.multi_environment_tools, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 2b81dee155fc..c2b1f48045fe 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -90,7 +90,9 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_write_stdin_tool(), create_update_plan_tool(), request_user_input_tool_spec(/*default_mode_request_user_input*/ false), - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::WebSearch { external_web_access: Some(true), filters: None, @@ -101,6 +103,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_image_generation_tool("png"), create_view_image_tool(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, + multi_environment_tools: false, }), ] { expected.insert(spec.name().to_string(), spec); @@ -433,6 +436,37 @@ fn view_image_tool_includes_detail_with_original_detail_support() { assert!(description.contains("omit this field for default resized behavior")); } +#[test] +fn view_image_tool_includes_environment_id_when_multi_environment_tools_enabled() { + let mut model_info = model_info(); + model_info.supports_image_detail_original = false; + let mut features = Features::with_defaults(); + features.enable(Feature::MultiEnvironmentTools); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { + panic!("view_image should be a function tool"); + }; + let (properties, _) = expect_object_schema(parameters); + assert!(properties.contains_key("environment_id")); +} + #[test] fn disabled_environment_omits_environment_backed_tools() { let model_info = model_info(); diff --git a/codex-rs/tools/src/view_image.rs b/codex-rs/tools/src/view_image.rs index 1d77ceadf3c9..017e09f73290 100644 --- a/codex-rs/tools/src/view_image.rs +++ b/codex-rs/tools/src/view_image.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ViewImageToolOptions { pub can_request_original_image_detail: bool, + pub multi_environment_tools: bool, } pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { @@ -24,6 +25,14 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { )), ); } + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to load the image from; omit to use the primary environment.".to_string(), + )), + ); + } ToolSpec::Function(ResponsesApiTool { name: VIEW_IMAGE_TOOL_NAME.to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 96cba2578565..37527bebc8f4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2190,6 +2190,7 @@ mod tests { turn_id: None, trace_id: None, cwd, + environments: None, current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), From 68202b32aab96da8ad2534a285405ff5c8b16274 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 15:38:53 -0700 Subject: [PATCH 31/38] Fix sticky environment lint failures Group thread-start options for clippy and make the new environment tests comply with stricter CI lint settings. Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 13 ++--- .../app-server/tests/suite/v2/turn_start.rs | 16 +++--- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/tests.rs | 14 ++++- codex-rs/core/src/thread_manager.rs | 52 +++++++++++-------- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8ff4de3e76d8..b99725423c20 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -218,6 +218,7 @@ use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; +use codex_core::StartThreadWithToolsOptions; use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; @@ -2613,20 +2614,20 @@ impl CodexMessageProcessor { match listener_task_context .thread_manager - .start_thread_with_tools_and_service_name( + .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { config, - match session_start_source + initial_history: match session_start_source .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) { codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New, codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, }, - core_dynamic_tools, + dynamic_tools: core_dynamic_tools, persist_extended_history, - service_name, - request_trace, + metrics_service_name: service_name, + parent_trace: request_trace, environment_selections, - ) + }) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", otel.name = "app_server.thread_start.create_thread", diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index c86227b92009..5235dc75cc36 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1969,8 +1969,11 @@ async fn run_environment_selection_case( mcp.read_stream_until_notification_message("turn/started"), ) .await??; - let started: TurnStartedNotification = - serde_json::from_value(started_notification.params.expect("turn/started params"))?; + let started: TurnStartedNotification = serde_json::from_value( + started_notification + .params + .ok_or_else(|| anyhow::anyhow!("turn/started notification should include params"))?, + )?; assert_eq!(started.turn.id, turn.id, "{}", case.name); let completed_notification = timeout( @@ -1978,11 +1981,10 @@ async fn run_environment_selection_case( mcp.read_stream_until_notification_message("turn/completed"), ) .await??; - let completed: TurnCompletedNotification = serde_json::from_value( - completed_notification - .params - .expect("turn/completed params"), - )?; + let completed: TurnCompletedNotification = + serde_json::from_value(completed_notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/completed notification should include params") + })?)?; assert_eq!(completed.turn.id, turn.id, "{}", case.name); assert_eq!( completed.turn.status, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cf61c5faf93f..4e87120d9568 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -118,6 +118,7 @@ pub(crate) mod web_search; pub(crate) mod windows_sandbox_read_grants; pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; +pub use thread_manager::StartThreadWithToolsOptions; pub use thread_manager::ThreadManager; pub use thread_manager::build_models_manager; pub use web_search::web_search_action_detail; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 72c2ef82d8bc..d1d11f155450 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4137,7 +4137,12 @@ async fn multi_environment_feature_without_selections_uses_single_environment_be let (_session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), - |config| config.features.enable(Feature::MultiEnvironmentTools), + |config| { + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("multi-environment tools feature should enable"); + }, ) .await; @@ -4162,7 +4167,12 @@ async fn multi_environment_tools_enable_only_with_feature_and_selection() { let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), - |config| config.features.enable(Feature::MultiEnvironmentTools), + |config| { + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("multi-environment tools feature should enable"); + }, ) .await; let selected_cwd = diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 8c74cd350609..6b765fc57f6c 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -198,6 +198,16 @@ pub struct ThreadManager { _test_codex_home_guard: Option, } +pub struct StartThreadWithToolsOptions { + pub config: Config, + pub initial_history: InitialHistory, + pub dynamic_tools: Vec, + pub persist_extended_history: bool, + pub metrics_service_name: Option, + pub parent_trace: Option, + pub environment_selections: Option>, +} + /// Shared, `Arc`-owned state for [`ThreadManager`]. This `Arc` is required to have a single /// `Arc` reference that can be downgraded to by `AgentControl` while preventing every single /// function to require an `Arc<&Self>`. @@ -494,38 +504,34 @@ impl ThreadManager { dynamic_tools: Vec, persist_extended_history: bool, ) -> CodexResult { - Box::pin(self.start_thread_with_tools_and_service_name( - config, - InitialHistory::New, - dynamic_tools, - persist_extended_history, - /*metrics_service_name*/ None, - /*parent_trace*/ None, - /*environment_selections*/ None, - )) + Box::pin( + self.start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { + config, + initial_history: InitialHistory::New, + dynamic_tools, + persist_extended_history, + metrics_service_name: None, + parent_trace: None, + environment_selections: None, + }), + ) .await } pub async fn start_thread_with_tools_and_service_name( &self, - config: Config, - initial_history: InitialHistory, - dynamic_tools: Vec, - persist_extended_history: bool, - metrics_service_name: Option, - parent_trace: Option, - environment_selections: Option>, + options: StartThreadWithToolsOptions, ) -> CodexResult { Box::pin(self.state.spawn_thread( - config, - initial_history, + options.config, + options.initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), - dynamic_tools, - persist_extended_history, - metrics_service_name, - parent_trace, - environment_selections, + options.dynamic_tools, + options.persist_extended_history, + options.metrics_service_name, + options.parent_trace, + options.environment_selections, /*user_shell_override*/ None, )) .await From 01fabfd70bd4de8f894ef87b70bf4033b60e4937 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 15:51:14 -0700 Subject: [PATCH 32/38] Use parsed apply_patch cwd for shell intercepts Co-authored-by: Codex --- .../core/src/tools/handlers/apply_patch.rs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c6c32e00bf10..2de1a99e7ad8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -402,12 +402,18 @@ impl ToolHandler for ApplyPatchHandler { .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { + let action_cwd = changes.cwd.clone(); let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &cwd, &changes) - .await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &action_cwd, + &changes, + ) + .await; match apply_patch::apply_patch( turn.as_ref(), - &cwd, + &action_cwd, &file_system_sandbox_policy, changes, ) @@ -432,7 +438,7 @@ impl ToolHandler for ApplyPatchHandler { let req = ApplyPatchRequest { environment_id: tool_environment.environment_id.clone(), environment: tool_environment.environment, - cwd, + cwd: action_cwd, action: apply.action, file_paths, changes, @@ -514,6 +520,7 @@ pub(crate) async fn intercept_apply_patch( .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { + let action_cwd = changes.cwd.clone(); session .record_model_warning( format!( @@ -523,9 +530,15 @@ pub(crate) async fn intercept_apply_patch( ) .await; let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), cwd, &changes).await; - match apply_patch::apply_patch(turn.as_ref(), cwd, &file_system_sandbox_policy, changes) - .await + effective_patch_permissions(session.as_ref(), turn.as_ref(), &action_cwd, &changes) + .await; + match apply_patch::apply_patch( + turn.as_ref(), + &action_cwd, + &file_system_sandbox_policy, + changes, + ) + .await { InternalApplyPatchInvocation::Output(item) => { let content = item?; @@ -554,7 +567,7 @@ pub(crate) async fn intercept_apply_patch( "apply_patch is unavailable in this session".to_string(), ) })?, - cwd: cwd.clone(), + cwd: action_cwd, action: apply.action, file_paths: approval_keys, changes, From 9c337efe5865fa67240f28c87033d4f4ff0631df Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 16:04:31 -0700 Subject: [PATCH 33/38] Use selected environment cwd for unified exec Co-authored-by: Codex --- codex-rs/core/src/tools/handlers/unified_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 728f183ab07c..cb08851b7b89 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -358,7 +358,7 @@ impl ToolHandler for UnifiedExecHandler { environment: tool_environment.environment, yield_time_ms, max_output_tokens, - workdir, + workdir: Some(cwd.clone()), network: context.turn.network.clone(), tty, sandbox_permissions: effective_additional_permissions From faf7af45526aca8b40d77b60fade483310159485 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 17:16:00 -0700 Subject: [PATCH 34/38] Fix multi-environment unified exec test wait The submit helper already waits for the turn to complete. Remove the second wait so the test can inspect the captured request after the successful selected-environment tool call. Co-authored-by: Codex --- codex-rs/core/tests/suite/unified_exec.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d4612b6d403f..a8a0e526c417 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -397,11 +397,6 @@ async fn unified_exec_environment_id_targets_non_primary_environment() -> Result ) .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - let bodies = request_log .requests() .into_iter() From 8bae1c1efdb55af653717bf37649759074085a9b Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 17:22:47 -0700 Subject: [PATCH 35/38] Fix unified exec environment id assertion The test serializes captured request JSON before checking the model-visible environment context, so XML attribute quotes are JSON-escaped in the assertion string. Co-authored-by: Codex --- codex-rs/core/tests/suite/unified_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index a8a0e526c417..6f04d1d7ec24 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -408,7 +408,7 @@ async fn unified_exec_environment_id_targets_non_primary_environment() -> Result "environment list should be visible to the model: {request_text}" ); assert!( - request_text.contains("id=\"local\""), + request_text.contains("id=\\\"local\\\""), "local environment id should be visible to the model: {request_text}" ); assert!( From fd4e5f163d621faf85a0837dc62a65bb814f659d Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 17:16:06 -0700 Subject: [PATCH 36/38] Fix multi-environment unified exec test Use a command-backed remote environment so the test covers remote selection without depending on an unreachable websocket during session startup. Co-authored-by: Codex --- codex-rs/core/tests/common/test_codex.rs | 41 ++++++++++++++++------- codex-rs/core/tests/suite/unified_exec.rs | 20 ++++++++++- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 74b05f6e1774..f0ea1c01ffc5 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -208,6 +208,7 @@ pub struct TestCodexBuilder { home: Option>, user_shell_override: Option, exec_server_url: Option, + environment_manager_config: Option, } impl TestCodexBuilder { @@ -264,6 +265,14 @@ impl TestCodexBuilder { self } + pub fn with_environment_manager_config( + mut self, + config: codex_exec_server::ConfiguredEnvironmentManagerArgs, + ) -> Self { + self.environment_manager_config = Some(config); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -359,19 +368,24 @@ impl TestCodexBuilder { let (config, fallback_cwd) = self .prepare_config(base_url, &home, test_env.cwd().clone()) .await?; - let exec_server_url = self - .exec_server_url - .clone() - .or_else(|| test_env.exec_server_url().map(str::to_owned)); - let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new( - codex_exec_server::EnvironmentManagerArgs { - exec_server_url, - local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( - std::env::current_exe()?, - /*codex_linux_sandbox_exe*/ None, - )?, - }, - )); + let environment_manager = match self.environment_manager_config.clone() { + Some(config) => Arc::new(codex_exec_server::EnvironmentManager::try_new(config)?), + None => { + let exec_server_url = self + .exec_server_url + .clone() + .or_else(|| test_env.exec_server_url().map(str::to_owned)); + Arc::new(codex_exec_server::EnvironmentManager::new( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, + }, + )) + } + }; let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); @@ -924,6 +938,7 @@ pub fn test_codex() -> TestCodexBuilder { home: None, user_shell_override: None, exec_server_url: None, + environment_manager_config: None, } } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6f04d1d7ec24..1f42f1c1d723 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -5,7 +5,10 @@ use std::sync::OnceLock; use anyhow::Context; use anyhow::Result; +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::RemoteExecServerTransport; use codex_features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -347,8 +350,22 @@ async fn unified_exec_environment_id_targets_non_primary_environment() -> Result skip_if_windows!(Ok(())); let server = start_mock_server().await; + let codex_exe = codex_utils_cargo_bin::cargo_bin("codex") + .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex-exec"))?; let mut builder = test_codex() - .with_exec_server_url("ws://127.0.0.1:9") + .with_environment_manager_config(ConfiguredEnvironmentManagerArgs { + default_environment: Some("local".to_string()), + environments: vec![ConfiguredEnvironmentSpec { + id: "remote".to_string(), + transport: RemoteExecServerTransport::Command { + command: format!("{codex_exe:?} exec-server --listen stdio://"), + }, + }], + local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( + codex_exe, + /*codex_linux_sandbox_exe*/ None, + )?, + }) .with_config(|config| { config .features @@ -362,6 +379,7 @@ async fn unified_exec_environment_id_targets_non_primary_environment() -> Result let test = builder.build(&server).await?; let selected_local_cwd = create_workspace_directory(&test, "selected-local").await?; let primary_remote_cwd = test.config.cwd.join("primary-remote"); + tokio::fs::create_dir_all(&primary_remote_cwd).await?; let call_id = "uexec-env-local"; let args = json!({ From 7249edf32639431aad9954b00c5c2958a56478ec Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 12:24:50 -0700 Subject: [PATCH 37/38] Fix TUI test environment fixtures Co-authored-by: Codex --- codex-rs/tui/src/app/tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c498d3d41d78..5176f6f01c87 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1658,6 +1658,7 @@ async fn update_feature_flags_enabling_guardian_selects_guardian_approvals() -> service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); let cell = match app_event_rx.try_recv() { @@ -1749,6 +1750,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); let cell = match app_event_rx.try_recv() { @@ -1828,6 +1830,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); @@ -1885,6 +1888,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); assert!( @@ -1944,6 +1948,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); @@ -2031,6 +2036,7 @@ guardian_approval = true service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) ); let cell = match app_event_rx.try_recv() { @@ -2663,6 +2669,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re turn_id: None, trace_id: None, cwd: test_path_buf("/tmp/agent"), + environments: None, current_date: None, timezone: None, approval_policy: primary_session.approval_policy, From 9f8f766b1cb7e319387df0c4d95e4ad902b64ef5 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 13:17:49 -0700 Subject: [PATCH 38/38] Apply formatting after sticky environment rebase Co-authored-by: Codex --- codex-rs/app-server/src/lib.rs | 4 +++- codex-rs/core/tests/suite/unified_exec.rs | 3 +-- codex-rs/mcp-server/src/lib.rs | 4 +++- codex-rs/tui/src/lib.rs | 4 +++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 10f6d5f1c807..b579ede94580 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -444,7 +444,9 @@ pub async fn run_main_with_transport( }; let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; let environment_manager = Arc::new( - EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, ); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 1f42f1c1d723..2e42c57cc0ff 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -362,8 +362,7 @@ async fn unified_exec_environment_id_targets_non_primary_environment() -> Result }, }], local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( - codex_exe, - /*codex_linux_sandbox_exe*/ None, + codex_exe, /*codex_linux_sandbox_exe*/ None, )?, }) .with_config(|config| { diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index c027350d23e4..cca68d28bed8 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -79,7 +79,9 @@ pub async fn run_main( })?; let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; let environment_manager = Arc::new( - EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, ); set_default_client_residency_requirement(config.enforce_residency.value()); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 37527bebc8f4..fdc6e260d6ed 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -851,7 +851,9 @@ pub async fn run_main( .await; let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; let environment_manager = Arc::new( - EnvironmentManager::try_new(config.environment_manager_args(exec_server_url, runtime_paths)?) + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?, );