From a7fa3679ba9389eaf113c54ae4c0507236defd3a Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 17 Apr 2026 15:02:29 -0700 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 e1d635f7a41cf7a09efa9eadeb9af93638167103 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:13:24 -0700 Subject: [PATCH 27/31] 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 7ce7f245c7bb126e5498ccc1575c9df38d11bbb1 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Mon, 20 Apr 2026 14:18:19 -0700 Subject: [PATCH 28/31] 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 57182b5653d359f794e2c9be3832b3da9e2cdabf Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 14:51:58 -0700 Subject: [PATCH 29/31] Polish sticky environment API wiring Group thread-start options for lint-friendly callsites and update generated v2 schema for sticky environment selections. Co-authored-by: Codex --- .../schema/json/v2/ThreadStartParams.json | 19 +++++++ .../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/thread_manager.rs | 52 +++++++++++-------- 5 files changed, 65 insertions(+), 36 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/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/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 6edcdf2bb57c3bee0035cdd0be9e88af64602efa Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 15:05:25 -0700 Subject: [PATCH 30/31] Rename sticky environment state to environments Introduce a core-owned EnvironmentSelection type so app-server converts API environment params at the core boundary instead of passing protocol operation structs through session/thread state. Rename the internal sticky field from environment_selections to environments to match the v2 API shape. Co-authored-by: Codex --- .../app-server-protocol/src/protocol/v2.rs | 6 +-- .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/session/handlers.rs | 4 +- codex-rs/core/src/session/mod.rs | 9 ++-- codex-rs/core/src/session/session.rs | 12 ++--- codex-rs/core/src/session/tests.rs | 32 ++++++------ .../core/src/session/tests/guardian_tests.rs | 2 +- codex-rs/core/src/session/turn_context.rs | 51 +++++++++---------- codex-rs/core/src/thread_manager.rs | 29 ++++++----- codex-rs/protocol/src/protocol.rs | 6 +-- 11 files changed, 78 insertions(+), 79 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9df301e878f9..f9f7d871078c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3074,7 +3074,7 @@ pub struct ThreadStartParams { pub ephemeral: Option, #[ts(optional = nullable)] pub session_start_source: Option, - /// Optional sticky environment selections for this thread. + /// Optional sticky environments for this thread. /// /// Omitted uses EnvironmentManager default behavior. Empty disables /// environment access for turns that do not provide a turn override. @@ -4669,9 +4669,9 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, - /// Optional turn-scoped environment selections. + /// Optional turn-scoped environments. /// - /// Omitted uses the thread sticky environment selections. Empty disables + /// Omitted uses the thread sticky environments. 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")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b99725423c20..85a423ffa237 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2478,7 +2478,7 @@ impl CodexMessageProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, - environment_selections: Option>, + environments: Option>, persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, @@ -2626,7 +2626,7 @@ impl CodexMessageProcessor { persist_extended_history, metrics_service_name: service_name, parent_trace: request_trace, - environment_selections, + environments, }) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index ae7b71756870..1772491223e4 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,7 +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, + environments: 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 108bb1cfe923..9939e0b91fdd 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -166,7 +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, + environments: None, }, None, environments, @@ -1103,7 +1103,7 @@ pub(super) async fn submission_loop( reasoning_summary: summary, service_tier, personality, - environment_selections: environments, + environments, ..Default::default() }, ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4f27edb5bdfa..5e972440db99 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; @@ -401,7 +402,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) environments: Option>, pub(crate) analytics_events_client: Option, } @@ -456,7 +457,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, - environment_selections, + environments, analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -619,7 +620,7 @@ impl Codex { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environment_selections, + environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -629,7 +630,7 @@ impl Codex { persist_extended_history, inherited_shell_snapshot, user_shell_override, - environment_selections, + environments, }; // 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 f33993b196b3..1a7eb2294ea7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -71,9 +71,8 @@ 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>, + /// Sticky environments for turns that do not provide a turn-local override. + pub(super) environments: Option>, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -184,8 +183,8 @@ 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); + if let Some(environments) = updates.environments.clone() { + next_configuration.environments = Some(environments); } Ok(next_configuration) } @@ -205,8 +204,7 @@ 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) environments: Option>, } pub(crate) struct AppServerClientMetadata { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5855b62141c0..84df38fd7f50 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -802,7 +802,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow sandbox_policy: Some(SandboxPolicy::DangerFullAccess), ..Default::default() }, - /*environment_selections*/ None, + /*environments*/ None, ) .await?; @@ -2322,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2428,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2784,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3055,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3156,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3377,7 +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, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3911,7 +3911,7 @@ async fn user_turn_updates_approvals_reviewer() { } #[tokio::test] -async fn turn_environment_selection_sets_primary_environment() { +async fn turn_environments_set_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")) @@ -3921,7 +3921,7 @@ async fn turn_environment_selection_sets_primary_environment() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate::default(), - Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + Some(vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: selected_cwd.clone(), }]), @@ -3947,7 +3947,7 @@ async fn turn_environment_selection_sets_primary_environment() { } #[tokio::test] -async fn multiple_turn_environment_selections_use_first_as_primary_environment() { +async fn multiple_turn_environments_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 = @@ -3960,11 +3960,11 @@ async fn multiple_turn_environment_selections_use_first_as_primary_environment() "sub-1".to_string(), SessionSettingsUpdate::default(), Some(vec![ - codex_protocol::protocol::TurnEnvironmentSelection { + TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: first_cwd.clone(), }, - codex_protocol::protocol::TurnEnvironmentSelection { + TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: second_cwd.clone(), }, @@ -3992,7 +3992,7 @@ async fn multiple_turn_environment_selections_use_first_as_primary_environment() } #[tokio::test] -async fn empty_turn_environment_selection_clears_primary_environment() { +async fn empty_turn_environments_clear_primary_environment() { let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; let turn_context = session @@ -4018,14 +4018,14 @@ async fn empty_turn_environment_selection_clears_primary_environment() { } #[tokio::test] -async fn unknown_turn_environment_selection_returns_error() { +async fn unknown_turn_environment_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 { + Some(vec![TurnEnvironmentSelection { environment_id: "missing".to_string(), cwd: session.get_config().await.cwd.clone(), }]), @@ -4389,7 +4389,7 @@ where cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, - environment_selections: None, + environments: 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 06c189993c8a..98b69d65b152 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -651,7 +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, + environments: 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 b44a416520d4..00af39218157 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -465,7 +465,7 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, - environment_selections: Option>, + environments: Option>, ) -> CodexResult> { let update_result = { let mut state = self.state.lock().await; @@ -510,23 +510,22 @@ 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); - } - }; + let effective_environments = + environments.or_else(|| session_configuration.environments.clone()); + let turn_environments = match self.resolve_turn_environments(effective_environments) { + 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, @@ -552,27 +551,27 @@ impl Session { fn resolve_turn_environments( &self, - environment_selections: Option>, + environments: Option>, ) -> CodexResult>> { - let Some(environment_selections) = environment_selections else { + let Some(environments) = environments else { return Ok(None); }; - let mut turn_environments = Vec::with_capacity(environment_selections.len()); - for environment_selection in environment_selections { + let mut turn_environments = Vec::with_capacity(environments.len()); + for selected_environment in environments { let environment = self .services .environment_manager - .get_environment(&environment_selection.environment_id) + .get_environment(&selected_environment.environment_id) .ok_or_else(|| { CodexErr::InvalidRequest(format!( "unknown turn environment id `{}`", - environment_selection.environment_id + selected_environment.environment_id )) })?; - let cwd = environment_selection.cwd; + let cwd = selected_environment.cwd; turn_environments.push(TurnEnvironment { - environment_id: environment_selection.environment_id, + environment_id: selected_environment.environment_id, environment, cwd, }); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6b765fc57f6c..460c843010c7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_utils_absolute_path::AbsolutePathBuf; @@ -205,7 +206,7 @@ pub struct StartThreadWithToolsOptions { pub persist_extended_history: bool, pub metrics_service_name: Option, pub parent_trace: Option, - pub environment_selections: Option>, + pub environments: Option>, } /// Shared, `Arc`-owned state for [`ThreadManager`]. This `Arc` is required to have a single @@ -512,7 +513,7 @@ impl ThreadManager { persist_extended_history, metrics_service_name: None, parent_trace: None, - environment_selections: None, + environments: None, }), ) .await @@ -531,7 +532,7 @@ impl ThreadManager { options.persist_extended_history, options.metrics_service_name, options.parent_trace, - options.environment_selections, + options.environments, /*user_shell_override*/ None, )) .await @@ -572,7 +573,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -592,7 +593,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -615,7 +616,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -724,7 +725,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -826,7 +827,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -854,7 +855,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -883,7 +884,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, - /*environment_selections*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -901,7 +902,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, - environment_selections: Option>, + environments: Option>, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -916,7 +917,7 @@ impl ThreadManagerState { /*inherited_shell_snapshot*/ None, /*inherited_exec_policy*/ None, parent_trace, - environment_selections, + environments, user_shell_override, )) .await @@ -936,7 +937,7 @@ impl ThreadManagerState { inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, parent_trace: Option, - environment_selections: Option>, + environments: Option>, user_shell_override: Option, ) -> CodexResult { let environment = self.environment_manager.default_environment(); @@ -974,7 +975,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, - environment_selections, + environments, 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 6fe8e37e38df..55f563b18757 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -431,7 +431,7 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, - /// Optional turn-scoped environment selections. + /// Optional turn-scoped environments. #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. @@ -498,7 +498,7 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] personality: Option, - /// Optional turn-scoped environment selections. + /// Optional turn-scoped environments. #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, }, @@ -568,7 +568,7 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] personality: Option, - /// Updated sticky environment selections for future turns. + /// Updated sticky environments for future turns. #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, }, From 16587a75a37fe098fe73829c5ff431bf1cd01dcf Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 21 Apr 2026 14:53:55 -0700 Subject: [PATCH 31/31] Load environments through configured providers Introduce a small EnvironmentProvider interface that discovers remote environment specs while EnvironmentManager owns validation and concrete runtime Environment caching. Wire env-var, static config.toml, and HTTP-backed provider setup through the same manager construction path. Co-authored-by: Codex --- codex-rs/app-server/src/lib.rs | 17 +- .../app-server/tests/suite/v2/skills_list.rs | 1 + codex-rs/cli/src/main.rs | 2 +- codex-rs/core/src/config/mod.rs | 221 ++++++++++++++++++ codex-rs/core/tests/common/test_codex.rs | 48 ++-- 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 | 202 ++++++++++++++-- codex-rs/exec-server/src/lib.rs | 6 + codex-rs/exec-server/src/server/transport.rs | 21 +- .../exec-server/src/server/transport_tests.rs | 2 +- .../exec-server/tests/command_transport.rs | 55 +++++ codex-rs/exec/src/lib.rs | 10 +- codex-rs/mcp-server/src/lib.rs | 17 +- codex-rs/tui/src/app/tests.rs | 7 + codex-rs/tui/src/lib.rs | 17 +- 17 files changed, 668 insertions(+), 79 deletions(-) create mode 100644 codex-rs/exec-server/tests/command_transport.rs diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index d3e874c5c44f..477ac444cd06 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,13 @@ pub async fn run_main_with_transport( })? } }; + let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; + let environment_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .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..abfd2a930bd9 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::EnvVarEnvironmentProvider; +use codex_exec_server::EnvironmentProvider; +use codex_exec_server::EnvironmentSpec; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_exec_server::RemoteExecServerTransport; +use codex_exec_server::StaticEnvironmentProvider; pub use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -833,6 +838,18 @@ impl Config { } } + pub fn environment_provider( + &self, + exec_server_url: Option, + ) -> 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))?; + build_environment_provider(environment_config, exec_server_url) + } + /// 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 +1300,210 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +#[derive(Debug, Deserialize, Default)] +struct EnvironmentConfigToml { + environment_provider: Option, + default_environment: Option, + environments: Option>, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentProviderToml { + #[serde(rename = "type")] + provider_type: EnvironmentProviderKind, + default_environment: Option, + environments: Option>, + url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum EnvironmentProviderKind { + Env, + Static, + Http, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentToml { + id: String, + #[serde(alias = "websocket_url", alias = "websocketUrl")] + url: Option, + #[serde(alias = "execServerCommand")] + exec_server_command: Option, +} + +fn build_environment_provider( + environment_config: EnvironmentConfigToml, + exec_server_url: Option, +) -> std::io::Result> { + match environment_config.environment_provider { + Some(provider) => build_configured_environment_provider(provider, exec_server_url), + None if environment_config.default_environment.is_some() + || environment_config.environments.is_some() => + { + Ok(Box::new(StaticEnvironmentProvider { + default_environment: environment_config.default_environment, + environments: build_environment_specs( + environment_config.environments.as_deref().unwrap_or(&[]), + )?, + })) + } + None => Ok(Box::new(EnvVarEnvironmentProvider { exec_server_url })), + } +} + +fn build_configured_environment_provider( + provider: EnvironmentProviderToml, + exec_server_url: Option, +) -> std::io::Result> { + match provider.provider_type { + EnvironmentProviderKind::Env => Ok(Box::new(EnvVarEnvironmentProvider { exec_server_url })), + EnvironmentProviderKind::Static => Ok(Box::new(StaticEnvironmentProvider { + default_environment: provider.default_environment, + environments: build_environment_specs(provider.environments.as_deref().unwrap_or(&[]))?, + })), + EnvironmentProviderKind::Http => { + let url = provider + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "environment_provider.url is required for http provider", + ) + })?; + Ok(Box::new(HttpEnvironmentProvider { + url: url.to_string(), + client: reqwest::Client::new(), + })) + } + } +} + +fn build_environment_specs( + environments: &[EnvironmentToml], +) -> std::io::Result> { + let mut environment_specs = Vec::with_capacity(environments.len()); + for environment in environments { + environment_specs.push(build_environment_spec(environment)?); + } + + Ok(environment_specs) +} + +fn build_environment_spec(environment: &EnvironmentToml) -> std::io::Result { + 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"), + )); + } + }; + + Ok(EnvironmentSpec { + id: id.to_string(), + transport, + }) +} + +struct HttpEnvironmentProvider { + url: String, + client: reqwest::Client, +} + +#[async_trait::async_trait] +impl EnvironmentProvider for HttpEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), codex_exec_server::ExecServerError> { + let url = format!("{}/environments", self.url.trim_end_matches('/')); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to load environments from `{url}`: {err}" + )) + })? + .error_for_status() + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to load environments from `{url}`: {err}" + )) + })? + .json::() + .await + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to parse environments from `{url}`: {err}" + )) + })?; + let environments = build_environment_specs(&response.environments).map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "invalid environment service response from `{url}`: {err}" + )) + })?; + Ok((response.default_environment, environments)) + } +} + +#[derive(Debug, Deserialize)] +struct HttpEnvironmentResponse { + #[serde(alias = "defaultEnvironment")] + default_environment: Option, + environments: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 74b05f6e1774..d1346cee454e 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_provider: Option, } impl TestCodexBuilder { @@ -264,6 +265,14 @@ impl TestCodexBuilder { self } + pub fn with_environment_provider( + mut self, + provider: codex_exec_server::StaticEnvironmentProvider, + ) -> Self { + self.environment_provider = Some(provider); + 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,31 @@ 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 local_runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?; + let environment_manager = match self.environment_provider.clone() { + Some(provider) => Arc::new( + codex_exec_server::EnvironmentManager::try_from_provider( + &provider, + local_runtime_paths, + ) + .await?, + ), + 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, + }, + )) + } + }; let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); @@ -924,6 +945,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/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..5fe92cf2e45d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,8 +1,11 @@ use std::collections::HashMap; use std::sync::Arc; +use async_trait::async_trait; + 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 +19,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 +67,73 @@ impl EnvironmentManagerArgs { } } +#[derive(Clone, Debug)] +pub struct EnvironmentSpec { + pub id: String, + pub transport: RemoteExecServerTransport, +} + +/// Discovers the remote environments that should be available to Codex. +/// +/// Implementations are source-specific: they may read process environment, +/// `config.toml`, or a remote service. They should return plain environment +/// specs and leave validation plus runtime object construction to +/// [`EnvironmentManager`]. +#[async_trait] +pub trait EnvironmentProvider: Send + Sync { + /// Returns the default environment id and remote environment specs. + /// + /// `EnvironmentManager` always adds `local` itself. A missing or empty + /// default means `local`; `"none"` disables the default environment. + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError>; +} + +#[derive(Clone, Debug)] +pub struct EnvVarEnvironmentProvider { + pub exec_server_url: Option, +} + +#[async_trait] +impl EnvironmentProvider for EnvVarEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError> { + let (exec_server_url, environment_disabled) = + normalize_exec_server_url(self.exec_server_url.clone()); + if environment_disabled { + return Ok((Some("none".to_string()), Vec::new())); + } + + match exec_server_url { + Some(url) => Ok(( + Some(REMOTE_ENVIRONMENT_ID.to_string()), + vec![EnvironmentSpec { + id: REMOTE_ENVIRONMENT_ID.to_string(), + transport: RemoteExecServerTransport::WebSocket { url }, + }], + )), + None => Ok((Some(LOCAL_ENVIRONMENT_ID.to_string()), Vec::new())), + } + } +} + +#[derive(Clone, Debug)] +pub struct StaticEnvironmentProvider { + pub default_environment: Option, + pub environments: Vec, +} + +#[async_trait] +impl EnvironmentProvider for StaticEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError> { + Ok((self.default_environment.clone(), self.environments.clone())) + } +} + impl EnvironmentManager { /// Builds a test-only manager without configured sandbox helper paths. pub fn default_for_tests() -> Self { @@ -73,9 +146,11 @@ 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 { + Self::from_exec_server_url_args(args) + } + + fn from_exec_server_url_args(args: EnvironmentManagerArgs) -> Self { let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, @@ -92,7 +167,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 +186,73 @@ impl EnvironmentManager { } } + pub fn try_new(args: EnvironmentManagerArgs) -> Result { + Ok(Self::from_exec_server_url_args(args)) + } + + pub async fn try_from_provider( + provider: &dyn EnvironmentProvider, + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + let (default_environment, environments) = provider.get_environments().await?; + Self::from_environment_specs(default_environment, environments, local_runtime_paths) + } + + fn from_environment_specs( + default_environment: Option, + environment_specs: Vec, + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + let mut environments = HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::local(local_runtime_paths.clone())), + )]); + + for environment in environment_specs { + 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 +280,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 +290,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 +301,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 +334,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 +349,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 +358,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 +375,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..15e7ef56e20b 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -20,12 +20,18 @@ 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::EnvVarEnvironmentProvider; pub use environment::Environment; pub use environment::EnvironmentManager; pub use environment::EnvironmentManagerArgs; +pub use environment::EnvironmentProvider; +pub use environment::EnvironmentSpec; +pub use environment::StaticEnvironmentProvider; 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-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..8db030d05371 --- /dev/null +++ b/codex-rs/exec-server/tests/command_transport.rs @@ -0,0 +1,55 @@ +mod common; + +use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentSpec; +use codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::RemoteExecServerTransport; +use codex_exec_server::StaticEnvironmentProvider; +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 provider = StaticEnvironmentProvider { + default_environment: Some("remote".to_string()), + environments: vec![EnvironmentSpec { + 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, + ), + }, + }], + }; + let manager = EnvironmentManager::try_from_provider( + &provider, + ExecServerRuntimePaths::new(helper_paths.codex_exe.clone(), None)?, + ) + .await?; + 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/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 11bf1b7a60a3..b50ff4dbcc22 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -490,6 +490,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?; + let exec_server_url = + EnvironmentManagerArgs::from_env(local_runtime_paths.clone()).exec_server_url; + let environment_provider = config.environment_provider(exec_server_url)?; + let environment_manager = + EnvironmentManager::try_from_provider(environment_provider.as_ref(), local_runtime_paths) + .await?; let in_process_start_args = InProcessClientStartArgs { arg0_paths, config: std::sync::Arc::new(config.clone()), @@ -498,9 +504,7 @@ 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(environment_manager), 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 1d904e4577a0..fe45d9f429a9 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,13 @@ 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_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .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/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, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7e33f2e8a3f6..bb93e721d94b 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,13 @@ pub async fn run_main( cloud_requirements.clone(), ) .await; + let exec_server_url = EnvironmentManagerArgs::from_env(runtime_paths.clone()).exec_server_url; + let environment_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .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 { @@ -2184,6 +2192,7 @@ mod tests { turn_id: None, trace_id: None, cwd, + environments: None, current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(),