Add sticky thread environment selections

Allow thread/start to configure sticky environment selections that are used by turns when no per-turn override is supplied. Per-turn environments continue to take precedence, while omitted thread selections preserve the existing default behavior.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-21 13:20:02 -07:00
co-authored by Codex
parent b49f5c0336
commit 2b0f0c2e7e
11 changed files with 87 additions and 14 deletions
@@ -3074,6 +3074,14 @@ pub struct ThreadStartParams {
pub ephemeral: Option<bool>,
#[ts(optional = nullable)]
pub session_start_source: Option<ThreadStartSource>,
/// 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<Vec<TurnEnvironmentParams>>,
#[experimental("thread/start.dynamicTools")]
#[ts(optional = nullable)]
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
@@ -4662,6 +4670,10 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// 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<Vec<TurnEnvironmentParams>>,
@@ -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<Vec<ApiDynamicToolSpec>>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
environment_selections: Option<Vec<TurnEnvironmentSelection>>,
persist_extended_history: bool,
service_name: Option<String>,
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;
+1
View File
@@ -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?;
+3
View File
@@ -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()
},
)
+4
View File
@@ -401,6 +401,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) inherited_exec_policy: Option<Arc<ExecPolicyManager>>,
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environment_selections: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
}
@@ -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.
+8
View File
@@ -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<String>,
/// Sticky environment selections for turns that do not provide a turn-local override.
pub(super) environment_selections:
Option<Vec<codex_protocol::protocol::TurnEnvironmentSelection>>,
// TODO(pakrym): Remove config from here
pub(super) original_config_do_not_use: Arc<Config>,
@@ -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<Personality>,
pub(crate) app_server_client_name: Option<String>,
pub(crate) app_server_client_version: Option<String>,
pub(crate) environment_selections:
Option<Vec<codex_protocol::protocol::TurnEnvironmentSelection>>,
}
pub(crate) struct AppServerClientMetadata {
+9
View File
@@ -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,
@@ -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
+17 -14
View File
@@ -467,20 +467,6 @@ impl Session {
updates: SessionSettingsUpdate,
environment_selections: Option<Vec<TurnEnvironmentSelection>>,
) -> CodexResult<Arc<TurnContext>> {
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,
+14
View File
@@ -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<String>,
parent_trace: Option<W3cTraceContext>,
environment_selections: Option<Vec<codex_protocol::protocol::TurnEnvironmentSelection>>,
) -> CodexResult<NewThread> {
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<String>,
parent_trace: Option<W3cTraceContext>,
environment_selections: Option<Vec<codex_protocol::protocol::TurnEnvironmentSelection>>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
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<Arc<ShellSnapshot>>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
parent_trace: Option<W3cTraceContext>,
environment_selections: Option<Vec<codex_protocol::protocol::TurnEnvironmentSelection>>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
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?;
+4
View File
@@ -567,6 +567,10 @@ pub enum Op {
/// Updated personality preference.
#[serde(skip_serializing_if = "Option::is_none")]
personality: Option<Personality>,
/// Updated sticky environment selections for future turns.
#[serde(default, skip_serializing_if = "Option::is_none")]
environments: Option<Vec<TurnEnvironmentSelection>>,
},
/// Approve a command execution