diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2ecaa733ed..9df301e878 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3074,6 +3074,14 @@ pub struct ThreadStartParams { pub ephemeral: Option, #[ts(optional = nullable)] pub session_start_source: Option, + /// Optional sticky environment selections for this thread. + /// + /// Omitted uses EnvironmentManager default behavior. Empty disables + /// environment access for turns that do not provide a turn override. + /// Non-empty selects the first environment as the current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, #[experimental("thread/start.dynamicTools")] #[ts(optional = nullable)] pub dynamic_tools: Option>, @@ -4662,6 +4670,10 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, /// Optional turn-scoped environment selections. + /// + /// Omitted uses the thread sticky environment selections. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. #[experimental("turn/start.environments")] #[ts(optional = nullable)] pub environments: Option>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d815cbb4..8ff4de3e76 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2353,8 +2353,18 @@ impl CodexMessageProcessor { personality, ephemeral, session_start_source, + environments, persist_extended_history, } = params; + let environments = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect() + }); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -2392,6 +2402,7 @@ impl CodexMessageProcessor { typesafe_overrides, dynamic_tools, session_start_source, + environments, persist_extended_history, service_name, experimental_raw_events, @@ -2466,6 +2477,7 @@ impl CodexMessageProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, + environment_selections: Option>, persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, @@ -2613,6 +2625,7 @@ impl CodexMessageProcessor { persist_extended_history, service_name, request_trace, + environment_selections, ) .instrument(tracing::info_span!( "app_server.thread_start.create_thread", @@ -7219,6 +7232,7 @@ impl CodexMessageProcessor { service_tier: params.service_tier, collaboration_mode, personality: params.personality, + environments: None, }, ) .await; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 858154eb77..ae7b717568 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive( user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, + environment_selections: None, analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), })) .await?; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 1751e7075d..108bb1cfe9 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -166,6 +166,7 @@ pub(super) async fn user_input_or_turn_inner( personality, app_server_client_name: None, app_server_client_version: None, + environment_selections: None, }, None, environments, @@ -1077,6 +1078,7 @@ pub(super) async fn submission_loop( service_tier, collaboration_mode, personality, + environments, } => { let collaboration_mode = if let Some(collab_mode) = collaboration_mode { collab_mode @@ -1101,6 +1103,7 @@ pub(super) async fn submission_loop( reasoning_summary: summary, service_tier, personality, + environment_selections: environments, ..Default::default() }, ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cd6aca5dfe..4f27edb5bd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -401,6 +401,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) inherited_exec_policy: Option>, pub(crate) user_shell_override: Option, pub(crate) parent_trace: Option, + pub(crate) environment_selections: Option>, pub(crate) analytics_events_client: Option, } @@ -455,6 +456,7 @@ impl Codex { user_shell_override, inherited_exec_policy, parent_trace: _, + environment_selections, analytics_events_client, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -617,6 +619,7 @@ impl Codex { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections, original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, @@ -626,6 +629,7 @@ impl Codex { persist_extended_history, inherited_shell_snapshot, user_shell_override, + environment_selections, }; // Generate a unique ID for the lifetime of this Codex session. diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0c8ab535f5..f33993b196 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -71,6 +71,9 @@ pub(crate) struct SessionConfiguration { pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. pub(super) thread_name: Option, + /// Sticky environment selections for turns that do not provide a turn-local override. + pub(super) environment_selections: + Option>, // TODO(pakrym): Remove config from here pub(super) original_config_do_not_use: Arc, @@ -181,6 +184,9 @@ impl SessionConfiguration { if let Some(app_server_client_version) = updates.app_server_client_version.clone() { next_configuration.app_server_client_version = Some(app_server_client_version); } + if let Some(environment_selections) = updates.environment_selections.clone() { + next_configuration.environment_selections = Some(environment_selections); + } Ok(next_configuration) } } @@ -199,6 +205,8 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) personality: Option, pub(crate) app_server_client_name: Option, pub(crate) app_server_client_version: Option, + pub(crate) environment_selections: + Option>, } pub(crate) struct AppServerClientMetadata { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5d838e3db5..5855b62141 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1688,6 +1688,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< service_tier: None, collaboration_mode: Some(collaboration_mode), personality: None, + environments: None, }) .await?; @@ -2321,6 +2322,7 @@ async fn set_rate_limits_retains_previous_credits() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2426,6 +2428,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -2781,6 +2784,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3051,6 +3055,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3151,6 +3156,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3371,6 +3377,7 @@ async fn make_session_with_config_and_rx( cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, @@ -3850,6 +3857,7 @@ fn op_kind_distinguishes_turn_ops() { service_tier: None, collaboration_mode: None, personality: None, + environments: None, } .kind(), "override_turn_context" @@ -4381,6 +4389,7 @@ where cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), thread_name: None, + environment_selections: None, original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5fb7fe3316..06c189993c 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -651,6 +651,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { inherited_exec_policy: Some(Arc::new(parent_exec_policy)), user_shell_override: None, parent_trace: None, + environment_selections: None, analytics_events_client: None, }) .await diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 998f016c96..b44a416520 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -467,20 +467,6 @@ impl Session { updates: SessionSettingsUpdate, environment_selections: Option>, ) -> CodexResult> { - let turn_environments = match self.resolve_turn_environments(environment_selections) { - Ok(turn_environments) => turn_environments, - Err(err) => { - self.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }) - .await; - return Err(err); - } - }; let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -524,6 +510,23 @@ impl Session { return Err(CodexErr::InvalidRequest(message)); } }; + let effective_environment_selections = + environment_selections.or_else(|| session_configuration.environment_selections.clone()); + let turn_environments = + match self.resolve_turn_environments(effective_environment_selections) { + Ok(turn_environments) => turn_environments, + Err(err) => { + self.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + return Err(err); + } + }; self.maybe_refresh_shell_snapshot_for_cwd( &previous_cwd, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 066bfe3165..8c74cd3506 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -501,6 +501,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, )) .await } @@ -513,6 +514,7 @@ impl ThreadManager { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + environment_selections: Option>, ) -> CodexResult { Box::pin(self.state.spawn_thread( config, @@ -523,6 +525,7 @@ impl ThreadManager { persist_extended_history, metrics_service_name, parent_trace, + environment_selections, /*user_shell_override*/ None, )) .await @@ -563,6 +566,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -582,6 +586,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -604,6 +609,7 @@ impl ThreadManager { /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ Some(user_shell_override), )) .await @@ -712,6 +718,7 @@ impl ThreadManager { persist_extended_history, /*metrics_service_name*/ None, parent_trace, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -813,6 +820,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -840,6 +848,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -868,6 +877,7 @@ impl ThreadManagerState { inherited_shell_snapshot, inherited_exec_policy, /*parent_trace*/ None, + /*environment_selections*/ None, /*user_shell_override*/ None, )) .await @@ -885,6 +895,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, parent_trace: Option, + environment_selections: Option>, user_shell_override: Option, ) -> CodexResult { Box::pin(self.spawn_thread_with_source( @@ -899,6 +910,7 @@ impl ThreadManagerState { /*inherited_shell_snapshot*/ None, /*inherited_exec_policy*/ None, parent_trace, + environment_selections, user_shell_override, )) .await @@ -918,6 +930,7 @@ impl ThreadManagerState { inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, parent_trace: Option, + environment_selections: Option>, user_shell_override: Option, ) -> CodexResult { let environment = self.environment_manager.default_environment(); @@ -955,6 +968,7 @@ impl ThreadManagerState { inherited_exec_policy, user_shell_override, parent_trace, + environment_selections, analytics_events_client: self.analytics_events_client.clone(), }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 615bc5bb8f..6fe8e37e38 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -567,6 +567,10 @@ pub enum Op { /// Updated personality preference. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Updated sticky environment selections for future turns. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, }, /// Approve a command execution