diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c6f678c2aa22..1c3de1208b15 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,7 @@ 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::default_for_tests()), config_warnings: Vec::new(), session_source, enable_codex_api_key_env: false, @@ -1969,9 +1970,14 @@ 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::new(EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + })); let runtime_args = InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), @@ -1998,7 +2004,13 @@ mod tests { &runtime_args.environment_manager, &environment_manager )); - assert!(runtime_args.environment_manager.is_remote()); + assert!( + runtime_args + .environment_manager + .default_environment() + .expect("default environment") + .is_remote() + ); } #[tokio::test] 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/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-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/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/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7162eb4deee..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>, @@ -4641,6 +4649,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 +4669,14 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[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>, /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, @@ -9759,6 +9783,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 +9800,109 @@ 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_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!({ + "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/README.md b/codex-rs/app-server/README.md index 86221ec801ff..02ab175fbdd2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -530,6 +530,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", diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a2ea77900aa9..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( - /*exec_server_url*/ None, - )), + 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 3b92d4199f58..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; @@ -260,6 +261,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; @@ -317,6 +319,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; @@ -2351,8 +2354,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, @@ -2390,6 +2403,7 @@ impl CodexMessageProcessor { typesafe_overrides, dynamic_tools, session_start_source, + environments, persist_extended_history, service_name, experimental_raw_events, @@ -2464,6 +2478,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, @@ -2599,19 +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", @@ -5694,27 +5710,17 @@ 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)) => { + 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()) } - Ok(None) => McpRuntimeEnvironment::new( - Arc::new(codex_exec_server::Environment::default()), + None => McpRuntimeEnvironment::new( + environment_manager.local_environment(), 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; - } }; tokio::spawn(async move { @@ -5864,25 +5870,14 @@ 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_manager = self.thread_manager.environment_manager(); + let environment = environment_manager + .default_environment() + .unwrap_or_else(|| environment_manager.local_environment()); + // Resource reads without a thread have no turn cwd. This fallback + // is used only by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) }; tokio::spawn(async move { @@ -6230,8 +6225,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; }); } @@ -6240,6 +6236,7 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: AppsListParams, config: Config, + environment_manager: Arc, ) { let AppsListParams { cursor, @@ -6274,12 +6271,15 @@ impl CodexMessageProcessor { let accessible_config = config.clone(); let accessible_tx = tx.clone(); 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)); }); @@ -6469,23 +6469,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 @@ -6801,8 +6789,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 @@ -6979,10 +6972,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 ), ); @@ -7191,6 +7185,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 @@ -7230,6 +7233,7 @@ impl CodexMessageProcessor { service_tier: params.service_tier, collaboration_mode, personality: params.personality, + environments: None, }, ) .await; @@ -7242,6 +7246,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/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/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 e73124c0d3e9..924398037bb5 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::new(/*exec_server_url*/ None)), + 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 d4573b267a83..b579ede94580 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,6 +8,7 @@ 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::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; @@ -361,12 +362,10 @@ 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( - 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); @@ -443,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_manager = Arc::new( + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { let (path, range) = exec_policy_warning_location(&err); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 53d9f2df4ce3..c54db8b55bbb 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 { @@ -1041,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 { @@ -1058,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/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 83f8bc98d7c6..8ff940667814 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::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), @@ -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/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index c26b456fa91c..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,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::new(/*exec_server_url*/ None)), + 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/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/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 65f8442b0de1..5235dc75cc36 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; @@ -1733,6 +1735,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 +1776,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(), @@ -1838,6 +1842,179 @@ 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 + .ok_or_else(|| anyhow::anyhow!("turn/started notification should include 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.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, + 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(())); 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/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/codex-experimental-api-macros/src/lib.rs b/codex-rs/codex-experimental-api-macros/src/lib.rs index c5099e40a5ce..2bca0190eaa2 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() }; @@ -276,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; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 10850ef8c74e..3aa7d6044c8c 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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); Self { @@ -430,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(), @@ -577,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(), @@ -690,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(), @@ -911,9 +912,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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -965,9 +964,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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1010,9 +1007,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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); let cloned = control.clone(); @@ -1057,9 +1052,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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1115,9 +1108,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( - /*exec_server_url*/ None, - )), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), ); let control = manager.agent_control(); @@ -1512,9 +1503,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( - /*exec_server_url*/ None, - )), + 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/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index c05a459049bf..0788ae216c3b 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -32,6 +32,7 @@ pub(crate) struct ApplyPatchRuntimeInvocation { pub(crate) async fn apply_patch( turn_context: &TurnContext, + cwd: &codex_utils_absolute_path::AbsolutePathBuf, file_system_sandbox_policy: &FileSystemSandboxPolicy, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { @@ -40,7 +41,7 @@ pub(crate) async fn apply_patch( turn_context.approval_policy.value(), turn_context.sandbox_policy.get(), file_system_sandbox_policy, - &turn_context.cwd, + cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 7247c601f46e..ae7b71756870 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), @@ -95,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?; @@ -185,6 +183,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/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7ae710f83cbc..f2681bafdb49 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,8 +49,13 @@ use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; +use codex_exec_server::EnvironmentManagerArgs; +use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_exec_server::RemoteExecServerTransport; pub use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -833,6 +838,38 @@ impl Config { } } + pub fn environment_manager_args( + &self, + exec_server_url: Option, + local_runtime_paths: ExecServerRuntimePaths, + ) -> std::io::Result { + let environment_config: EnvironmentConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let Some(environments) = environment_config.environments.as_ref() else { + if environment_config.default_environment.is_none() { + return Ok(EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths, + } + .into()); + } + return build_configured_environment_manager_args( + environment_config.default_environment, + &[], + local_runtime_paths, + ); + }; + + build_configured_environment_manager_args( + environment_config.default_environment, + environments, + local_runtime_paths, + ) + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1283,6 +1320,92 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +#[derive(Debug, Deserialize, Default)] +struct EnvironmentConfigToml { + default_environment: Option, + environments: Option>, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentToml { + id: String, + url: Option, + exec_server_command: Option, +} + +fn build_configured_environment_manager_args( + default_environment: Option, + environments: &[EnvironmentToml], + local_runtime_paths: ExecServerRuntimePaths, +) -> std::io::Result { + let mut configured_environments = Vec::with_capacity(environments.len()); + for environment in environments { + let id = environment.id.trim(); + if id.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "environment id must not be empty", + )); + } + if id == "local" || id.eq_ignore_ascii_case("none") { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment id `{id}` is reserved"), + )); + } + + let url = environment + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let command = environment + .exec_server_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let transport = match (url, command) { + (Some(url), None) => { + if !(url.starts_with("ws://") || url.starts_with("wss://")) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` url must start with ws:// or wss://"), + )); + } + RemoteExecServerTransport::WebSocket { + url: url.to_string(), + } + } + (None, Some(command)) => RemoteExecServerTransport::Command { + command: command.to_string(), + }, + (Some(_), Some(_)) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set only one of url or exec_server_command"), + )); + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set url or exec_server_command"), + )); + } + }; + configured_environments.push(ConfiguredEnvironmentSpec { + id: id.to_string(), + transport, + }); + } + + Ok(ConfiguredEnvironmentManagerArgs { + default_environment, + environments: configured_environments, + local_runtime_paths, + } + .into()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 965521ae3d9e..2c4d78e8b55a 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; @@ -190,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); @@ -247,6 +271,10 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( let (tx_event, rx_event) = unbounded(); drop(rx_event); + 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 +283,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/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index c4e77624f864..046f1725377b 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -9,6 +9,7 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] pub(crate) struct EnvironmentContext { pub(crate) cwd: Option, + pub(crate) environments: Option>, pub(crate) shell: String, pub(crate) current_date: Option, pub(crate) timezone: Option, @@ -16,6 +17,13 @@ pub(crate) struct EnvironmentContext { pub(crate) subagents: Option, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct EnvironmentContextEnvironment { + pub(crate) id: String, + pub(crate) cwd: PathBuf, + pub(crate) primary: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct NetworkContext { allowed_domains: Vec, @@ -32,6 +40,7 @@ impl NetworkContext { } impl EnvironmentContext { + #[cfg(test)] pub(crate) fn new( cwd: Option, shell: String, @@ -39,9 +48,30 @@ impl EnvironmentContext { timezone: Option, network: Option, subagents: Option, + ) -> Self { + Self::new_with_environments( + cwd, + /*environments*/ None, + shell, + current_date, + timezone, + network, + subagents, + ) + } + + fn new_with_environments( + cwd: Option, + environments: Option>, + shell: String, + current_date: Option, + timezone: Option, + network: Option, + subagents: Option, ) -> Self { Self { cwd, + environments, shell, current_date, timezone, @@ -56,6 +86,7 @@ impl EnvironmentContext { pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { let EnvironmentContext { cwd, + environments, current_date, timezone, network, @@ -63,6 +94,7 @@ impl EnvironmentContext { shell: _, } = other; self.cwd == *cwd + && self.environments == *environments && self.current_date == *current_date && self.timezone == *timezone && self.network == *network @@ -83,8 +115,9 @@ impl EnvironmentContext { } else { before_network }; - EnvironmentContext::new( + EnvironmentContext::new_with_environments( cwd, + Self::environments_for_diff(before, after), after.shell.clone(), after.current_date.clone(), after.timezone.clone(), @@ -94,8 +127,9 @@ impl EnvironmentContext { } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new( + Self::new_with_environments( Some(turn_context.cwd.to_path_buf()), + Self::environments_from_turn_context(turn_context), shell.name().to_string(), turn_context.current_date.clone(), turn_context.timezone.clone(), @@ -108,8 +142,9 @@ impl EnvironmentContext { turn_context_item: &TurnContextItem, shell: String, ) -> Self { - Self::new( + Self::new_with_environments( Some(turn_context_item.cwd.clone()), + Self::environments_from_turn_context_item(turn_context_item), shell, turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), @@ -159,6 +194,63 @@ impl EnvironmentContext { denied_domains.clone(), )) } + + fn environments_from_turn_context( + turn_context: &TurnContext, + ) -> Option> { + if !turn_context.tools_config.multi_environment_tools { + return None; + } + let environments = turn_context.environments.as_ref()?; + if environments.is_empty() { + return None; + } + + Some( + environments + .iter() + .enumerate() + .map(|(index, environment)| EnvironmentContextEnvironment { + id: environment.environment_id.clone(), + cwd: environment.cwd.to_path_buf(), + primary: index == 0, + }) + .collect(), + ) + } + + fn environments_from_turn_context_item( + turn_context_item: &TurnContextItem, + ) -> Option> { + let environments = turn_context_item.environments.as_ref()?; + if environments.is_empty() { + return None; + } + + Some( + environments + .iter() + .enumerate() + .map(|(index, environment)| EnvironmentContextEnvironment { + id: environment.environment_id.clone(), + cwd: environment.cwd.to_path_buf(), + primary: index == 0, + }) + .collect(), + ) + } + + fn environments_for_diff( + before: &TurnContextItem, + after: &EnvironmentContext, + ) -> Option> { + let before_environments = Self::environments_from_turn_context_item(before); + if before_environments == after.environments { + None + } else { + after.environments.clone() + } + } } impl ContextualUserFragment for EnvironmentContext { @@ -171,6 +263,26 @@ impl ContextualUserFragment for EnvironmentContext { if let Some(cwd) = &self.cwd { lines.push(format!(" {}", cwd.to_string_lossy())); } + if let Some(environments) = &self.environments { + lines.push(" ".to_string()); + for environment in environments { + let primary = if environment.primary { + " primary=\"true\"" + } else { + "" + }; + lines.push(format!( + " ", + environment.id, primary + )); + lines.push(format!( + " {}", + environment.cwd.to_string_lossy() + )); + lines.push(" ".to_string()); + } + lines.push(" ".to_string()); + } lines.push(format!(" {}", self.shell)); if let Some(current_date) = &self.current_date { diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 84f8c0d99f00..3279797fb380 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -72,6 +72,51 @@ fn serialize_environment_context_with_network() { assert_eq!(context.render(), expected); } +#[test] +fn serialize_environment_context_with_selected_environments() { + let context = EnvironmentContext::new_with_environments( + Some(test_path_buf("/repo-primary")), + Some(vec![ + EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: test_path_buf("/repo-primary"), + primary: true, + }, + EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo-local"), + primary: false, + }, + ]), + fake_shell_name(), + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + /*network*/ None, + /*subagents*/ None, + ); + + let expected = format!( + r#" + {primary} + + + {primary} + + + {local} + + + bash + 2026-02-26 + America/Los_Angeles +"#, + primary = test_path_buf("/repo-primary").display(), + local = test_path_buf("/repo-local").display() + ); + + assert_eq!(context.render(), expected); +} + #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8bcd1..af7f620c28e2 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -129,6 +129,7 @@ fn reference_context_item() -> TurnContextItem { turn_id: Some("reference-turn".to_string()), trace_id: None, cwd: PathBuf::from("/tmp/reference-cwd"), + environments: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/core/src/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/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/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index af698e1eaaa3..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( - /*exec_server_url*/ None, - )), + 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 9717163df2db..1f62c2b08845 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -2,6 +2,8 @@ use std::collections::HashSet; 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; @@ -29,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), @@ -38,7 +45,9 @@ pub async fn build_prompt_input( .features .enabled(Feature::DefaultModeRequestUserInput), }, - Arc::new(EnvironmentManager::from_env()), + 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/handlers.rs b/codex-rs/core/src/session/handlers.rs index 71efd2332650..108bb1cfe923 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 { @@ -165,12 +166,15 @@ 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, ) } Op::UserInput { items, + environments, final_output_json_schema, responsesapi_client_metadata, } => ( @@ -180,11 +184,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; }; @@ -519,8 +527,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; @@ -1070,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 @@ -1094,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/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/mod.rs b/codex-rs/core/src/session/mod.rs index 4b919800cd93..ac75741fffb5 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,6 +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) analytics_events_client: Option, } @@ -455,15 +457,13 @@ 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); 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()); @@ -620,6 +620,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, @@ -650,7 +651,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, - environment, + environment_manager, analytics_events_client, ) .await @@ -1084,6 +1085,7 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { + environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), @@ -1255,11 +1257,19 @@ impl Session { .reconstruct_history_from_rollout(turn_context, rollout_items) .await; let previous_turn_settings = reconstructed_rollout.previous_turn_settings.clone(); + let environment_selections = reconstructed_rollout + .reference_context_item + .as_ref() + .and_then(|context_item| context_item.environments.clone()); self.replace_history( reconstructed_rollout.history, reconstructed_rollout.reference_context_item, ) .await; + if let Some(environment_selections) = environment_selections { + let mut state = self.state.lock().await; + state.session_configuration.environment_selections = Some(environment_selections); + } self.set_previous_turn_settings(previous_turn_settings.clone()) .await; previous_turn_settings diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 94de4617d5a4..62f4c9a87139 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) @@ -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/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 3dca7f0ce6d6..86a8f65bd965 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -64,6 +64,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -104,6 +105,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -899,6 +901,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -975,6 +978,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1005,6 +1009,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1118,6 +1123,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1229,6 +1235,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1379,6 +1386,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 16e86e3aeac8..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 { @@ -228,7 +236,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, - environment: Option>, + environment_manager: Arc, analytics_events_client: Option, ) -> anyhow::Result> { debug!( @@ -695,7 +703,7 @@ impl Session { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: environment.clone(), + environment_manager, }; services .model_client @@ -790,9 +798,10 @@ impl Session { tx_event.clone(), session_configuration.sandbox_policy.get().clone(), McpRuntimeEnvironment::new( - environment - .clone() - .unwrap_or_else(|| Arc::new(Environment::default())), + sess.services + .environment_manager + .default_environment() + .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 f1a11bfcb604..d1d11f155450 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(), @@ -1686,12 +1688,14 @@ 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?; forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -1729,10 +1733,15 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< async fn record_initial_history_forked_hydrates_previous_turn_settings() { let (session, turn_context) = make_session_and_context().await; let previous_model = "forked-rollout-model"; + let previous_environments = vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test_path_buf("/tmp/forked-local").abs(), + }]; let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), cwd: turn_context.cwd.to_path_buf(), + environments: Some(previous_environments.clone()), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1794,6 +1803,15 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { realtime_active: Some(turn_context.realtime_active), }) ); + assert_eq!( + session + .state + .lock() + .await + .session_configuration + .environment_selections, + Some(previous_environments) + ); assert_eq!(history.raw_items(), &[]); assert_eq!( serde_json::to_value(session.reference_context_item().await) @@ -2318,6 +2336,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, @@ -2423,6 +2442,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, @@ -2778,6 +2798,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, @@ -2855,8 +2876,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 @@ -3048,6 +3069,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, @@ -3082,11 +3104,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_for_tests()), /*analytics_events_client*/ None, ) .await; @@ -3152,6 +3170,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, @@ -3162,7 +3181,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(), @@ -3183,8 +3203,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) - .await + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) .expect("create environment"), ); @@ -3249,7 +3268,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: Some(Arc::clone(&environment)), + 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(), @@ -3284,6 +3303,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, @@ -3370,6 +3391,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, @@ -3405,11 +3427,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_for_tests()), /*analytics_events_client*/ None, ) .await?; @@ -3853,12 +3871,14 @@ fn op_kind_distinguishes_turn_ops() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, } .kind(), "override_turn_context" ); assert_eq!( Op::UserInput { + environments: None, items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -3877,6 +3897,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(), @@ -3903,6 +3924,296 @@ 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()); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve"); + assert_eq!(primary_environment.environment_id.as_deref(), Some("local")); + assert!(std::sync::Arc::ptr_eq( + &primary_environment.environment, + &turn_environments[0].environment + )); + assert_eq!(primary_environment.cwd, selected_cwd); + let tool_environment = turn_context + .environment_for_tool(Some("local")) + .expect("tool environment should resolve by id"); + assert_eq!(tool_environment.environment_id.as_deref(), Some("local")); + assert!(std::sync::Arc::ptr_eq( + &tool_environment.environment, + &turn_environments[0].environment + )); + let default_tool_environment = turn_context + .environment_for_tool(/*environment_id*/ None) + .expect("tool environment should default to primary"); + assert_eq!( + default_tool_environment.environment_id.as_deref(), + Some("local") + ); + assert_eq!( + turn_context + .resolve_path_for_environment(&primary_environment, Some("relative/path".to_string())), + selected_cwd.join("relative/path") + ); + + assert_eq!( + turn_context.to_turn_context_item().environments, + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd, + }]) + ); +} + +#[tokio::test] +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); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve"); + assert_eq!(primary_environment.cwd, first_cwd); + let tool_environment = turn_context + .environment_for_tool(Some("local")) + .expect("tool environment should resolve by id"); + assert_eq!(tool_environment.cwd, first_cwd); + + assert_eq!( + turn_context.to_turn_context_item().environments, + Some(vec![ + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: first_cwd, + }, + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: second_cwd, + }, + ]) + ); +} + +#[tokio::test] +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 + ); + assert!(turn_context.primary_environment().is_none()); + assert!( + turn_context + .environment_for_tool(/*environment_id*/ None) + .is_none() + ); + assert!(turn_context.environment_for_tool(Some("local")).is_none()); +} + +#[tokio::test] +async fn primary_environment_falls_back_to_compatibility_projection() { + let (_session, turn_context, _rx) = make_session_and_context_with_rx().await; + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve from compatibility fields"); + assert_eq!(primary_environment.environment_id.as_deref(), None); + assert!(std::sync::Arc::ptr_eq( + &primary_environment.environment, + turn_context + .environment + .as_ref() + .expect("compatibility environment should be set") + )); + assert_eq!(primary_environment.cwd, turn_context.cwd); + + let default_tool_environment = turn_context + .environment_for_tool(/*environment_id*/ None) + .expect("tool environment should resolve from compatibility fields"); + assert_eq!(default_tool_environment.environment_id.as_deref(), None); + assert!(std::sync::Arc::ptr_eq( + &default_tool_environment.environment, + &primary_environment.environment + )); + assert!(turn_context.environment_for_tool(Some("local")).is_none()); + assert_eq!( + turn_context + .resolve_path_for_environment(&primary_environment, Some("relative/path".to_string())), + turn_context.cwd.join("relative/path") + ); +} + +#[tokio::test] +async fn multi_environment_feature_without_selections_uses_single_environment_behavior() { + let (_session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("multi-environment tools feature should enable"); + }, + ) + .await; + + assert!( + turn_context + .features + .enabled(Feature::MultiEnvironmentTools) + ); + assert!(turn_context.environments.is_none()); + assert!(!turn_context.tools_config.multi_environment_tools); + assert_eq!(turn_context.to_turn_context_item().environments, None); + + let primary_environment = turn_context + .primary_environment() + .expect("primary environment should resolve from compatibility fields"); + assert_eq!(primary_environment.environment_id, None); + assert_eq!(primary_environment.cwd, turn_context.cwd); +} + +#[tokio::test] +async fn multi_environment_tools_enable_only_with_feature_and_selection() { + let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( + CodexAuth::from_api_key("Test API Key"), + Vec::new(), + |config| { + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("multi-environment tools feature should enable"); + }, + ) + .await; + let selected_cwd = + AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) + .expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd, + }]), + ) + .await + .expect("turn should start"); + + assert!(turn_context.tools_config.multi_environment_tools); +} + +#[tokio::test] +async fn unknown_turn_environment_selection_returns_error() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + 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!(matches!(err, CodexErr::InvalidRequest(_))); + assert!(err.to_string().contains("missing")); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { @@ -4255,6 +4566,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, @@ -4265,7 +4577,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(), @@ -4286,8 +4599,7 @@ where )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( - codex_exec_server::Environment::create(/*exec_server_url*/ None) - .await + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) .expect("create environment"), ); @@ -4352,7 +4664,7 @@ where code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Some(Arc::clone(&environment)), + 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(), @@ -4387,6 +4699,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/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 9b1172d5788a..06c189993c8a 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::new(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), skills_manager, plugins_manager, mcp_manager, @@ -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 dd86804ee5d6..6f15d9ba1fae 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!( @@ -9,6 +10,14 @@ pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManag ) } +fn multi_environment_tools_enabled( + features: &codex_features::Features, + environments: Option<&[TurnEnvironment]>, +) -> bool { + features.enabled(codex_features::Feature::MultiEnvironmentTools) + && environments.is_some_and(|environments| !environments.is_empty()) +} + #[derive(Clone, Debug)] pub(crate) struct TurnSkillsContext { pub(crate) outcome: Arc, @@ -24,6 +33,32 @@ 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, +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub(crate) struct SelectedTurnEnvironment { + pub(crate) environment_id: Option, + pub(crate) environment: Arc, + pub(crate) cwd: AbsolutePathBuf, +} + +impl TurnEnvironment { + fn selected(&self) -> SelectedTurnEnvironment { + SelectedTurnEnvironment { + environment_id: Some(self.environment_id.clone()), + environment: Arc::clone(&self.environment), + cwd: self.cwd.clone(), + } + } +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -39,6 +74,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()`. @@ -127,6 +163,8 @@ impl TurnContext { /*developer_instructions*/ None, ); let features = self.features.clone(); + let multi_environment_tools = + multi_environment_tools_enabled(&features, self.environments.as_deref()); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &models_manager @@ -145,6 +183,7 @@ impl TurnContext { .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) .with_has_environment(self.tools_config.has_environment) + .with_multi_environment_tools(multi_environment_tools) .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) @@ -168,6 +207,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(), @@ -205,14 +245,60 @@ impl TurnContext { .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } - pub(crate) fn file_system_sandbox_context( + #[allow(dead_code)] + pub(crate) fn primary_environment(&self) -> Option { + self.environments + .as_ref() + .and_then(|environments| environments.first()) + .map(TurnEnvironment::selected) + .or_else(|| { + self.environment + .as_ref() + .map(|environment| SelectedTurnEnvironment { + environment_id: None, + environment: Arc::clone(environment), + cwd: self.cwd.clone(), + }) + }) + } + + #[allow(dead_code)] + pub(crate) fn environment_for_tool( + &self, + environment_id: Option<&str>, + ) -> Option { + match environment_id { + Some(environment_id) => self + .environments + .as_ref()? + .iter() + .find(|environment| environment.environment_id == environment_id) + .map(TurnEnvironment::selected), + None => self.primary_environment(), + } + } + + #[allow(dead_code)] + pub(crate) fn resolve_path_for_environment( &self, + environment: &SelectedTurnEnvironment, + path: Option, + ) -> AbsolutePathBuf { + path.as_ref().map_or_else( + || environment.cwd.clone(), + |path| environment.cwd.join(path), + ) + } + + pub(crate) fn file_system_sandbox_context_for_cwd( + &self, + cwd: &AbsolutePathBuf, additional_permissions: Option, ) -> FileSystemSandboxContext { FileSystemSandboxContext { sandbox_policy: self.sandbox_policy.get().clone(), - sandbox_policy_cwd: Some(self.cwd.clone()), - file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), + sandbox_policy_cwd: Some(cwd.clone()), + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(cwd), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -223,15 +309,16 @@ impl TurnContext { } } - fn non_legacy_file_system_sandbox_policy(&self) -> Option { + fn non_legacy_file_system_sandbox_policy( + &self, + cwd: &AbsolutePathBuf, + ) -> Option { // Omit the derived split filesystem policy when it is equivalent to // the legacy sandbox policy. This keeps turn-context payloads stable // while both fields exist; once callers consume only the split policy, // this comparison and the legacy projection should go away. - let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( - self.sandbox_policy.get(), - &self.cwd, - ); + let legacy_file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(self.sandbox_policy.get(), cwd); (self.file_system_sandbox_policy != legacy_file_system_sandbox_policy) .then(|| self.file_system_sandbox_policy.clone()) } @@ -247,12 +334,21 @@ impl TurnContext { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), cwd: self.cwd.to_path_buf(), + environments: self.environments.as_ref().map(|environments| { + environments + .iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.clone(), + }) + .collect() + }), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), - file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), + file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(&self.cwd), model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), @@ -300,11 +396,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; @@ -346,6 +445,8 @@ impl Session { models_manager: &ModelsManager, network: Option, environment: Option>, + environments: Option>, + cwd: AbsolutePathBuf, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -361,6 +462,8 @@ impl Session { let session_source = session_configuration.session_source.clone(); let image_generation_tool_auth_allowed = image_generation_tool_auth_allowed(auth_manager.as_deref()); + let multi_environment_tools = + multi_environment_tools_enabled(&per_turn_config.features, environments.as_deref()); let auth_manager_for_context = auth_manager.clone(); let provider_for_context = create_model_provider(provider, auth_manager); let session_telemetry_for_context = session_telemetry; @@ -382,6 +485,7 @@ impl Session { .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_has_environment(environment.is_some()) + .with_multi_environment_tools(multi_environment_tools) .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) @@ -389,8 +493,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 +516,7 @@ impl Session { reasoning_summary, session_source, environment, + environments, cwd, current_date: Some(current_date), timezone: Some(timezone), @@ -450,7 +553,8 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, - ) -> ConstraintResult> { + environment_selections: Option>, + ) -> CodexResult> { let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -482,17 +586,35 @@ 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)); } }; + 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, @@ -511,17 +633,63 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + turn_environments, ) .await) } + fn resolve_turn_environments( + &self, + environment_selections: Option>, + ) -> CodexResult>> { + 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(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + environment_selection.environment_id + )) + })?; + 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); + // `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); @@ -544,9 +712,7 @@ 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 fs = environment .as_ref() .map(|environment| environment.get_filesystem()); let skills_outcome = Arc::new( @@ -576,7 +742,9 @@ impl Session { ) .then(|| started_proxy.proxy()) }), - self.services.environment.clone(), + environment, + turn_environments, + cwd, sub_id, Arc::clone(&self.js_repl), skills_outcome, @@ -620,6 +788,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*turn_environments*/ None, ) .await } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 5db38f7b72a0..94e17eb15723 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -17,7 +17,7 @@ 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; use codex_mcp::McpConnectionManager; @@ -64,5 +64,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: Option>, + /// 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, } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e4da99bb55a0..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>`. @@ -301,7 +311,7 @@ impl ThreadManager { auth, provider, codex_home.clone(), - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::default_for_tests()), ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager @@ -494,35 +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, - )) + 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, + 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, + options.dynamic_tools, + options.persist_extended_history, + options.metrics_service_name, + options.parent_trace, + options.environment_selections, /*user_shell_override*/ None, )) .await @@ -563,6 +572,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -582,6 +592,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 +615,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 +724,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -813,6 +826,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -840,6 +854,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -868,6 +883,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -885,6 +901,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 +916,7 @@ impl ThreadManagerState { /*inherited_shell_snapshot*/ None, /*inherited_exec_policy*/ None, parent_trace, + environment_selections, user_shell_override, )) .await @@ -918,13 +936,10 @@ 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 - .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 @@ -959,6 +974,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/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index fe2039e89bc4..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( - /*exec_server_url*/ None, - )), + 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( - /*exec_server_url*/ None, - )), + 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( - /*exec_server_url*/ None, - )), + 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( - /*exec_server_url*/ None, - )), + 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( - /*exec_server_url*/ None, - )), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 033315a69489..2de1a99e7ad8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -47,6 +47,7 @@ use codex_tools::ApplyPatchToolArgs; use codex_utils_absolute_path::AbsolutePathBuf; const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); +const APPLY_PATCH_ENVIRONMENT_PREFIX: &str = "*** Environment: "; pub struct ApplyPatchHandler; @@ -239,9 +240,48 @@ fn write_permissions_for_paths( normalize_additional_permissions(permissions).ok() } +fn split_freeform_apply_patch_environment( + input: String, +) -> Result<(String, Option), FunctionCallError> { + let Some(rest) = input.strip_prefix(APPLY_PATCH_ENVIRONMENT_PREFIX) else { + return Ok((input, None)); + }; + let Some((environment_id, patch_input)) = rest.split_once('\n') else { + return Err(FunctionCallError::RespondToModel( + "apply_patch environment header must be followed by a patch body".to_string(), + )); + }; + let environment_id = environment_id.trim(); + if environment_id.is_empty() { + return Err(FunctionCallError::RespondToModel( + "apply_patch environment header must include an environment id".to_string(), + )); + } + Ok((patch_input.to_string(), Some(environment_id.to_string()))) +} + +fn merge_apply_patch_environment_ids( + argument_environment_id: Option, + header_environment_id: Option, +) -> Result, FunctionCallError> { + match (argument_environment_id, header_environment_id) { + (Some(argument_environment_id), Some(header_environment_id)) + if argument_environment_id != header_environment_id => + { + Err(FunctionCallError::RespondToModel(format!( + "apply_patch environment id mismatch: argument requested `{argument_environment_id}` but patch header requested `{header_environment_id}`" + ))) + } + (Some(environment_id), Some(_)) | (Some(environment_id), None) => Ok(Some(environment_id)), + (None, Some(environment_id)) => Ok(Some(environment_id)), + (None, None) => Ok(None), + } +} + async fn effective_patch_permissions( session: &Session, turn: &TurnContext, + cwd: &AbsolutePathBuf, action: &ApplyPatchAction, ) -> ( Vec, @@ -261,7 +301,7 @@ async fn effective_patch_permissions( session, turn.cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, - write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, &turn.cwd), + write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), ) .await; @@ -305,12 +345,29 @@ impl ToolHandler for ApplyPatchHandler { .. } = invocation; - let patch_input = match payload { + let multi_environment_tools = turn.tools_config.multi_environment_tools; + let (patch_input, requested_environment_id) = match payload { ToolPayload::Function { arguments } => { let args: ApplyPatchToolArgs = parse_arguments(&arguments)?; - args.input + if multi_environment_tools { + let (patch_input, header_environment_id) = + split_freeform_apply_patch_environment(args.input)?; + let environment_id = merge_apply_patch_environment_ids( + args.environment_id, + header_environment_id, + )?; + (patch_input, environment_id) + } else { + (args.input, None) + } + } + ToolPayload::Custom { input } => { + if multi_environment_tools { + split_freeform_apply_patch_environment(input)? + } else { + (input, None) + } } - ToolPayload::Custom { input } => input, _ => { return Err(FunctionCallError::RespondToModel( "apply_patch handler received unsupported payload".to_string(), @@ -320,17 +377,22 @@ impl ToolHandler for ApplyPatchHandler { // Re-parse and verify the patch so we can compute changes and approval. // Avoid building temporary ExecParams/command vectors; derive directly from inputs. - let cwd = turn.cwd.clone(); + let tool_environment = turn + .environment_for_tool(requested_environment_id.as_deref()) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id.as_deref() { + Some(environment_id) => { + format!("environment `{environment_id}` is unavailable in this session") + } + None => "apply_patch is unavailable in this session".to_string(), + }) + })?; + let cwd = tool_environment.cwd.clone(); let command = vec!["apply_patch".to_string(), patch_input.clone()]; - let Some(environment) = turn.environment.as_ref() else { - return Err(FunctionCallError::RespondToModel( - "apply_patch is unavailable in this session".to_string(), - )); - }; - let fs = environment.get_filesystem(); - let sandbox = environment - .is_remote() - .then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + let fs = tool_environment.environment.get_filesystem(); + let sandbox = tool_environment.environment.is_remote().then(|| { + turn.file_system_sandbox_context_for_cwd(&cwd, /*additional_permissions*/ None) + }); match codex_apply_patch::maybe_parse_apply_patch_verified( &command, &cwd, @@ -340,10 +402,22 @@ impl ToolHandler for ApplyPatchHandler { .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { + let action_cwd = changes.cwd.clone(); let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; - match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) - .await + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &action_cwd, + &changes, + ) + .await; + match apply_patch::apply_patch( + turn.as_ref(), + &action_cwd, + &file_system_sandbox_policy, + changes, + ) + .await { InternalApplyPatchInvocation::Output(item) => { let content = item?; @@ -362,6 +436,9 @@ impl ToolHandler for ApplyPatchHandler { emitter.begin(event_ctx).await; let req = ApplyPatchRequest { + environment_id: tool_environment.environment_id.clone(), + environment: tool_environment.environment, + cwd: action_cwd, action: apply.action, file_paths, changes, @@ -432,15 +509,18 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn - .environment + let tool_environment = turn.environment_for_tool(/*environment_id*/ None); + let sandbox = tool_environment .as_ref() - .filter(|env| env.is_remote()) - .map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + .filter(|environment| environment.environment.is_remote()) + .map(|_| { + turn.file_system_sandbox_context_for_cwd(cwd, /*additional_permissions*/ None) + }); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref()) .await { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { + let action_cwd = changes.cwd.clone(); session .record_model_warning( format!( @@ -450,9 +530,15 @@ pub(crate) async fn intercept_apply_patch( ) .await; let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await; - match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) - .await + effective_patch_permissions(session.as_ref(), turn.as_ref(), &action_cwd, &changes) + .await; + match apply_patch::apply_patch( + turn.as_ref(), + &action_cwd, + &file_system_sandbox_policy, + changes, + ) + .await { InternalApplyPatchInvocation::Output(item) => { let content = item?; @@ -470,6 +556,18 @@ pub(crate) async fn intercept_apply_patch( emitter.begin(event_ctx).await; let req = ApplyPatchRequest { + environment_id: tool_environment + .as_ref() + .and_then(|environment| environment.environment_id.clone()), + environment: tool_environment + .as_ref() + .map(|environment| Arc::clone(&environment.environment)) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "apply_patch is unavailable in this session".to_string(), + ) + })?, + cwd: action_cwd, action: apply.action, file_paths: approval_keys, changes, diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index 39c8029e67d4..d159c0b7618d 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -108,6 +108,31 @@ fn diff_consumer_sends_next_update_after_buffer_interval() { ); } +#[test] +fn split_freeform_apply_patch_environment_extracts_environment_header() { + let input = + "*** Environment: local\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n" + .to_string(); + + let (patch, environment_id) = + split_freeform_apply_patch_environment(input).expect("header should parse"); + + assert_eq!(environment_id.as_deref(), Some("local")); + assert_eq!( + patch, + "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n" + ); +} + +#[test] +fn merge_apply_patch_environment_ids_rejects_mismatch() { + let err = + merge_apply_patch_environment_ids(Some("local".to_string()), Some("remote".to_string())) + .expect_err("mismatched environment ids should fail"); + + assert!(err.to_string().contains("environment id mismatch")); +} + #[tokio::test] async fn approval_keys_include_move_destination() { let tmp = TempDir::new().expect("tmp"); diff --git a/codex-rs/core/src/tools/handlers/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/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index fd04d42f2ed8..cb08851b7b89 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -64,6 +64,12 @@ pub(crate) struct ExecCommandArgs { prefix_rule: Option>, } +#[derive(Debug, Deserialize)] +struct ToolEnvironmentArgs { + #[serde(default, alias = "environmentId")] + environment_id: Option, +} + #[derive(Debug, Deserialize)] struct WriteStdinArgs { // The model is trained on `session_id`. @@ -181,21 +187,44 @@ impl ToolHandler for UnifiedExecHandler { } }; - let Some(environment) = turn.environment.as_ref() else { + let Some(default_environment) = turn.environment_for_tool(/*environment_id*/ None) else { return Err(FunctionCallError::RespondToModel( "unified exec is unavailable in this session".to_string(), )); }; - let fs = environment.get_filesystem(); let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); let response = match tool_name.name.as_str() { "exec_command" => { - let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?; + let selector: ToolEnvironmentArgs = parse_arguments(&arguments)?; + let requested_environment_id = if context.turn.tools_config.multi_environment_tools + { + selector.environment_id.as_deref() + } else { + None + }; + let should_intercept_apply_patch = requested_environment_id.is_none(); + let tool_environment = context + .turn + .environment_for_tool(requested_environment_id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id { + Some(environment_id) => { + format!( + "environment `{environment_id}` is unavailable in this session" + ) + } + None => "unified exec is unavailable in this session".to_string(), + }) + })?; + let fs = tool_environment.environment.get_filesystem(); + let cwd = resolve_workdir_base_path(&arguments, &tool_environment.cwd)?; let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; - let workdir = context.turn.resolve_path(args.workdir.clone()); + let workdir = context + .turn + .resolve_path_for_environment(&tool_environment, args.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), context.turn.as_ref(), @@ -259,7 +288,11 @@ impl ToolHandler for UnifiedExecHandler { let workdir = workdir.filter(|value| !value.is_empty()); - let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir))); + let workdir = workdir.map(|dir| { + context + .turn + .resolve_path_for_environment(&tool_environment, Some(dir)) + }); let cwd = workdir.clone().unwrap_or(cwd); let normalized_additional_permissions = match implicit_granted_permissions( sandbox_permissions, @@ -286,17 +319,18 @@ impl ToolHandler for UnifiedExecHandler { } }; - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - &tool_name.name, - ) - .await? + if should_intercept_apply_patch + && let Some(output) = intercept_apply_patch( + &command, + &cwd, + fs.as_ref(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + &tool_name.name, + ) + .await? { manager.release_process_id(process_id).await; return Ok(ExecCommandToolOutput { @@ -320,9 +354,11 @@ impl ToolHandler for UnifiedExecHandler { command, hook_command: args.cmd, process_id, + environment_id: tool_environment.environment_id.clone(), + environment: tool_environment.environment, yield_time_ms, max_output_tokens, - workdir, + workdir: Some(cwd.clone()), network: context.turn.network.clone(), tty, sandbox_permissions: effective_additional_permissions @@ -363,6 +399,7 @@ impl ToolHandler for UnifiedExecHandler { } } "write_stdin" => { + let _ = default_environment; let args: WriteStdinArgs = parse_arguments(&arguments)?; let response = manager .write_stdin(WriteStdinRequest { diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 8f3f69701f9c..b0c8355e9983 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -29,6 +29,8 @@ const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str = struct ViewImageArgs { path: String, detail: Option, + #[serde(default, alias = "environmentId")] + environment_id: Option, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -87,17 +89,31 @@ impl ToolHandler for ViewImageHandler { } }; - let abs_path = turn.resolve_path(Some(args.path)); - let Some(environment) = turn.environment.as_ref() else { - return Err(FunctionCallError::RespondToModel( - "view_image is unavailable in this session".to_string(), - )); + let requested_environment_id = if turn.tools_config.multi_environment_tools { + args.environment_id.as_deref() + } else { + None }; - let sandbox = environment - .is_remote() - .then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + let tool_environment = turn + .environment_for_tool(requested_environment_id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(match requested_environment_id { + Some(environment_id) => { + format!("environment `{environment_id}` is unavailable in this session") + } + None => "view_image is unavailable in this session".to_string(), + }) + })?; + let abs_path = turn.resolve_path_for_environment(&tool_environment, Some(args.path)); + let sandbox = tool_environment.environment.is_remote().then(|| { + turn.file_system_sandbox_context_for_cwd( + &tool_environment.cwd, + /*additional_permissions*/ None, + ) + }); - let metadata = environment + let metadata = tool_environment + .environment .get_filesystem() .get_metadata(&abs_path, sandbox.as_ref()) .await @@ -114,7 +130,8 @@ impl ToolHandler for ViewImageHandler { abs_path.display() ))); } - let file_bytes = environment + let file_bytes = tool_environment + .environment .get_filesystem() .read_file(&abs_path, sandbox.as_ref()) .await diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index c1b960bc8dbb..e57bd09d0c6d 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -200,6 +200,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + let sandbox_cwd = tool.sandbox_cwd(req, turn_ctx); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, @@ -207,7 +208,7 @@ impl ToolOrchestrator { network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, @@ -348,7 +349,7 @@ impl ToolOrchestrator { network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: managed_network_active, manager: &self.sandbox, - sandbox_cwd: &turn_ctx.cwd, + sandbox_cwd, codex_linux_sandbox_exe: None, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index aac8e6bc1340..f4de1cda44ef 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -16,6 +16,7 @@ use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::with_cached_approval; use codex_apply_patch::ApplyPatchAction; +use codex_exec_server::Environment; use codex_exec_server::FileSystemSandboxContext; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -35,10 +36,14 @@ use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::path::PathBuf; +use std::sync::Arc; use std::time::Instant; #[derive(Debug)] pub struct ApplyPatchRequest { + pub environment_id: Option, + pub environment: Arc, + pub cwd: AbsolutePathBuf, pub action: ApplyPatchAction, pub file_paths: Vec, pub changes: std::collections::HashMap, @@ -47,6 +52,12 @@ pub struct ApplyPatchRequest { pub permissions_preapproved: bool, } +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)] +pub struct ApplyPatchApprovalKey { + pub environment_id: Option, + pub path: AbsolutePathBuf, +} + #[derive(Default)] pub struct ApplyPatchRuntime; @@ -61,7 +72,7 @@ impl ApplyPatchRuntime { ) -> GuardianApprovalRequest { GuardianApprovalRequest::ApplyPatch { id: call_id.to_string(), - cwd: req.action.cwd.clone(), + cwd: req.cwd.clone(), files: req.file_paths.clone(), patch: req.action.patch.clone(), } @@ -121,10 +132,17 @@ impl Sandboxable for ApplyPatchRuntime { } impl Approvable for ApplyPatchRuntime { - type ApprovalKey = AbsolutePathBuf; + type ApprovalKey = ApplyPatchApprovalKey; fn approval_keys(&self, req: &ApplyPatchRequest) -> Vec { - req.file_paths.clone() + req.file_paths + .iter() + .cloned() + .map(|path| ApplyPatchApprovalKey { + environment_id: req.environment_id.clone(), + path, + }) + .collect() } fn start_approval_async<'a>( @@ -201,23 +219,28 @@ impl Approvable for ApplyPatchRuntime { } impl ToolRuntime for ApplyPatchRuntime { + fn sandbox_cwd<'a>( + &self, + req: &'a ApplyPatchRequest, + _turn_ctx: &'a crate::session::turn_context::TurnContext, + ) -> &'a AbsolutePathBuf { + &req.cwd + } + async fn run( &mut self, req: &ApplyPatchRequest, attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, ) -> Result { - let environment = ctx.turn.environment.as_ref().ok_or_else(|| { - ToolError::Rejected("apply_patch is unavailable in this session".to_string()) - })?; let started_at = Instant::now(); - let fs = environment.get_filesystem(); + let fs = req.environment.get_filesystem(); let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let result = codex_apply_patch::apply_patch( &req.action.patch, - &req.action.cwd, + &req.cwd, &mut stdout, &mut stderr, fs.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 7f3641c03c51..c8e77a0a2829 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -15,6 +15,11 @@ use codex_sandboxing::SandboxType; use core_test_support::PathBufExt; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::sync::Arc; + +fn local_environment() -> Arc { + Arc::new(Environment::create(/*exec_server_url*/ None).expect("local environment")) +} #[test] fn wants_no_sandbox_approval_granular_respects_sandbox_flag() { @@ -49,6 +54,9 @@ fn guardian_review_request_includes_patch_context() { let expected_cwd = action.cwd.clone(); let expected_patch = action.patch.clone(); let request = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: expected_cwd.clone(), action, file_paths: vec![path.clone()], changes: HashMap::from([( @@ -91,6 +99,9 @@ fn file_system_sandbox_context_uses_active_attempt() { )), }; let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), @@ -147,6 +158,9 @@ fn file_system_sandbox_context_omits_legacy_equivalent_policy() { .join("apply-patch-runtime-legacy-equivalent.txt") .abs(); let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), @@ -188,6 +202,9 @@ fn no_sandbox_attempt_has_no_file_system_context() { .join("apply-patch-runtime-none.txt") .abs(); let req = ApplyPatchRequest { + environment_id: None, + environment: local_environment(), + cwd: path.clone(), action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()), file_paths: vec![path.clone()], changes: HashMap::new(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index fdd434e5425d..c0f1da7aed32 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -34,6 +34,7 @@ use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -45,6 +46,7 @@ use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use std::sync::Arc; /// Request payload used by the unified-exec runtime after approvals and /// sandbox preferences have been resolved for the current turn. @@ -53,6 +55,8 @@ pub struct UnifiedExecRequest { pub command: Vec, pub hook_command: String, pub process_id: i32, + pub environment_id: Option, + pub environment: Arc, pub cwd: AbsolutePathBuf, pub env: HashMap, pub exec_server_env_config: Option, @@ -72,6 +76,7 @@ pub struct UnifiedExecRequest { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, + pub environment_id: Option, pub cwd: AbsolutePathBuf, pub tty: bool, pub sandbox_permissions: SandboxPermissions, @@ -111,6 +116,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { command: canonicalize_command_for_approval(&req.command), + environment_id: req.environment_id.clone(), cwd: req.cwd.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, @@ -198,6 +204,14 @@ impl Approvable for UnifiedExecRuntime<'_> { } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { + fn sandbox_cwd<'b>( + &self, + req: &'b UnifiedExecRequest, + _turn_ctx: &'b crate::session::turn_context::TurnContext, + ) -> &'b AbsolutePathBuf { + &req.cwd + } + fn network_approval_spec( &self, req: &UnifiedExecRequest, @@ -219,11 +233,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); - let environment_is_remote = ctx - .turn - .environment - .as_ref() - .is_some_and(|environment| environment.is_remote()); + let environment_is_remote = req.environment.is_remote(); let command = if environment_is_remote { base_command.to_vec() } else { @@ -267,12 +277,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .await? { Some(prepared) => { - let Some(environment) = ctx.turn.environment.as_ref() else { - return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; - if environment.is_remote() { + if req.environment.is_remote() { return Err(ToolError::Rejected( "unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(), )); @@ -284,7 +289,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &prepared.exec_request, req.tty, prepared.spawn_lifecycle, - environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { @@ -315,18 +320,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); - let Some(environment) = ctx.turn.environment.as_ref() else { - return Err(ToolError::Rejected( - "exec_command is unavailable in this session".to_string(), - )); - }; self.manager .open_session_with_exec_env( req.process_id, &exec_env, req.tty, Box::new(NoopSpawnLifecycle), - environment.as_ref(), + req.environment.as_ref(), ) .await .map_err(|err| match err { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 87bef4617c31..d6c17118e586 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -329,6 +329,10 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { None } + fn sandbox_cwd<'a>(&self, _req: &'a Req, turn_ctx: &'a TurnContext) -> &'a AbsolutePathBuf { + &turn_ctx.cwd + } + async fn run( &mut self, req: &Req, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 01c8914bc24c..06dd6fb48a71 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -27,6 +27,7 @@ use std::collections::HashSet; use std::sync::Arc; use std::sync::Weak; +use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -90,6 +91,8 @@ pub(crate) struct ExecCommandRequest { pub command: Vec, pub hook_command: String, pub process_id: i32, + pub environment_id: Option, + pub environment: Arc, pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, diff --git a/codex-rs/core/src/unified_exec/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/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5181359698df..d401946819b5 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -753,6 +753,8 @@ impl UnifiedExecProcessManager { command: request.command.clone(), hook_command: request.hook_command.clone(), process_id: request.process_id, + environment_id: request.environment_id.clone(), + environment: Arc::clone(&request.environment), cwd, env, exec_server_env_config: Some(exec_server_env_config), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5075f91620e5..f0ea1c01ffc5 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; @@ -76,7 +77,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).await?; + let environment = + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None)?; Ok(Self { environment, cwd, @@ -115,7 +117,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)).await?; + let environment = + codex_exec_server::Environment::create_for_tests(Some(websocket_url))?; let cwd = remote_aware_cwd_path(); environment .get_filesystem() @@ -204,6 +207,8 @@ pub struct TestCodexBuilder { workspace_setups: Vec>, home: Option>, user_shell_override: Option, + exec_server_url: Option, + environment_manager_config: Option, } impl TestCodexBuilder { @@ -255,6 +260,19 @@ 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_environment_manager_config( + mut self, + config: codex_exec_server::ConfiguredEnvironmentManagerArgs, + ) -> Self { + self.environment_manager_config = Some(config); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -350,9 +368,24 @@ 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 = match self.environment_manager_config.clone() { + Some(config) => Arc::new(codex_exec_server::EnvironmentManager::try_new(config)?), + None => { + let exec_server_url = self + .exec_server_url + .clone() + .or_else(|| test_env.exec_server_url().map(str::to_owned)); + Arc::new(codex_exec_server::EnvironmentManager::new( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, + }, + )) + } + }; let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); @@ -587,6 +620,7 @@ impl TestCodex { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Some(service_tier), + /*environments*/ None, ) .await } @@ -602,6 +636,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 } @@ -612,10 +662,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(), @@ -885,6 +937,8 @@ pub fn test_codex() -> TestCodexBuilder { workspace_setups: vec![], home: None, user_shell_override: None, + exec_server_url: None, + environment_manager_config: None, } } 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 2086367b21e7..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(), @@ -1103,9 +1107,7 @@ 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::default_for_tests()), /*analytics_events_client*/ None, ); let NewThread { thread: codex, .. } = thread_manager @@ -1115,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(), @@ -1152,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(), @@ -1238,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(), @@ -1299,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(), @@ -1356,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(), @@ -1396,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(), @@ -1451,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(), @@ -1503,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(), @@ -1543,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(), @@ -1584,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(), @@ -1638,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(), @@ -1694,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(), @@ -1757,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(), @@ -1810,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(), @@ -1867,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(), @@ -1904,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(), @@ -1950,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(), @@ -1995,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(), @@ -2045,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(), @@ -2337,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(), @@ -2506,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(), @@ -2581,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(), @@ -2594,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(), @@ -2677,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(), @@ -2786,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(), @@ -2873,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(), @@ -2935,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(), @@ -2949,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(), @@ -2963,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..610671561b0d 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(), @@ -134,11 +135,13 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -174,6 +177,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(), @@ -233,11 +237,13 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -286,11 +292,13 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu service_tier: None, collaboration_mode: Some(base_mode), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -357,11 +365,13 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -385,11 +395,13 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -442,11 +454,13 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -470,11 +484,13 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -529,11 +545,13 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang Some(default_text), )), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -560,11 +578,13 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang Some(plan_text), )), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -620,11 +640,13 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() Some(collab_text), )), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -651,11 +673,13 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() Some(collab_text), )), personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -714,12 +738,14 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { service_tier: None, collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), personality: None, + environments: None, }) .await?; initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -734,6 +760,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(), @@ -786,11 +813,13 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { }, }), personality: None, + environments: None, }) .await?; 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..f37e6f307e14 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(), @@ -3060,6 +3090,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("override turn context"); @@ -3067,6 +3098,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 +3194,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 +3220,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 +3317,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 +3331,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 +3403,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..98ef96b199df 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(), @@ -2077,11 +2110,13 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; } codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -2167,6 +2202,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(), @@ -2190,10 +2226,12 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2311,6 +2349,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 +2362,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 +2446,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 +2523,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 +2608,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 +2624,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 +2705,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..01a710526424 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -589,6 +589,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { }, }), personality: None, + environments: None, }) .await?; @@ -803,6 +804,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_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f7fe7cc32224..aba3ed3bfb9a 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -37,6 +37,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("submit override"); @@ -75,6 +76,7 @@ async fn override_turn_context_does_not_create_config_file() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .expect("submit override"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 139ee7a85624..793b0f5e7ed9 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(), @@ -153,11 +154,13 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch models".into(), text_elements: Vec::new(), @@ -218,6 +221,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(), @@ -250,11 +254,13 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch model and personality".into(), text_elements: Vec::new(), @@ -392,6 +398,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 +425,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 +534,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 +556,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 +668,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 +690,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 +804,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 +834,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 +992,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(), @@ -1032,11 +1047,13 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; 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..175979725a1a 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(), @@ -458,11 +465,13 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; 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/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 13ace1fe6df9..193accbccb6e 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -125,6 +125,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -167,6 +168,7 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -206,6 +208,7 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/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..8c7bc22e8ab6 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(), @@ -110,11 +112,13 @@ async fn permissions_message_added_on_override_change() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -159,6 +163,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 +176,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 +221,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(), @@ -238,11 +245,13 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -300,6 +309,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(), @@ -324,12 +334,14 @@ async fn resume_replays_permissions_messages() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -344,6 +356,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 +415,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(), @@ -426,12 +440,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -452,6 +468,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 +502,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 +556,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..944eba13af3f 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(), @@ -354,11 +359,13 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -426,6 +433,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(), @@ -459,11 +467,13 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -544,6 +554,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(), @@ -577,11 +588,13 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> service_tier: None, collaboration_mode: None, personality: Some(Personality::Pragmatic), + environments: None, }) .await?; test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -701,6 +714,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 +836,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(), @@ -855,11 +870,13 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - service_tier: None, collaboration_mode: None, personality: Some(Personality::Friendly), + environments: None, }) .await?; 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..7f2d090e30c4 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(), @@ -444,12 +451,14 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; // Second turn after overrides codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -528,11 +537,13 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -685,6 +696,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 +719,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 +833,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 +855,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 +961,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 +983,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..d54e4b8b6e70 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?; @@ -597,6 +602,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -636,6 +642,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -841,6 +848,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -861,6 +869,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..6b6a30fd30d5 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(), @@ -429,11 +435,13 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; resumed .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/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 8ea2be3147a3..2368b7c5e3e7 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -29,6 +29,7 @@ fn resume_history( turn_id: Some(turn_id.clone()), trace_id: None, cwd: config.cwd.to_path_buf(), + environments: None, current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f4da7c549b30..9baae7c48f97 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(), @@ -845,6 +846,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/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 1b2ac71643b2..015f4ef0f238 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; @@ -98,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(), @@ -234,7 +236,15 @@ 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::new( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, + }, + )), /*analytics_events_client*/ None, ); let new_thread = thread_manager.start_thread(config.clone()).await?; 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..2e42c57cc0ff 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -5,13 +5,17 @@ use std::sync::OnceLock; use anyhow::Context; use anyhow::Result; +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::RemoteExecServerTransport; use codex_features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::process::process_is_alive; @@ -166,6 +170,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 +255,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(), @@ -337,6 +343,108 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_environment_id_targets_non_primary_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + let codex_exe = codex_utils_cargo_bin::cargo_bin("codex") + .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex-exec"))?; + let mut builder = test_codex() + .with_environment_manager_config(ConfiguredEnvironmentManagerArgs { + default_environment: Some("local".to_string()), + environments: vec![ConfiguredEnvironmentSpec { + id: "remote".to_string(), + transport: RemoteExecServerTransport::Command { + command: format!("{codex_exe:?} exec-server --listen stdio://"), + }, + }], + local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new( + codex_exe, /*codex_linux_sandbox_exe*/ None, + )?, + }) + .with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow unified exec"); + config + .features + .enable(Feature::MultiEnvironmentTools) + .expect("test config should allow multi-environment tools"); + }); + let test = builder.build(&server).await?; + let selected_local_cwd = create_workspace_directory(&test, "selected-local").await?; + let primary_remote_cwd = test.config.cwd.join("primary-remote"); + tokio::fs::create_dir_all(&primary_remote_cwd).await?; + + let call_id = "uexec-env-local"; + let args = json!({ + "cmd": "pwd", + "environment_id": "local", + "yield_time_ms": 5_000, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + let request_log = mount_sse_sequence(&server, responses).await; + + test.submit_turn_with_environments( + "print the selected local cwd", + Some(vec![ + TurnEnvironmentSelection { + environment_id: "remote".to_string(), + cwd: primary_remote_cwd, + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_local_cwd.clone().try_into()?, + }, + ]), + ) + .await?; + + let bodies = request_log + .requests() + .into_iter() + .map(|request| request.body_json()) + .collect::>(); + let request_text = serde_json::to_string(&bodies)?; + assert!( + request_text.contains(""), + "environment list should be visible to the model: {request_text}" + ); + assert!( + request_text.contains("id=\\\"local\\\""), + "local environment id should be visible to the model: {request_text}" + ); + assert!( + request_text.contains("\"environment_id\""), + "exec_command schema should expose environment_id when gated: {request_text}" + ); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs + .get(call_id) + .expect("missing exec_command environment-id output"); + assert_eq!( + output.output.trim(), + selected_local_cwd.display().to_string() + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1740,6 +1848,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 +1942,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 +2415,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 +2525,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 +2654,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-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 4e282c8fd3fb..a79171f438eb 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -10,16 +10,21 @@ 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; +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; @@ -101,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 { @@ -161,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(); + } } } @@ -174,6 +193,50 @@ pub struct ExecServerClient { inner: Arc, } +#[derive(Clone)] +pub(crate) struct LazyRemoteExecServerClient { + transport: RemoteExecServerTransport, + client: Arc>, +} + +impl LazyRemoteExecServerClient { + pub(crate) fn new(transport: RemoteExecServerTransport) -> Self { + Self { + transport, + client: Arc::new(OnceCell::new()), + } + } + + pub(crate) async fn get(&self) -> Result { + self.client + .get_or_try_init(|| async { + 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() + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] @@ -223,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 } @@ -378,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| { @@ -423,6 +520,7 @@ impl ExecServerClient { http_body_stream_next_id: AtomicU64::new(1), session_id: std::sync::RwLock::new(None), reader_task, + child, } }); @@ -917,6 +1015,7 @@ mod tests { "test-exec-server-client".to_string(), ), ExecServerClientConnectOptions::default(), + /*child*/ None, ) .await .expect("client should connect"); @@ -1060,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 afe072019600..922c65f89f2d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,11 +1,10 @@ +use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::OnceCell; - -use crate::ExecServerClient; use crate::ExecServerError; use crate::ExecServerRuntimePaths; -use crate::RemoteExecServerConnectArgs; +use crate::RemoteExecServerTransport; +use crate::client::LazyRemoteExecServerClient; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; @@ -15,130 +14,253 @@ 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 the Codex runtime. +/// +/// `EnvironmentManager` is a shared registry for concrete environments. It +/// 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. /// -/// The manager keeps the session's environment selection stable so subagents -/// and follow-up turns preserve an explicit disabled state. +/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving +/// 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 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 { - exec_server_url: Option, - local_runtime_paths: Option, - disabled: bool, - current_environment: OnceCell>>, + default_environment: Option, + environments: HashMap>, +} + +pub const LOCAL_ENVIRONMENT_ID: &str = "local"; +pub const REMOTE_ENVIRONMENT_ID: &str = "remote"; + +#[derive(Clone, Debug)] +pub struct EnvironmentManagerArgs { + pub exec_server_url: Option, + pub local_runtime_paths: ExecServerRuntimePaths, +} + +impl EnvironmentManagerArgs { + pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self { + Self { + exec_server_url: None, + local_runtime_paths, + } + } + + pub fn from_env(local_runtime_paths: ExecServerRuntimePaths) -> Self { + Self { + exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + local_runtime_paths, + } + } +} + +#[derive(Clone, Debug)] +pub struct ConfiguredEnvironmentManagerArgs { + pub default_environment: Option, + pub environments: Vec, + pub local_runtime_paths: ExecServerRuntimePaths, +} + +#[derive(Clone, Debug)] +pub struct ConfiguredEnvironmentSpec { + pub id: String, + pub transport: RemoteExecServerTransport, +} + +#[derive(Clone, Debug)] +pub enum EnvironmentManagerConfig { + ExecServerUrl(EnvironmentManagerArgs), + Configured(ConfiguredEnvironmentManagerArgs), +} + +impl From for EnvironmentManagerConfig { + fn from(args: EnvironmentManagerArgs) -> Self { + Self::ExecServerUrl(args) + } } -impl Default for EnvironmentManager { - fn default() -> Self { - Self::new(/*exec_server_url*/ None) +impl From for EnvironmentManagerConfig { + fn from(args: ConfiguredEnvironmentManagerArgs) -> Self { + Self::Configured(args) } } 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) + /// Builds a test-only manager without configured sandbox helper paths. + pub fn default_for_tests() -> Self { + 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_with_runtime_paths( - exec_server_url: Option, - local_runtime_paths: Option, - ) -> Self { - let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url); - Self { + pub fn new(config: impl Into) -> Self { + match config.into() { + EnvironmentManagerConfig::ExecServerUrl(args) => Self::from_exec_server_url_args(args), + EnvironmentManagerConfig::Configured(args) => Self::from_configured_args(args) + .unwrap_or_else(|err| panic!("invalid environment manager config: {err}")), + } + } + + fn from_exec_server_url_args(args: EnvironmentManagerArgs) -> Self { + let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, - disabled, - current_environment: OnceCell::new(), + } = args; + 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(local_runtime_paths.clone())), + )]); + let default_environment = if environment_disabled { + None + } else { + match exec_server_url { + Some(exec_server_url) => { + environments.insert( + REMOTE_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::remote_with_runtime_paths( + RemoteExecServerTransport::WebSocket { + url: exec_server_url, + }, + Some(local_runtime_paths), + )), + ); + Some(REMOTE_ENVIRONMENT_ID.to_string()) + } + None => Some(LOCAL_ENVIRONMENT_ID.to_string()), + } + }; + + Self { + default_environment, + environments, } } - /// Builds a manager from process environment variables. - pub fn from_env() -> Self { - Self::from_env_with_runtime_paths(/*local_runtime_paths*/ None) + pub fn try_new(config: impl Into) -> Result { + match config.into() { + EnvironmentManagerConfig::ExecServerUrl(args) => { + Ok(Self::from_exec_server_url_args(args)) + } + EnvironmentManagerConfig::Configured(args) => Self::from_configured_args(args), + } } - /// 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_with_runtime_paths( - std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(), + fn from_configured_args( + args: ConfiguredEnvironmentManagerArgs, + ) -> Result { + let ConfiguredEnvironmentManagerArgs { + default_environment, + environments: configured_environments, local_runtime_paths, - ) + } = args; + let mut environments = HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::local(local_runtime_paths.clone())), + )]); + + for environment in configured_environments { + let id = environment.id.trim(); + if id.is_empty() { + return Err(ExecServerError::Protocol( + "environment id must not be empty".to_string(), + )); + } + if id == LOCAL_ENVIRONMENT_ID || id.eq_ignore_ascii_case("none") { + return Err(ExecServerError::Protocol(format!( + "environment id `{id}` is reserved" + ))); + } + if environments.contains_key(id) { + return Err(ExecServerError::Protocol(format!( + "duplicate environment id `{id}`" + ))); + } + environments.insert( + id.to_string(), + Arc::new(Environment::remote_with_runtime_paths( + environment.transport, + Some(local_runtime_paths.clone()), + )), + ); + } + + let default_environment = match default_environment.as_deref().map(str::trim) { + None | Some("") => Some(LOCAL_ENVIRONMENT_ID.to_string()), + Some(default_environment) if default_environment.eq_ignore_ascii_case("none") => None, + Some(default_environment) => { + if !environments.contains_key(default_environment) { + return Err(ExecServerError::Protocol(format!( + "default_environment `{default_environment}` is not a configured environment id" + ))); + } + Some(default_environment.to_string()) + } + }; + + Ok(Self { + default_environment, + environments, + }) } - /// 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(), - }, - } + /// 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 remote exec-server URL when one is configured. - pub fn exec_server_url(&self) -> Option<&str> { - self.exec_server_url.as_deref() + /// Returns the local environment instance used for internal runtime work. + pub fn local_environment(&self) -> Arc { + match self.get_environment(LOCAL_ENVIRONMENT_ID) { + Some(environment) => environment, + None => unreachable!("EnvironmentManager always has a local environment"), + } } - /// 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 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 a named environment instance. + pub fn get_environment(&self, environment_id: &str) -> Option> { + self.environments.get(environment_id).cloned() } } /// 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, + remote_transport: Option, exec_backend: Arc, + filesystem: Arc, 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, - remote_exec_server_client: None, + remote_transport: None, exec_backend: Arc::new(LocalProcess::default()), + filesystem: Arc::new(LocalFileSystem::unsandboxed()), local_runtime_paths: None, } } @@ -147,20 +269,28 @@ impl Default for 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() } } 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, + 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 async fn create_with_runtime_paths( + fn create_inner( exec_server_url: Option, local_runtime_paths: Option, ) -> Result { @@ -171,43 +301,64 @@ 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?, - ) - } else { - None - }; + Ok(match exec_server_url { + 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(), + }, + }) + } + + fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { + Self { + remote_transport: None, + exec_backend: Arc::new(LocalProcess::default()), + filesystem: Arc::new(LocalFileSystem::with_runtime_paths( + local_runtime_paths.clone(), + )), + local_runtime_paths: Some(local_runtime_paths), + } + } - let exec_backend: Arc = - if let Some(client) = remote_exec_server_client.clone() { - Arc::new(RemoteProcess::new(client)) - } else { - Arc::new(LocalProcess::default()) - }; + fn remote_with_runtime_paths( + transport: RemoteExecServerTransport, + local_runtime_paths: Option, + ) -> Self { + let client = LazyRemoteExecServerClient::new(transport.clone()); + let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); + let filesystem: Arc = Arc::new(RemoteFileSystem::new(client)); - Ok(Self { - exec_server_url, - remote_exec_server_client, + Self { + remote_transport: Some(transport), exec_backend, + filesystem, local_runtime_paths, - }) + } } 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> { @@ -219,13 +370,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) } } @@ -242,100 +387,162 @@ 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; 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) - .await + let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) .expect("create environment"); assert_eq!(environment.exec_server_url(), None); - assert!(environment.remote_exec_server_client.is_none()); + assert!(!environment.is_remote()); } - #[test] - fn environment_manager_normalizes_empty_url() { - let manager = EnvironmentManager::new(Some(String::new())); - - assert!(!manager.disabled); - assert_eq!(manager.exec_server_url(), None); - assert!(!manager.is_remote()); + #[tokio::test] + async fn environment_manager_normalizes_empty_url() { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: Some(String::new()), + local_runtime_paths: test_runtime_paths(), + }); + + let environment = manager.default_environment().expect("default environment"); + assert!(!environment.is_remote()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); + 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())); + #[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: test_runtime_paths(), + }); - assert!(manager.disabled); - assert_eq!(manager.exec_server_url(), None); - assert!(!manager.is_remote()); + assert!(manager.default_environment().is_none()); + assert!( + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() + ); + 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())); - - assert!(manager.is_remote()); - assert_eq!(manager.exec_server_url(), Some("ws://127.0.0.1:8765")); + #[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: test_runtime_paths(), + }); + + 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() + ); } #[tokio::test] - async fn environment_manager_current_caches_environment() { - let manager = EnvironmentManager::new(/*exec_server_url*/ None); + async fn environment_manager_default_environment_caches_environment() { + let manager = EnvironmentManager::default_for_tests(); - let first = manager.current().await.expect("get current environment"); - let second = manager.current().await.expect("get current environment"); - - let first = first.expect("local environment"); - let second = second.expect("local environment"); + let first = manager.default_environment().expect("default environment"); + 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] 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 manager = EnvironmentManager::new_with_runtime_paths( - /*exec_server_url*/ None, - Some(runtime_paths.clone()), - ); + let runtime_paths = test_runtime_paths(); + let manager = EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: None, + local_runtime_paths: runtime_paths.clone(), + }); - let environment = manager - .current() - .await - .expect("get current environment") - .expect("local environment"); + let environment = manager.default_environment().expect("default 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::new(EnvironmentManagerArgs { + exec_server_url: environment.exec_server_url().map(str::to_owned), + 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)); } #[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_no_default_environment() { + let manager = EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: test_runtime_paths(), + }); + assert!(manager.default_environment().is_none()); + } + + #[tokio::test] + 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: test_runtime_paths(), + }); + + assert!(manager.default_environment().is_none()); assert!( - manager - .current() - .await - .expect("get current environment") - .is_none() + !manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + .is_remote() ); + assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); + } + + #[tokio::test] + async fn get_environment_returns_none_for_unknown_id() { + 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() @@ -354,4 +561,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/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 067fa0a7c147..4fde4de4f755 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -20,11 +20,17 @@ mod server; pub use client::ExecServerClient; pub use client::ExecServerError; +pub use client_api::CommandExecServerConnectArgs; pub use client_api::ExecServerClientConnectOptions; pub use client_api::RemoteExecServerConnectArgs; +pub use client_api::RemoteExecServerTransport; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; +pub use environment::ConfiguredEnvironmentManagerArgs; +pub use environment::ConfiguredEnvironmentSpec; pub use environment::Environment; pub use environment::EnvironmentManager; +pub use environment::EnvironmentManagerArgs; +pub use environment::EnvironmentManagerConfig; pub use file_system::CopyOptions; pub use file_system::CreateDirectoryOptions; pub use file_system::ExecutorFileSystem; diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index d6a32ba4d532..dc269505a1d4 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::client::LazyRemoteExecServerClient; use crate::protocol::FsCopyParams; use crate::protocol::FsCreateDirectoryParams; use crate::protocol::FsGetMetadataParams; @@ -28,11 +28,11 @@ 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 } } @@ -46,8 +46,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_file"); - let response = self - .client + let client = self.client.get().await.map_err(map_remote_error)?; + let response = client .fs_read_file(FsReadFileParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -69,7 +69,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs write_file"); - self.client + let client = self.client.get().await.map_err(map_remote_error)?; + client .fs_write_file(FsWriteFileParams { path: path.clone(), data_base64: STANDARD.encode(contents), @@ -87,7 +88,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs create_directory"); - self.client + let client = self.client.get().await.map_err(map_remote_error)?; + client .fs_create_directory(FsCreateDirectoryParams { path: path.clone(), recursive: Some(options.recursive), @@ -104,8 +106,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult { trace!("remote fs get_metadata"); - let response = self - .client + let client = self.client.get().await.map_err(map_remote_error)?; + let response = client .fs_get_metadata(FsGetMetadataParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -127,8 +129,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult> { trace!("remote fs read_directory"); - let response = self - .client + let client = self.client.get().await.map_err(map_remote_error)?; + let response = client .fs_read_directory(FsReadDirectoryParams { path: path.clone(), sandbox: sandbox.cloned(), @@ -153,7 +155,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs remove"); - self.client + let client = self.client.get().await.map_err(map_remote_error)?; + client .fs_remove(FsRemoveParams { path: path.clone(), recursive: Some(options.recursive), @@ -173,7 +176,8 @@ impl ExecutorFileSystem for RemoteFileSystem { sandbox: Option<&FileSystemSandboxContext>, ) -> FileSystemResult<()> { trace!("remote fs copy"); - self.client + let client = self.client.get().await.map_err(map_remote_error)?; + 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..d8d06735cdb9 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -9,7 +9,7 @@ use crate::ExecProcess; use crate::ExecProcessEventReceiver; use crate::ExecServerError; use crate::StartedExecProcess; -use crate::client::ExecServerClient; +use crate::client::LazyRemoteExecServerClient; use crate::client::Session; use crate::protocol::ExecParams; use crate::protocol::ReadResponse; @@ -17,7 +17,7 @@ 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/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..35eb73ca84c5 --- /dev/null +++ b/codex-rs/exec-server/tests/command_transport.rs @@ -0,0 +1,50 @@ +mod common; + +use codex_exec_server::ConfiguredEnvironmentManagerArgs; +use codex_exec_server::ConfiguredEnvironmentSpec; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::RemoteExecServerTransport; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn configured_command_environment_connects_lazily_over_stdio() -> anyhow::Result<()> { + let helper_paths = common::exec_server::test_codex_helper_paths()?; + let temp_dir = tempfile::tempdir()?; + let target_path = temp_dir.path().join("target.txt"); + let marker_path = temp_dir.path().join("spawned.txt"); + tokio::fs::write(&target_path, "ok").await?; + + let manager = EnvironmentManager::try_new(ConfiguredEnvironmentManagerArgs { + default_environment: Some("remote".to_string()), + environments: vec![ConfiguredEnvironmentSpec { + id: "remote".to_string(), + transport: RemoteExecServerTransport::Command { + command: format!( + "echo spawned > {marker_path:?}; exec {codex_exe:?} exec-server --listen stdio://", + marker_path = marker_path, + codex_exe = helper_paths.codex_exe, + ), + }, + }], + local_runtime_paths: None, + })?; + let environment = manager.default_environment().expect("default environment"); + + assert!( + tokio::fs::metadata(&marker_path).await.is_err(), + "command transport should not connect before the first remote operation" + ); + + let metadata = environment + .get_filesystem() + .get_metadata( + &AbsolutePathBuf::from_absolute_path(&target_path)?, + /*sandbox*/ None, + ) + .await?; + + assert_eq!(metadata.is_file, true); + assert_eq!(tokio::fs::read_to_string(&marker_path).await?, "spawned\n"); + Ok(()) +} diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index d449315c8d6e..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())).await?; + 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).await?; + 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 d4f94c7e44c1..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())).await?; + 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())).await?; + 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 781e423fde45..d5cd4769a4e7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,6 +15,7 @@ pub use cli::Command; pub use cli::ReviewArgs; 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 +498,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, - environment_manager: std::sync::Arc::new(EnvironmentManager::from_env_with_runtime_paths( - Some(local_runtime_paths), - )), + environment_manager: std::sync::Arc::new(EnvironmentManager::try_new( + config.environment_manager_args( + EnvironmentManagerArgs::from_env(local_runtime_paths.clone()).exec_server_url, + local_runtime_paths, + )?, + )?), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, @@ -744,6 +748,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/features/src/lib.rs b/codex-rs/features/src/lib.rs index 046063a554e4..7d836406af05 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -91,6 +91,8 @@ pub enum Feature { ShellZshFork, /// Include the freeform apply_patch tool. ApplyPatchFreeform, + /// Expose selected turn environments to the model and allow tools to target them by id. + MultiEnvironmentTools, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -728,6 +730,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::MultiEnvironmentTools, + key: "multi_environment_tools", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ApplyPatchStreamingEvents, key: "apply_patch_streaming_events", diff --git a/codex-rs/mcp-server/src/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/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 1320fd1b67e2..cca68d28bed8 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; 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 +60,10 @@ 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( - 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| { @@ -78,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_manager = Arc::new( + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); set_default_client_residency_requirement(config.enforce_residency.value()); let otel = codex_core::otel_init::build_provider( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b9a7a1395f52..37504a8781e9 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, TS)] +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 @@ -554,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 @@ -715,6 +732,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, @@ -2914,6 +2932,8 @@ pub struct TurnContextItem { pub trace_id: Option, pub cwd: PathBuf, #[serde(default, skip_serializing_if = "Option::is_none")] + pub environments: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, @@ -4904,6 +4924,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 +4943,7 @@ mod tests { assert_eq!( op, Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4942,6 +4964,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 +4986,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([( @@ -5060,17 +5084,64 @@ mod tests { }))?; assert_eq!(item.trace_id, None); + assert_eq!(item.environments, None); assert_eq!(item.network, None); assert_eq!(item.file_system_sandbox_policy, None); Ok(()) } + #[test] + fn turn_context_item_serializes_environments_when_present() -> Result<()> { + let item = TurnContextItem { + turn_id: None, + trace_id: None, + cwd: test_path_buf("/tmp/primary"), + environments: Some(vec![ + TurnEnvironmentSelection { + environment_id: "remote".to_string(), + cwd: test_path_buf("/tmp/primary").abs(), + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test_path_buf("/tmp/local").abs(), + }, + ]), + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: ReasoningSummaryConfig::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }; + + let value = serde_json::to_value(item)?; + assert_eq!( + value["environments"], + json!([ + { "environment_id": "remote", "cwd": "/tmp/primary" }, + { "environment_id": "local", "cwd": "/tmp/local" }, + ]) + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { turn_id: None, trace_id: None, cwd: test_path_buf("/tmp"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index fa789d3a7e90..01cda8f20abb 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -543,6 +543,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re turn_id: Some("turn-1".to_string()), trace_id: None, cwd: latest_cwd.clone(), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 64d7f5bbb71a..586101d89766 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -299,6 +299,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/parent/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, @@ -338,6 +339,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, @@ -371,6 +373,7 @@ mod tests { turn_id: Some("turn-1".to_string()), trace_id: None, cwd: PathBuf::from("/fallback/workspace"), + environments: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/tools/BUILD.bazel b/codex-rs/tools/BUILD.bazel index 7b1541e4e84b..d37bbcfde087 100644 --- a/codex-rs/tools/BUILD.bazel +++ b/codex-rs/tools/BUILD.bazel @@ -5,5 +5,6 @@ codex_rust_crate( crate_name = "codex_tools", compile_data = [ "src/tool_apply_patch.lark", + "src/tool_apply_patch_environment.lark", ], ) diff --git a/codex-rs/tools/src/apply_patch_tool.rs b/codex-rs/tools/src/apply_patch_tool.rs index 469bb5236769..3e5bc12a3c30 100644 --- a/codex-rs/tools/src/apply_patch_tool.rs +++ b/codex-rs/tools/src/apply_patch_tool.rs @@ -8,6 +8,13 @@ use serde::Serialize; use std::collections::BTreeMap; const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark"); +const APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR: &str = + include_str!("tool_apply_patch_environment.lark"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApplyPatchToolOptions { + pub multi_environment_tools: bool, +} const APPLY_PATCH_JSON_TOOL_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: @@ -82,30 +89,50 @@ It is important to remember: #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApplyPatchToolArgs { pub input: String, + #[serde(default, alias = "environmentId")] + pub environment_id: Option, } /// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models /// https://platform.openai.com/docs/guides/function-calling#custom-tools -pub fn create_apply_patch_freeform_tool() -> ToolSpec { +pub fn create_apply_patch_freeform_tool(options: ApplyPatchToolOptions) -> ToolSpec { + let description = if options.multi_environment_tools { + "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. To target a non-primary selected environment, add a first line `*** Environment: ` before `*** Begin Patch`." + } else { + "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON." + }; + let definition = if options.multi_environment_tools { + APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR + } else { + APPLY_PATCH_LARK_GRAMMAR + }; ToolSpec::Freeform(FreeformTool { name: "apply_patch".to_string(), - description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(), + description: description.to_string(), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), - definition: APPLY_PATCH_LARK_GRAMMAR.to_string(), + definition: definition.to_string(), }, }) } /// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models -pub fn create_apply_patch_json_tool() -> ToolSpec { - let properties = BTreeMap::from([( +pub fn create_apply_patch_json_tool(options: ApplyPatchToolOptions) -> ToolSpec { + let mut properties = BTreeMap::from([( "input".to_string(), JsonSchema::string(Some( "The entire contents of the apply_patch command".to_string(), )), )]); + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to apply the patch in; omit to use the primary environment.".to_string(), + )), + ); + } ToolSpec::Function(ResponsesApiTool { name: "apply_patch".to_string(), diff --git a/codex-rs/tools/src/apply_patch_tool_tests.rs b/codex-rs/tools/src/apply_patch_tool_tests.rs index c128594587a3..9c623b9c23ce 100644 --- a/codex-rs/tools/src/apply_patch_tool_tests.rs +++ b/codex-rs/tools/src/apply_patch_tool_tests.rs @@ -6,7 +6,9 @@ use std::collections::BTreeMap; #[test] fn create_apply_patch_freeform_tool_matches_expected_spec() { assert_eq!( - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::Freeform(FreeformTool { name: "apply_patch".to_string(), description: @@ -24,7 +26,9 @@ fn create_apply_patch_freeform_tool_matches_expected_spec() { #[test] fn create_apply_patch_json_tool_matches_expected_spec() { assert_eq!( - create_apply_patch_json_tool(), + create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::Function(ResponsesApiTool { name: "apply_patch".to_string(), description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(), @@ -44,3 +48,31 @@ fn create_apply_patch_json_tool_matches_expected_spec() { }) ); } + +#[test] +fn create_apply_patch_freeform_tool_with_environment_matches_expected_spec() { + let ToolSpec::Freeform(tool) = create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: true, + }) else { + panic!("apply_patch should be a freeform tool"); + }; + assert!( + tool.description + .contains("*** Environment: ") + ); + assert_eq!(tool.format.definition, APPLY_PATCH_ENVIRONMENT_LARK_GRAMMAR); +} + +#[test] +fn create_apply_patch_json_tool_with_environment_includes_environment_id() { + let ToolSpec::Function(tool) = create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: true, + }) else { + panic!("apply_patch should be a function tool"); + }; + let properties = tool + .parameters + .properties + .expect("apply_patch parameters should include properties"); + assert!(properties.contains_key("environment_id")); +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2dc7c165d52c..83789a8092a2 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -41,6 +41,7 @@ pub use agent_tool::create_spawn_agent_tool_v2; pub use agent_tool::create_wait_agent_tool_v1; pub use agent_tool::create_wait_agent_tool_v2; pub use apply_patch_tool::ApplyPatchToolArgs; +pub use apply_patch_tool::ApplyPatchToolOptions; pub use apply_patch_tool::create_apply_patch_freeform_tool; pub use apply_patch_tool::create_apply_patch_json_tool; pub use code_mode::augment_tool_spec_for_code_mode; diff --git a/codex-rs/tools/src/local_tool.rs b/codex-rs/tools/src/local_tool.rs index 3e369ab1e331..8148ad60ddb3 100644 --- a/codex-rs/tools/src/local_tool.rs +++ b/codex-rs/tools/src/local_tool.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; pub struct CommandToolOptions { pub allow_login_shell: bool, pub exec_permission_approvals_enabled: bool, + pub multi_environment_tools: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,6 +64,14 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { )), ); } + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to run the command in; omit to use the primary environment.".to_string(), + )), + ); + } properties.extend(create_approval_parameters( options.exec_permission_approvals_enabled, )); diff --git a/codex-rs/tools/src/local_tool_tests.rs b/codex-rs/tools/src/local_tool_tests.rs index b751545b3ace..a14cfd1d6289 100644 --- a/codex-rs/tools/src/local_tool_tests.rs +++ b/codex-rs/tools/src/local_tool_tests.rs @@ -96,6 +96,7 @@ fn exec_command_tool_matches_expected_spec() { let tool = create_exec_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }); let description = if cfg!(windows) { @@ -332,6 +333,7 @@ fn shell_command_tool_matches_expected_spec() { let tool = create_shell_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }); let description = if cfg!(windows) { diff --git a/codex-rs/tools/src/tool_apply_patch_environment.lark b/codex-rs/tools/src/tool_apply_patch_environment.lark new file mode 100644 index 000000000000..709e6b6ce9e9 --- /dev/null +++ b/codex-rs/tools/src/tool_apply_patch_environment.lark @@ -0,0 +1,21 @@ +start: environment? begin_patch hunk+ end_patch +environment: "*** Environment: " environment_id LF +environment_id: /[A-Za-z0-9_.:-]+/ +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? + +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change_move? change? + +filename: /(.+)/ +add_line: "+" /(.*)/ LF -> line + +change_move: "*** Move to: " filename LF +change: (change_context | change_line)+ eof_line? +change_context: ("@@" | "@@ " /(.+)/) LF +change_line: ("+" | "-" | " ") /(.*)/ LF +eof_line: "*** End of File" LF + +%import common.LF diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 20c812ce9629..28ca10e34f9d 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -97,6 +97,7 @@ pub struct ToolsConfig { pub tool_suggest: bool, pub exec_permission_approvals_enabled: bool, pub request_permissions_tool_enabled: bool, + pub multi_environment_tools: bool, pub code_mode_enabled: bool, pub code_mode_only_enabled: bool, pub js_repl_enabled: bool, @@ -161,6 +162,7 @@ impl ToolsConfig { && supports_image_generation(model_info); let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); + let multi_environment_tools = features.enabled(Feature::MultiEnvironmentTools); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -218,6 +220,7 @@ impl ToolsConfig { tool_suggest: include_tool_suggest, exec_permission_approvals_enabled, request_permissions_tool_enabled, + multi_environment_tools, code_mode_enabled: include_code_mode, code_mode_only_enabled: include_code_mode_only, js_repl_enabled: include_js_repl, @@ -269,6 +272,11 @@ impl ToolsConfig { self } + pub fn with_multi_environment_tools(mut self, multi_environment_tools: bool) -> Self { + self.multi_environment_tools = multi_environment_tools; + self + } + pub fn with_unified_exec_shell_mode( mut self, unified_exec_shell_mode: UnifiedExecShellMode, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index d3b075f5abc3..5ffa91896aa4 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -1,3 +1,4 @@ +use crate::ApplyPatchToolOptions; use crate::CommandToolOptions; use crate::REQUEST_USER_INPUT_TOOL_NAME; use crate::ResponsesApiNamespace; @@ -157,6 +158,7 @@ pub fn build_tool_registry_plan( create_exec_command_tool(CommandToolOptions { allow_login_shell: config.allow_login_shell, exec_permission_approvals_enabled, + multi_environment_tools: config.multi_environment_tools, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, @@ -175,6 +177,7 @@ pub fn build_tool_registry_plan( create_shell_command_tool(CommandToolOptions { allow_login_shell: config.allow_login_shell, exec_permission_approvals_enabled, + multi_environment_tools: false, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, @@ -315,14 +318,18 @@ pub fn build_tool_registry_plan( match apply_patch_tool_type { ApplyPatchToolType::Freeform => { plan.push_spec( - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: config.multi_environment_tools, + }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); } ApplyPatchToolType::Function => { plan.push_spec( - create_apply_patch_json_tool(), + create_apply_patch_json_tool(ApplyPatchToolOptions { + multi_environment_tools: config.multi_environment_tools, + }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -382,6 +389,7 @@ pub fn build_tool_registry_plan( plan.push_spec( create_view_image_tool(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, + multi_environment_tools: config.multi_environment_tools, }), /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 0b94ef64ca58..c2b1f48045fe 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -85,11 +85,14 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_exec_command_tool(CommandToolOptions { allow_login_shell: true, exec_permission_approvals_enabled: false, + multi_environment_tools: false, }), create_write_stdin_tool(), create_update_plan_tool(), request_user_input_tool_spec(/*default_mode_request_user_input*/ false), - create_apply_patch_freeform_tool(), + create_apply_patch_freeform_tool(ApplyPatchToolOptions { + multi_environment_tools: false, + }), ToolSpec::WebSearch { external_web_access: Some(true), filters: None, @@ -100,6 +103,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_image_generation_tool("png"), create_view_image_tool(ViewImageToolOptions { can_request_original_image_detail: config.can_request_original_image_detail, + multi_environment_tools: false, }), ] { expected.insert(spec.name().to_string(), spec); @@ -432,6 +436,37 @@ fn view_image_tool_includes_detail_with_original_detail_support() { assert!(description.contains("omit this field for default resized behavior")); } +#[test] +fn view_image_tool_includes_environment_id_when_multi_environment_tools_enabled() { + let mut model_info = model_info(); + model_info.supports_image_detail_original = false; + let mut features = Features::with_defaults(); + features.enable(Feature::MultiEnvironmentTools); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else { + panic!("view_image should be a function tool"); + }; + let (properties, _) = expect_object_schema(parameters); + assert!(properties.contains_key("environment_id")); +} + #[test] fn disabled_environment_omits_environment_backed_tools() { let model_info = model_info(); diff --git a/codex-rs/tools/src/view_image.rs b/codex-rs/tools/src/view_image.rs index 1d77ceadf3c9..017e09f73290 100644 --- a/codex-rs/tools/src/view_image.rs +++ b/codex-rs/tools/src/view_image.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ViewImageToolOptions { pub can_request_original_image_detail: bool, + pub multi_environment_tools: bool, } pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { @@ -24,6 +25,14 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { )), ); } + if options.multi_environment_tools { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional selected environment id to load the image from; omit to use the primary environment.".to_string(), + )), + ); + } ToolSpec::Function(ResponsesApiTool { name: VIEW_IMAGE_TOOL_NAME.to_string(), diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 8b2dfe8d47f8..4dc724ee5e1f 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::new(/*exec_server_url*/ None)), + 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 1749ee9f1eb1..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, @@ -3576,7 +3583,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(/*exec_server_url*/ None)), + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), remote_app_server_url: None, remote_app_server_auth_token: None, pending_update_action: None, @@ -3633,7 +3640,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(/*exec_server_url*/ None)), + 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_command.rs b/codex-rs/tui/src/app_command.rs index e94dced053a9..4893f7a9fe62 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, @@ -180,6 +181,7 @@ impl AppCommand { ) -> Self { Self(Op::OverrideTurnContext { cwd, + environments: None, approval_policy, approvals_reviewer, sandbox_policy, @@ -296,6 +298,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + environments: _, } => AppCommandView::UserTurn { items, cwd, @@ -312,6 +315,7 @@ impl AppCommand { }, Op::OverrideTurnContext { cwd, + environments: _, approval_policy, approvals_reviewer, sandbox_policy, 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()), diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index c1e142c4e149..8b19bc753801 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -629,6 +629,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context op, Op::OverrideTurnContext { cwd: None, + environments: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7c60b2e38a28..fdc6e260d6ed 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -36,6 +36,7 @@ use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; 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; @@ -425,7 +426,7 @@ 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::default_for_tests()), ) .await } @@ -623,7 +624,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); @@ -726,11 +729,12 @@ pub async fn run_main( } }; - let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some( - 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 environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( + runtime_paths.clone(), ))); let cwd = cli.cwd.clone(); let config_cwd = @@ -845,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_manager = Arc::new( + EnvironmentManager::try_new( + config.environment_manager_args(exec_server_url, runtime_paths)?, + ) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?, + ); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1771,7 +1782,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::default_for_tests()), ) .await } @@ -1919,8 +1930,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 { @@ -1930,7 +1942,7 @@ 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::default_for_tests(); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -1939,11 +1951,12 @@ 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 = EnvironmentManager::new(/*exec_server_url*/ None); + let environment_manager = EnvironmentManager::default_for_tests(); let config_cwd = config_cwd_for_app_server_target(Some(temp_dir.path()), &target, &environment_manager)?; @@ -1957,13 +1970,13 @@ 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; - let environment_manager = EnvironmentManager::new(/*exec_server_url*/ None); + 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"); @@ -1972,15 +1985,23 @@ 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 { 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::new(codex_exec_server::EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + )?, + }); let config_cwd = config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?; @@ -2107,7 +2128,7 @@ mod tests { CloudRequirementsLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), + Arc::new(EnvironmentManager::default_for_tests()), |_args| async { Err(std::io::Error::other("boom")) }, ) .await; @@ -2171,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(), diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index aee909cea1fc..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( - /*exec_server_url*/ None, - )), + 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,