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..f9f7d871078c 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 environments 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 environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[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..85a423ffa237 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, + environments: 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, + environments, + }) .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..477ac444cd06 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_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { let (path, range) = exec_policy_warning_location(&err); diff --git a/codex-rs/app-server/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/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 7247c601f46e..1772491223e4 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, + environments: 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..abfd2a930bd9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,8 +49,13 @@ use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_exec_server::EnvVarEnvironmentProvider; +use codex_exec_server::EnvironmentProvider; +use codex_exec_server::EnvironmentSpec; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_exec_server::RemoteExecServerTransport; +use codex_exec_server::StaticEnvironmentProvider; pub use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -833,6 +838,18 @@ impl Config { } } + pub fn environment_provider( + &self, + exec_server_url: Option, + ) -> std::io::Result> { + let environment_config: EnvironmentConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + build_environment_provider(environment_config, exec_server_url) + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1283,6 +1300,210 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +#[derive(Debug, Deserialize, Default)] +struct EnvironmentConfigToml { + environment_provider: Option, + default_environment: Option, + environments: Option>, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentProviderToml { + #[serde(rename = "type")] + provider_type: EnvironmentProviderKind, + default_environment: Option, + environments: Option>, + url: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum EnvironmentProviderKind { + Env, + Static, + Http, +} + +#[derive(Debug, Deserialize)] +struct EnvironmentToml { + id: String, + #[serde(alias = "websocket_url", alias = "websocketUrl")] + url: Option, + #[serde(alias = "execServerCommand")] + exec_server_command: Option, +} + +fn build_environment_provider( + environment_config: EnvironmentConfigToml, + exec_server_url: Option, +) -> std::io::Result> { + match environment_config.environment_provider { + Some(provider) => build_configured_environment_provider(provider, exec_server_url), + None if environment_config.default_environment.is_some() + || environment_config.environments.is_some() => + { + Ok(Box::new(StaticEnvironmentProvider { + default_environment: environment_config.default_environment, + environments: build_environment_specs( + environment_config.environments.as_deref().unwrap_or(&[]), + )?, + })) + } + None => Ok(Box::new(EnvVarEnvironmentProvider { exec_server_url })), + } +} + +fn build_configured_environment_provider( + provider: EnvironmentProviderToml, + exec_server_url: Option, +) -> std::io::Result> { + match provider.provider_type { + EnvironmentProviderKind::Env => Ok(Box::new(EnvVarEnvironmentProvider { exec_server_url })), + EnvironmentProviderKind::Static => Ok(Box::new(StaticEnvironmentProvider { + default_environment: provider.default_environment, + environments: build_environment_specs(provider.environments.as_deref().unwrap_or(&[]))?, + })), + EnvironmentProviderKind::Http => { + let url = provider + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "environment_provider.url is required for http provider", + ) + })?; + Ok(Box::new(HttpEnvironmentProvider { + url: url.to_string(), + client: reqwest::Client::new(), + })) + } + } +} + +fn build_environment_specs( + environments: &[EnvironmentToml], +) -> std::io::Result> { + let mut environment_specs = Vec::with_capacity(environments.len()); + for environment in environments { + environment_specs.push(build_environment_spec(environment)?); + } + + Ok(environment_specs) +} + +fn build_environment_spec(environment: &EnvironmentToml) -> std::io::Result { + let id = environment.id.trim(); + if id.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "environment id must not be empty", + )); + } + if id == "local" || id.eq_ignore_ascii_case("none") { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment id `{id}` is reserved"), + )); + } + + let url = environment + .url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let command = environment + .exec_server_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let transport = match (url, command) { + (Some(url), None) => { + if !(url.starts_with("ws://") || url.starts_with("wss://")) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` url must start with ws:// or wss://"), + )); + } + RemoteExecServerTransport::WebSocket { + url: url.to_string(), + } + } + (None, Some(command)) => RemoteExecServerTransport::Command { + command: command.to_string(), + }, + (Some(_), Some(_)) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set only one of url or exec_server_command"), + )); + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("environment `{id}` must set url or exec_server_command"), + )); + } + }; + + Ok(EnvironmentSpec { + id: id.to_string(), + transport, + }) +} + +struct HttpEnvironmentProvider { + url: String, + client: reqwest::Client, +} + +#[async_trait::async_trait] +impl EnvironmentProvider for HttpEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), codex_exec_server::ExecServerError> { + let url = format!("{}/environments", self.url.trim_end_matches('/')); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to load environments from `{url}`: {err}" + )) + })? + .error_for_status() + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to load environments from `{url}`: {err}" + )) + })? + .json::() + .await + .map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "failed to parse environments from `{url}`: {err}" + )) + })?; + let environments = build_environment_specs(&response.environments).map_err(|err| { + codex_exec_server::ExecServerError::Protocol(format!( + "invalid environment service response from `{url}`: {err}" + )) + })?; + Ok((response.default_environment, environments)) + } +} + +#[derive(Debug, Deserialize)] +struct HttpEnvironmentResponse { + #[serde(alias = "defaultEnvironment")] + default_environment: Option, + environments: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, diff --git a/codex-rs/core/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/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..9939e0b91fdd 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, + environments: 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, + 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..5e972440db99 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -110,6 +110,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -401,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) environments: Option>, pub(crate) analytics_events_client: Option, } @@ -455,15 +457,13 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + environments, 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, + environments, original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -629,6 +630,7 @@ impl Codex { persist_extended_history, inherited_shell_snapshot, user_shell_override, + environments, }; // Generate a unique ID for the lifetime of this Codex session. @@ -650,7 +652,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, - environment, + environment_manager, analytics_events_client, ) .await @@ -1084,6 +1086,7 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { + environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 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/session.rs b/codex-rs/core/src/session/session.rs index 16e86e3aeac8..1a7eb2294ea7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -71,6 +71,8 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, + /// Sticky environments for turns that do not provide a turn-local override. + pub(super) environments: Option>, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -181,6 +183,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(environments) = updates.environments.clone() { + next_configuration.environments = Some(environments); + } Ok(next_configuration) } } @@ -199,6 +204,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) environments: Option>, } pub(crate) struct AppServerClientMetadata { @@ -228,7 +234,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 +701,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 +796,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..84df38fd7f50 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() }, + /*environments*/ 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(), @@ -2318,6 +2322,7 @@ async fn set_rate_limits_retains_previous_credits() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2423,6 +2428,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2778,6 +2784,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2855,8 +2862,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 +3055,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3082,11 +3090,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 +3156,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3162,7 +3167,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 +3189,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 +3254,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 +3289,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 +3377,7 @@ async fn make_session_with_config_and_rx( cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3405,11 +3413,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 +3857,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 +3883,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 +3910,133 @@ async fn user_turn_updates_approvals_reviewer() { ); } +#[tokio::test] +async fn turn_environments_set_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let selected_cwd = + AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) + .expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd.clone(), + }]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 1); + assert_eq!(turn_environments[0].environment_id, "local"); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); +} + +#[tokio::test] +async fn multiple_turn_environments_use_first_as_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let session_cwd = session.get_config().await.cwd.clone(); + let first_cwd = + 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![ + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: first_cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: second_cwd.clone(), + }, + ]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 2); + assert_eq!(turn_environments[0].cwd, first_cwd); + assert_eq!(turn_environments[1].cwd, second_cwd); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd, first_cwd); + assert_eq!(turn_context.config.cwd, first_cwd); +} + +#[tokio::test] +async fn empty_turn_environments_clear_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![]), + ) + .await + .expect("turn should start"); + + assert!(turn_context.environment.is_none()); + assert_eq!(turn_context.cwd, session.get_config().await.cwd); + assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); + assert_eq!( + turn_context + .environments + .as_ref() + .expect("turn environments should be recorded") + .len(), + 0 + ); +} + +#[tokio::test] +async fn unknown_turn_environment_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![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 +4389,7 @@ where cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environments: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -4265,7 +4400,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 +4422,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 +4487,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 +4522,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..98b69d65b152 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, + environments: None, analytics_events_client: None, }) .await diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index dd86804ee5d6..00af39218157 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,6 +1,7 @@ use super::*; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::protocol::TurnEnvironmentSelection; pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { matches!( @@ -24,6 +25,14 @@ impl TurnSkillsContext { } } +#[derive(Clone, Debug)] +pub(crate) struct TurnEnvironment { + #[allow(dead_code)] + pub(crate) environment_id: String, + pub(crate) environment: Arc, + pub(crate) cwd: AbsolutePathBuf, +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -39,6 +48,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Option>, + pub(crate) environments: Option>, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -168,6 +178,7 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), environment: self.environment.clone(), + environments: self.environments.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -300,11 +311,14 @@ fn local_time_context() -> (String, String) { impl Session { /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. - pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + pub(crate) fn build_per_turn_config( + session_configuration: &SessionConfiguration, + cwd: AbsolutePathBuf, + ) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.cwd = session_configuration.cwd.clone(); + per_turn_config.cwd = cwd; per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -346,6 +360,8 @@ impl Session { models_manager: &ModelsManager, network: Option, environment: Option>, + environments: Option>, + cwd: AbsolutePathBuf, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -389,8 +405,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 +428,7 @@ impl Session { reasoning_summary, session_source, environment, + environments, cwd, current_date: Some(current_date), timezone: Some(timezone), @@ -450,7 +465,8 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, - ) -> ConstraintResult> { + environments: Option>, + ) -> CodexResult> { let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -481,6 +497,23 @@ impl Session { session_source, ) = 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: message.clone(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + return Err(CodexErr::InvalidRequest(message)); + } + }; + let effective_environments = + environments.or_else(|| session_configuration.environments.clone()); + let turn_environments = match self.resolve_turn_environments(effective_environments) { + Ok(turn_environments) => turn_environments, Err(err) => { self.send_event_raw(Event { id: sub_id.clone(), @@ -511,17 +544,63 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + turn_environments, ) .await) } + fn resolve_turn_environments( + &self, + environments: Option>, + ) -> CodexResult>> { + let Some(environments) = environments else { + return Ok(None); + }; + + let mut turn_environments = Vec::with_capacity(environments.len()); + for selected_environment in environments { + let environment = self + .services + .environment_manager + .get_environment(&selected_environment.environment_id) + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + selected_environment.environment_id + )) + })?; + let cwd = selected_environment.cwd; + turn_environments.push(TurnEnvironment { + environment_id: selected_environment.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 +623,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 +653,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 +699,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..460c843010c7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_utils_absolute_path::AbsolutePathBuf; @@ -198,6 +199,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 environments: 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 +312,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 +505,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, + environments: 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.environments, /*user_shell_override*/ None, )) .await @@ -563,6 +573,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -582,6 +593,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environments*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -604,6 +616,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environments*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -712,6 +725,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -813,6 +827,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -840,6 +855,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -868,6 +884,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environments*/ None, /*user_shell_override*/ None, )) .await @@ -885,6 +902,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + environments: Option>, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -899,6 +917,7 @@ impl ThreadManagerState { /*inherited_shell_snapshot*/ None, /*inherited_exec_policy*/ None, parent_trace, + environments, user_shell_override, )) .await @@ -918,13 +937,10 @@ impl ThreadManagerState { inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, parent_trace: Option, + environments: 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 +975,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + environments, 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/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/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 9877b2cb9fcd..1865188d3e7d 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -508,7 +508,7 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { shell_env(), ); - let environment = codex_exec_server::Environment::default(); + let environment = codex_exec_server::Environment::default_for_tests(); let process = UnifiedExecProcessManager::default() .open_session_with_exec_env( /*process_id*/ 1234, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 5075f91620e5..d1346cee454e 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_provider: 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_provider( + mut self, + provider: codex_exec_server::StaticEnvironmentProvider, + ) -> Self { + self.environment_provider = Some(provider); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -350,9 +368,31 @@ 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 local_runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?; + let environment_manager = match self.environment_provider.clone() { + Some(provider) => Arc::new( + codex_exec_server::EnvironmentManager::try_from_provider( + &provider, + local_runtime_paths, + ) + .await?, + ), + None => { + let exec_server_url = self + .exec_server_url + .clone() + .or_else(|| test_env.exec_server_url().map(str::to_owned)); + Arc::new(codex_exec_server::EnvironmentManager::new( + codex_exec_server::EnvironmentManagerArgs { + exec_server_url, + local_runtime_paths, + }, + )) + } + }; let file_system = test_env.environment().get_filesystem(); let mut workspace_setups = vec![]; swap(&mut self.workspace_setups, &mut workspace_setups); @@ -587,6 +627,7 @@ impl TestCodex { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Some(service_tier), + /*environments*/ None, ) .await } @@ -602,6 +643,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 +669,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 +944,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..3f8d2378ade1 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -79,6 +79,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -139,6 +140,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -174,6 +176,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -238,6 +241,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -291,6 +295,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -362,6 +367,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -390,6 +396,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -475,6 +483,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -534,6 +543,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -565,6 +575,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -625,6 +636,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -656,6 +668,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -720,6 +733,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -734,6 +748,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -791,6 +806,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5468151b47c4..c37326ffc956 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -240,6 +240,7 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), @@ -263,6 +264,7 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), @@ -440,6 +442,7 @@ async fn manual_compact_uses_custom_prompt() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -585,6 +588,7 @@ async fn manual_compact_emits_context_compaction_items() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual compact".into(), text_elements: Vec::new(), @@ -749,6 +753,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_message.into(), text_elements: Vec::new(), @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1263,6 +1269,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1277,6 +1284,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1446,6 +1454,7 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -1525,6 +1534,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1538,6 +1548,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1551,6 +1562,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1665,6 +1677,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: follow_up_user.into(), text_elements: Vec::new(), @@ -1756,6 +1769,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before switch".into(), text_elements: Vec::new(), @@ -1781,6 +1795,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after switch".into(), text_elements: Vec::new(), @@ -1892,6 +1907,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { initial .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before resume".into(), text_elements: Vec::new(), @@ -1941,6 +1957,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -2044,6 +2061,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2057,6 +2075,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2070,6 +2089,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2157,6 +2177,7 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2269,6 +2290,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2362,6 +2384,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2378,6 +2401,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2394,6 +2418,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: final_user_message.into(), text_elements: Vec::new(), @@ -2556,6 +2581,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2659,6 +2685,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), @@ -2858,6 +2885,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2976,6 +3004,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3036,6 +3065,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3067,6 +3097,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -3162,6 +3193,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3187,6 +3219,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3283,6 +3316,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3296,6 +3330,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3367,6 +3402,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 30e3c54c9433..58ed42c288c0 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -246,6 +246,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -261,6 +262,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -392,6 +394,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -467,6 +470,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -479,6 +483,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -595,6 +600,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -607,6 +613,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -625,6 +632,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -723,6 +731,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), @@ -735,6 +744,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -827,6 +837,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -842,6 +853,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -931,6 +943,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1015,6 +1029,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1094,6 +1109,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1177,6 +1193,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "needs compaction".into(), text_elements: Vec::new(), @@ -1319,6 +1336,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1335,6 +1353,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1358,6 +1377,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -1453,6 +1473,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1468,6 +1489,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1538,6 +1560,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1550,6 +1573,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1615,6 +1639,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1674,6 +1699,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1688,6 +1714,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1761,6 +1788,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1776,6 +1804,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1857,6 +1886,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "SETUP_USER".to_string(), text_elements: Vec::new(), @@ -1871,6 +1901,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1960,6 +1991,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1988,6 +2020,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2082,6 +2115,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us } codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -2167,6 +2201,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2194,6 +2229,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2311,6 +2347,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2323,6 +2360,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2406,6 +2444,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2482,6 +2521,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2566,6 +2606,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2581,6 +2622,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2661,6 +2703,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index efa39aaeebed..48d359529b88 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -803,6 +803,7 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3c80fc80d095..8b2654a7205b 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -44,6 +44,7 @@ async fn submit_user_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -125,6 +126,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell command".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index dc7f2151f0d2..bcb7864cf9c0 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -48,6 +48,7 @@ async fn fork_thread_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index b2d8e07b65ca..5c71516f40a1 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1036,6 +1036,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "initial prompt".to_string(), text_elements: Vec::new(), @@ -1053,6 +1054,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index a7ec8318f158..0e2d88237dd9 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -111,6 +111,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::LocalImage { path: abs_path.clone(), @@ -198,6 +199,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 25eb21df9ebb..6cf42b5eedb6 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -85,6 +85,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -139,6 +140,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please summarize results".into(), text_elements: Vec::new(), @@ -198,6 +200,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "explain your reasoning".into(), text_elements: Vec::new(), @@ -258,6 +261,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "find the weather".into(), text_elements: Vec::new(), @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate a tiny blue square".into(), text_elements: Vec::new(), @@ -386,6 +391,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -440,6 +446,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please stream text".into(), text_elements: Vec::new(), @@ -523,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -600,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -709,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan with citations".into(), text_elements: Vec::new(), @@ -896,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1010,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1088,6 +1100,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "reason through it".into(), text_elements: Vec::new(), @@ -1148,6 +1161,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "show raw reasoning".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 755b2694728d..d6728deb0c02 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -73,6 +73,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383f5c..cfafdea3f73f 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -48,6 +48,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Text { text: prompt.to_string(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 139ee7a85624..54c2d1035f2f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -121,6 +121,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch models".into(), text_elements: Vec::new(), @@ -218,6 +220,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -255,6 +258,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch model and personality".into(), text_elements: Vec::new(), @@ -392,6 +396,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -526,6 +532,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -547,6 +554,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -658,6 +666,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -679,6 +688,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -792,6 +802,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -821,6 +832,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after rollback".to_string(), text_elements: Vec::new(), @@ -978,6 +990,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use larger model".into(), text_elements: Vec::new(), @@ -1037,6 +1050,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch to smaller model".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 0f41637ea258..c93294121cfb 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -114,6 +114,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -138,6 +139,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn with context updates".into(), text_elements: Vec::new(), @@ -217,6 +219,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn in agents_one".into(), text_elements: Vec::new(), @@ -241,6 +244,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn in agents_two".into(), text_elements: Vec::new(), @@ -317,6 +321,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -352,6 +357,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "resume and change personality".into(), text_elements: Vec::new(), @@ -417,6 +423,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -463,6 +470,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first resumed turn after model override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 2ffce5742165..5728205b6c3b 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -90,6 +90,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hi".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 34aaf86deeba..d46214d1f274 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -101,6 +101,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please run a tool".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index f93945e78fff..3893170ed745 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -102,6 +102,7 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -146,6 +147,7 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -190,6 +192,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -236,6 +239,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -301,6 +305,7 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -364,6 +369,7 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -415,6 +421,7 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -474,6 +481,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -527,6 +535,7 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -600,6 +609,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -685,6 +695,7 @@ async fn record_responses_sets_span_fields_for_response_events() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -770,6 +781,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -844,6 +856,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -928,6 +941,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -997,6 +1011,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1106,6 +1121,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1158,6 +1174,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "approved".into(), text_elements: Vec::new(), @@ -1225,6 +1242,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1292,6 +1310,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "retry".into(), text_elements: Vec::new(), @@ -1359,6 +1378,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), @@ -1426,6 +1446,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1494,6 +1515,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 777103534b80..10907f02637f 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -95,6 +95,7 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -109,6 +110,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -272,6 +274,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".into(), text_elements: Vec::new(), @@ -289,6 +292,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "second prompt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d7ceb25fefe6..fb0acf7519b2 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -49,6 +49,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -87,6 +88,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -115,6 +117,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -159,6 +162,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -215,6 +220,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -243,6 +249,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -300,6 +307,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -330,6 +338,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -344,6 +353,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -402,6 +412,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -432,6 +443,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -452,6 +464,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -485,6 +498,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -538,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 251aad76c087..7c72478acee6 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -96,6 +96,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -147,6 +148,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -205,6 +207,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -269,6 +272,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -321,6 +325,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -359,6 +364,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -426,6 +432,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -544,6 +552,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -582,6 +591,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -701,6 +711,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -822,6 +833,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -860,6 +872,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 49be14a60df0..07602c1be87f 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -192,6 +192,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -268,6 +269,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), @@ -348,6 +350,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 63a21ce1d597..cee43e9820e0 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -146,6 +146,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -244,6 +246,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -256,6 +259,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -413,6 +419,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -450,6 +457,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -533,6 +541,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -685,6 +694,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -707,6 +717,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }; codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -820,6 +831,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -841,6 +853,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -967,6 +981,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index c59f2d86bce4..4c0677e69a58 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -41,6 +41,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "quota?".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 60167a15586a..b3d695250d57 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1943,6 +1943,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result let prefixed_user_text = format!("[USER] {user_text}"); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.to_string(), text_elements: Vec::new(), @@ -2072,6 +2073,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re ); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.clone(), text_elements: Vec::new(), @@ -3230,6 +3232,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9b157bde5ff5..e62fa0c3234d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -172,6 +172,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -323,6 +325,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -411,6 +414,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -474,6 +478,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -861,6 +867,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 44d37f311cec..fddb18f15bfb 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -40,6 +40,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "compress me".into(), text_elements: Vec::new(), @@ -88,6 +89,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "do not compress".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 614cd10b9fc8..866df1386384 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -187,6 +187,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 0578441e9918..d31b060c38b3 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -138,6 +138,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c2189..4c59c8e7f3d9 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -131,6 +131,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), @@ -249,6 +250,7 @@ where codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 67f6e86ab0e2..8e5d1e0e3c0a 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -128,6 +128,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 55d7e45f0aaa..febeb926b160 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -86,6 +86,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record some messages".into(), text_elements: text_elements.clone(), @@ -172,6 +173,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record reasoning messages".into(), text_elements: Vec::new(), @@ -262,6 +264,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -303,6 +306,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Resume with different model".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Second turn after resume".into(), text_elements: Vec::new(), @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -434,6 +440,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn after override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f4da7c549b30..faf469c9812b 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -721,6 +721,7 @@ async fn review_history_surfaces_in_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7875f09810ab..cb6553343fc0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -296,6 +296,7 @@ async fn call_cwd_tool( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -429,6 +430,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -797,6 +799,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -929,6 +932,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1028,6 +1032,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1179,6 +1184,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1416,6 +1422,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1524,6 +1531,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1660,6 +1668,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1769,6 +1778,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1893,6 +1903,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -2109,6 +2120,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16af4..f3211e00d63e 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -38,6 +38,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger safety check".to_string(), text_elements: Vec::new(), @@ -137,6 +138,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger response model check".to_string(), text_elements: Vec::new(), @@ -223,6 +225,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger follow-up turn".to_string(), text_elements: Vec::new(), @@ -273,6 +276,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger casing check".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 2f24fba33e81..1e37d5ae10db 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -502,6 +502,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let test = builder.build(&server).await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), @@ -778,6 +779,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Use the automation tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 516bc049f53a..b3a53fde1195 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -157,6 +157,7 @@ async fn run_snapshot_command_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), @@ -248,6 +249,7 @@ async fn run_shell_command_snapshot_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), @@ -319,6 +321,7 @@ async fn run_tool_turn_on_harness( let cwd = test.cwd_path().to_path_buf(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -554,6 +557,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f9487..de4827d6befd 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -44,6 +44,7 @@ async fn submit_turn_with_policies( ) -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 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..58dbad7b1d22 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -166,6 +166,7 @@ async fn submit_unified_exec_turn( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -250,6 +251,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via unified exec".into(), text_elements: Vec::new(), @@ -1740,6 +1742,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), @@ -1833,6 +1836,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), @@ -2305,6 +2309,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "summarize large output".into(), text_elements: Vec::new(), @@ -2414,6 +2419,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "read the fixture files".into(), text_elements: Vec::new(), @@ -2542,6 +2548,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "start python under seatbelt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 3d02c2004dde..5fe08789f438 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -57,6 +57,7 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 807c7469050d..0aa52b7de877 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -170,6 +170,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> fixture .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run model shell command".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 9f53e60d723a..456740344924 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -160,6 +160,7 @@ async fn assert_user_turn_local_image_resizes_to( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], @@ -279,6 +280,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -410,6 +412,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the original screenshot".into(), text_elements: Vec::new(), @@ -509,6 +512,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image at low detail".into(), text_elements: Vec::new(), @@ -599,6 +603,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image with a null detail".into(), text_elements: Vec::new(), @@ -699,6 +704,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -803,6 +809,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -905,6 +912,7 @@ await codex.emitImage(out); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image and attach it".into(), text_elements: Vec::new(), @@ -1025,6 +1033,7 @@ console.log(out.type); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image but do not emit it".into(), text_elements: Vec::new(), @@ -1118,6 +1127,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the folder".into(), text_elements: Vec::new(), @@ -1199,6 +1209,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please use the view_image tool to read the json file".into(), text_elements: Vec::new(), @@ -1285,6 +1296,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the missing image".into(), text_elements: Vec::new(), @@ -1422,6 +1434,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image".into(), text_elements: Vec::new(), @@ -1503,6 +1516,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec64bd..6611ebdf5d0f 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -150,6 +150,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index bfcdcf25eb6b..de52821839de 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -104,6 +104,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/exec-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..5fe92cf2e45d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,11 +1,12 @@ +use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::OnceCell; +use async_trait::async_trait; -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 +16,283 @@ 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. /// -/// The manager keeps the session's environment selection stable so subagents -/// and follow-up turns preserve an explicit disabled state. +/// `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. +/// +/// 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 Default for EnvironmentManager { - fn default() -> Self { - Self::new(/*exec_server_url*/ None) +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, + } } } -impl EnvironmentManager { - /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value. - pub fn new(exec_server_url: Option) -> Self { - Self::new_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None) +#[derive(Clone, Debug)] +pub struct EnvironmentSpec { + pub id: String, + pub transport: RemoteExecServerTransport, +} + +/// Discovers the remote environments that should be available to Codex. +/// +/// Implementations are source-specific: they may read process environment, +/// `config.toml`, or a remote service. They should return plain environment +/// specs and leave validation plus runtime object construction to +/// [`EnvironmentManager`]. +#[async_trait] +pub trait EnvironmentProvider: Send + Sync { + /// Returns the default environment id and remote environment specs. + /// + /// `EnvironmentManager` always adds `local` itself. A missing or empty + /// default means `local`; `"none"` disables the default environment. + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError>; +} + +#[derive(Clone, Debug)] +pub struct EnvVarEnvironmentProvider { + pub exec_server_url: Option, +} + +#[async_trait] +impl EnvironmentProvider for EnvVarEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError> { + let (exec_server_url, environment_disabled) = + normalize_exec_server_url(self.exec_server_url.clone()); + if environment_disabled { + return Ok((Some("none".to_string()), Vec::new())); + } + + match exec_server_url { + Some(url) => Ok(( + Some(REMOTE_ENVIRONMENT_ID.to_string()), + vec![EnvironmentSpec { + id: REMOTE_ENVIRONMENT_ID.to_string(), + transport: RemoteExecServerTransport::WebSocket { url }, + }], + )), + None => Ok((Some(LOCAL_ENVIRONMENT_ID.to_string()), Vec::new())), + } + } +} + +#[derive(Clone, Debug)] +pub struct StaticEnvironmentProvider { + pub default_environment: Option, + pub environments: Vec, +} + +#[async_trait] +impl EnvironmentProvider for StaticEnvironmentProvider { + async fn get_environments( + &self, + ) -> Result<(Option, Vec), ExecServerError> { + Ok((self.default_environment.clone(), self.environments.clone())) } +} - /// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local - /// runtime paths used when creating local filesystem helpers. - pub fn new_with_runtime_paths( - exec_server_url: Option, - local_runtime_paths: Option, - ) -> Self { - let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url); +impl EnvironmentManager { + /// 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()), + )]), + } + } + + pub fn new(args: EnvironmentManagerArgs) -> Self { + Self::from_exec_server_url_args(args) + } + + fn from_exec_server_url_args(args: EnvironmentManagerArgs) -> Self { + let EnvironmentManagerArgs { exec_server_url, local_runtime_paths, - 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(args: EnvironmentManagerArgs) -> Result { + Ok(Self::from_exec_server_url_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(), - local_runtime_paths, - ) + pub async fn try_from_provider( + provider: &dyn EnvironmentProvider, + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + let (default_environment, environments) = provider.get_environments().await?; + Self::from_environment_specs(default_environment, environments, local_runtime_paths) } - /// 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(), - }, + fn from_environment_specs( + default_environment: Option, + environment_specs: Vec, + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + let mut environments = HashMap::from([( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::new(Environment::local(local_runtime_paths.clone())), + )]); + + for environment in environment_specs { + let id = environment.id.trim(); + if id.is_empty() { + return Err(ExecServerError::Protocol( + "environment id must not be empty".to_string(), + )); + } + if id == LOCAL_ENVIRONMENT_ID || id.eq_ignore_ascii_case("none") { + return Err(ExecServerError::Protocol(format!( + "environment id `{id}` is reserved" + ))); + } + if environments.contains_key(id) { + return Err(ExecServerError::Protocol(format!( + "duplicate environment id `{id}`" + ))); + } + environments.insert( + id.to_string(), + Arc::new(Environment::remote_with_runtime_paths( + environment.transport, + Some(local_runtime_paths.clone()), + )), + ); } + + let default_environment = match default_environment.as_deref().map(str::trim) { + None | Some("") => Some(LOCAL_ENVIRONMENT_ID.to_string()), + Some(default_environment) if default_environment.eq_ignore_ascii_case("none") => None, + Some(default_environment) => { + if !environments.contains_key(default_environment) { + return Err(ExecServerError::Protocol(format!( + "default_environment `{default_environment}` is not a configured environment id" + ))); + } + Some(default_environment.to_string()) + } + }; + + Ok(Self { + default_environment, + environments, + }) } - /// Returns the remote exec-server URL when one is configured. - pub fn exec_server_url(&self) -> Option<&str> { - self.exec_server_url.as_deref() + /// 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 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 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 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 +301,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 +333,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(), + }, + }) + } - let exec_backend: Arc = - if let Some(client) = remote_exec_server_client.clone() { - Arc::new(RemoteProcess::new(client)) - } else { - Arc::new(LocalProcess::default()) - }; + 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), + } + } - Ok(Self { - exec_server_url, - remote_exec_server_client, + 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)); + + 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 +402,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 +419,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); - - let first = manager.current().await.expect("get current environment"); - let second = manager.current().await.expect("get current environment"); + async fn environment_manager_default_environment_caches_environment() { + let manager = EnvironmentManager::default_for_tests(); - 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 +593,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..15e7ef56e20b 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -20,11 +20,18 @@ mod server; pub use client::ExecServerClient; pub use client::ExecServerError; +pub use client_api::CommandExecServerConnectArgs; pub use client_api::ExecServerClientConnectOptions; pub use client_api::RemoteExecServerConnectArgs; +pub use client_api::RemoteExecServerTransport; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; +pub use environment::EnvVarEnvironmentProvider; pub use environment::Environment; pub use environment::EnvironmentManager; +pub use environment::EnvironmentManagerArgs; +pub use environment::EnvironmentProvider; +pub use environment::EnvironmentSpec; +pub use environment::StaticEnvironmentProvider; pub use file_system::CopyOptions; pub use file_system::CreateDirectoryOptions; pub use file_system::ExecutorFileSystem; diff --git a/codex-rs/exec-server/src/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..8db030d05371 --- /dev/null +++ b/codex-rs/exec-server/tests/command_transport.rs @@ -0,0 +1,55 @@ +mod common; + +use codex_exec_server::EnvironmentManager; +use codex_exec_server::EnvironmentSpec; +use codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::RemoteExecServerTransport; +use codex_exec_server::StaticEnvironmentProvider; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn configured_command_environment_connects_lazily_over_stdio() -> anyhow::Result<()> { + let helper_paths = common::exec_server::test_codex_helper_paths()?; + let temp_dir = tempfile::tempdir()?; + let target_path = temp_dir.path().join("target.txt"); + let marker_path = temp_dir.path().join("spawned.txt"); + tokio::fs::write(&target_path, "ok").await?; + + let provider = StaticEnvironmentProvider { + default_environment: Some("remote".to_string()), + environments: vec![EnvironmentSpec { + id: "remote".to_string(), + transport: RemoteExecServerTransport::Command { + command: format!( + "echo spawned > {marker_path:?}; exec {codex_exe:?} exec-server --listen stdio://", + marker_path = marker_path, + codex_exe = helper_paths.codex_exe, + ), + }, + }], + }; + let manager = EnvironmentManager::try_from_provider( + &provider, + ExecServerRuntimePaths::new(helper_paths.codex_exe.clone(), None)?, + ) + .await?; + let environment = manager.default_environment().expect("default environment"); + + assert!( + tokio::fs::metadata(&marker_path).await.is_err(), + "command transport should not connect before the first remote operation" + ); + + let metadata = environment + .get_filesystem() + .get_metadata( + &AbsolutePathBuf::from_absolute_path(&target_path)?, + /*sandbox*/ None, + ) + .await?; + + assert_eq!(metadata.is_file, true); + assert_eq!(tokio::fs::read_to_string(&marker_path).await?, "spawned\n"); + Ok(()) +} diff --git a/codex-rs/exec-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..b50ff4dbcc22 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; @@ -489,6 +490,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?; + let exec_server_url = + EnvironmentManagerArgs::from_env(local_runtime_paths.clone()).exec_server_url; + let environment_provider = config.environment_provider(exec_server_url)?; + let environment_manager = + EnvironmentManager::try_from_provider(environment_provider.as_ref(), local_runtime_paths) + .await?; let in_process_start_args = InProcessClientStartArgs { arg0_paths, config: std::sync::Arc::new(config.clone()), @@ -497,9 +504,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cloud_requirements: run_cloud_requirements, feedback: CodexFeedback::new(), log_db: None, - environment_manager: std::sync::Arc::new(EnvironmentManager::from_env_with_runtime_paths( - Some(local_runtime_paths), - )), + environment_manager: std::sync::Arc::new(environment_manager), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, @@ -744,6 +749,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { thread_id: primary_thread_id_for_span.clone(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(default_cwd), approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9c9680017f90..5ad6b160b159 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -108,6 +108,7 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { + environments: None, items: vec![UserInput::Text { text: initial_prompt.clone(), // MCP tool prompts are plain text with no UI element ranges. @@ -156,6 +157,7 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), thread_id); if let Err(e) = thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: prompt, // MCP tool prompts are plain text with no UI element ranges. diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 1320fd1b67e2..fe45d9f429a9 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_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?, + ); set_default_client_residency_requirement(config.enforce_residency.value()); let otel = codex_core::otel_init::build_provider( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b9a7a1395f52..55f563b18757 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,12 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct TurnEnvironmentSelection { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "string")] @@ -425,6 +431,9 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, + /// Optional turn-scoped environments. + #[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 environments. + #[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 environments 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, @@ -4904,6 +4922,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 +4941,7 @@ mod tests { assert_eq!( op, Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4942,6 +4962,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 +4984,7 @@ mod tests { #[test] fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: Some(HashMap::from([( diff --git a/codex-rs/tui/src/app/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..45425c3494c5 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -150,6 +150,7 @@ impl AppCommand { ) -> Self { Self(Op::UserTurn { items, + environments: None, cwd, approval_policy, approvals_reviewer: None, @@ -296,6 +297,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + environments: _, } => AppCommandView::UserTurn { items, cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4d8e213ef77b..655947a080b6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -532,6 +532,7 @@ impl AppServerSession { thread_id: thread_id.to_string(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7c60b2e38a28..bb93e721d94b 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_provider = config.environment_provider(exec_server_url)?; + let environment_manager = Arc::new( + EnvironmentManager::try_from_provider(environment_provider.as_ref(), runtime_paths) + .await + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?, + ); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -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,