diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index 6ec89401ec56..27661a5f7035 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -222,6 +222,7 @@ impl ExternalAgentSessionImporter { }, dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: Some(MultiAgentVersion::V1), history_mode: ThreadHistoryMode::Legacy, initial_window_id: uuid::Uuid::now_v7().to_string(), diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index a563e48d927c..ca7cce3b39d2 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -202,6 +202,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, @@ -292,6 +293,7 @@ pub fn create_fake_rollout_with_text_elements( base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 4ea84b19a679..17c964c0e11e 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -137,6 +137,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 2002b08a8887..1bb0eb0e35cb 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -159,6 +159,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 9e8983840b73..3e1bfe50c683 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -433,6 +433,150 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_subagent_drops_omitted_selected_capabilities_from_child_request() -> Result<()> +{ + const CHILD_PROMPT: &str = "Inspect only local capabilities."; + const PARENT_PROMPT: &str = "Use $executor-demo:deploy and spawn a local-only child."; + const SPAWN_CALL_ID: &str = "spawn-local-only-child"; + + let responses_server = responses::start_mock_server().await; + let (apps_url, apps_server_handle) = start_apps_server_with_delays( + vec![AppInfo { + id: CONNECTOR_ID.to_string(), + name: "Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + icon_assets: None, + icon_dark_assets: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }], + vec![connector_tool(CONNECTOR_ID, "Calendar")?], + Duration::ZERO, + Duration::ZERO, + ) + .await?; + let fixture = selected_capability_fixture(&responses_server.uri(), &apps_url)?; + let config_path = fixture.codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?.replacen( + "deferred_executor = true", + "deferred_executor = true\nmulti_agent_v2 = true", + 1, + ); + std::fs::write(config_path, config)?; + + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "local_child", + "environment_ids": [LOCAL_ENVIRONMENT_ID], + "fork_turns": "all", + }))?; + let parent_turn = responses::mount_sse_once_match( + &responses_server, + |request: &wiremock::Request| request_body_contains(request, PARENT_PROMPT), + responses::sse(vec![ + responses::ev_response_created("parent-spawn"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "collaboration", + "spawn_agent", + &spawn_args, + ), + responses::ev_completed("parent-spawn"), + ]), + ) + .await; + let child_turn = responses::mount_sse_once_match( + &responses_server, + |request: &wiremock::Request| { + request_body_contains(request, CHILD_PROMPT) + && !request_body_contains(request, SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("child-done"), + responses::ev_assistant_message("child-message", "Done"), + responses::ev_completed("child-done"), + ]), + ) + .await; + let _parent_follow_up = responses::mount_sse_once_match( + &responses_server, + |request: &wiremock::Request| request_body_contains(request, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("parent-done"), + responses::ev_assistant_message("parent-message", "Done"), + responses::ev_completed("parent-done"), + ]), + ) + .await; + + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + // This fixture owns environments.toml and selects its environments explicitly. + .without_auto_env() + .build() + .await?; + timeout(READ_TIMEOUT, app_server.initialize()).await??; + let thread_id = start_thread( + &mut app_server, + fixture.selected_root, + fixture.environment_cwd.clone(), + ) + .await?; + + let mut exec_server = + spawn_exec_server(fixture.codex_home.path(), &fixture.exec_server_url).await?; + add_environment(&mut app_server, &fixture.exec_server_url).await?; + wait_for_selected_mcp_server(&mut app_server, &thread_id).await?; + + run_turn( + &mut app_server, + &thread_id, + PARENT_PROMPT, + fixture.environment_cwd, + ) + .await?; + let parent_request = parent_turn.single_request(); + assert_selected_skill_is_injected(&parent_request, /*expected_count*/ 1); + assert_selected_plugin_tools(&parent_request); + + let child_request = timeout(READ_TIMEOUT, async { + loop { + if let Some(request) = child_turn.last_request() { + return request; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await?; + let child_skill_update = latest_selected_skill_update(&child_request) + .expect("child request should update selected skills after restricting environments"); + assert!(child_skill_update.contains(NO_SELECTED_SKILLS_MESSAGE)); + assert_selected_plugin_tools_absent(&child_request); + assert!(!child_request.body_contains_text(SKILL_DESCRIPTION)); + assert!(!child_request.body_contains_text(&format!("skill://{PLUGIN_ID}/"))); + assert!( + child_request + .message_input_texts("user") + .into_iter() + .filter(|text| text.starts_with("")) + .all(|text| !text.contains(SKILL_BODY_MARKER)) + ); + + exec_server.kill().await?; + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + struct SelectedCapabilityFixture { codex_home: TempDir, _plugin: TempDir, @@ -560,6 +704,10 @@ fn assert_selected_capabilities_absent(request: &ResponsesRequest) { assert_plugin_guidance_count(request, /*expected_count*/ 0); } +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + String::from_utf8_lossy(&request.body).contains(text) +} + fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) { assert!( request diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 4162e7ebd60b..0a4101f7e507 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1542,6 +1542,7 @@ async fn seed_pathless_store_thread( base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 8337575e5265..2e7747ed3ac3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2431,6 +2431,7 @@ stream_max_retries = 0 base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 66d51d278052..2c652378ff73 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -222,6 +222,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 85e54e2fb9b7..3adf8ed84623 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -69,6 +69,8 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_mode: Option, pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, + /// Explicit child environment subset used to filter fork-inherited capability roots. + pub(crate) restricted_environment_ids: Option>, } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 9078434f1fe8..ff3c24ff8b6a 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,6 +1,8 @@ use super::residency::is_v2_resident_session_source; use super::*; use codex_extension_api::ExtensionDataInit; +use codex_protocol::capabilities::CapabilityRootLocation; +use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); @@ -480,7 +482,7 @@ impl AgentControl { )) })?; - let selected_capability_roots = parent_history + let mut selected_capability_roots = parent_history .items .iter() .find_map(|item| { @@ -490,6 +492,25 @@ impl AgentControl { Some(meta_line.meta.selected_capability_roots.clone()) }) .unwrap_or_default(); + let mut omitted_selected_skill_path_prefixes = Vec::new(); + let mut omitted_selected_skill_catalog_locators = Vec::new(); + if let Some(restricted_environment_ids) = options.restricted_environment_ids.as_ref() { + // Forked selected-skill bodies and catalog fragments use the root ID in their + // `skill://` locator. Drop context owned by omitted environments while retaining + // allowed selected skills. + for root in &selected_capability_roots { + let CapabilityRootLocation::Environment { environment_id, .. } = &root.location; + if !restricted_environment_ids.contains(environment_id) { + let locator = format!("skill://{}/", root.id); + omitted_selected_skill_path_prefixes.push(format!("{locator}")); + omitted_selected_skill_catalog_locators.push(locator); + } + } + selected_capability_roots.retain(|root| { + let CapabilityRootLocation::Environment { environment_id, .. } = &root.location; + restricted_environment_ids.contains(environment_id) + }); + } let mut forked_rollout_items = parent_history.items; if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { forked_rollout_items = @@ -527,6 +548,44 @@ impl AgentControl { Vec::new() }; let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); + let is_omitted_selected_skill_message = |response_item: &ResponseItem| { + let ResponseItem::Message { role, content, .. } = response_item else { + return false; + }; + role == "user" + && content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.trim_start().starts_with("") + && omitted_selected_skill_path_prefixes + .iter() + .any(|prefix| text.contains(prefix)) + }) + }; + let strip_omitted_selected_skill_catalog = |response_item: &mut ResponseItem| { + let ResponseItem::Message { role, content, .. } = response_item else { + return; + }; + if role != "developer" { + return; + } + content.retain(|content_item| { + let ContentItem::InputText { text } = content_item else { + return true; + }; + !(text.trim_start().starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG) + && omitted_selected_skill_catalog_locators + .iter() + .any(|locator| text.contains(locator))) + }); + }; + let is_empty_message = |response_item: &ResponseItem| { + matches!( + response_item, + ResponseItem::Message { content, .. } if content.is_empty() + ) + }; forked_rollout_items.retain(|item| { keep_forked_rollout_item(item, preserve_reference_context_item) && !matches!( @@ -537,19 +596,41 @@ impl AgentControl { &multi_agent_v2_usage_hint_texts_to_filter, ) ) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_omitted_selected_skill_message(response_item) + ) }); for item in &mut forked_rollout_items { - if let RolloutItem::Compacted(compacted) = item - && let Some(replacement_history) = compacted.replacement_history.as_mut() - { - replacement_history.retain(|response_item| { - !is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - }); + match item { + RolloutItem::ResponseItem(response_item) => { + strip_omitted_selected_skill_catalog(response_item); + } + RolloutItem::Compacted(compacted) => { + let Some(replacement_history) = compacted.replacement_history.as_mut() else { + continue; + }; + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) && !is_omitted_selected_skill_message(response_item) + }); + for response_item in replacement_history.iter_mut() { + strip_omitted_selected_skill_catalog(response_item); + } + replacement_history.retain(|response_item| !is_empty_message(response_item)); + } + _ => {} } } + forked_rollout_items.retain(|item| { + !matches!( + item, + RolloutItem::ResponseItem(response_item) if is_empty_message(response_item) + ) + }); if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 && let Some(subagent_usage_hint_text) = diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 355f45d9af2e..0f69e39a7f34 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -591,6 +591,13 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { let _ = config.features.enable(Feature::MultiAgentV2); let _ = config.features.enable(Feature::Sqlite); let harness = AgentControlHarness::new_with_config(home, config).await; + assert!( + !harness + .manager + .default_environment_selections(&harness.config.cwd) + .is_empty(), + "test must have a default environment to catch reload fallback" + ); let (parent_thread_id, _parent_thread) = harness.start_thread().await; let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); let spawned_agent = harness @@ -607,6 +614,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { })), SpawnAgentOptions { parent_thread_id: Some(parent_thread_id), + environments: Some(Vec::new()), ..Default::default() }, ) @@ -647,11 +655,16 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id) .await .expect("known v2 agent should reload"); - let _ = harness + let reloaded_child = harness .manager .get_thread(spawned_agent.thread_id) .await .expect("reloaded child thread should exist"); + assert_eq!( + reloaded_child.environment_selections().await, + Vec::new(), + "reload should preserve the child environment subset" + ); let communication = InterAgentCommunication::new( AgentPath::root(), diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 80f5b3c02054..fbc4507a48e2 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -60,6 +60,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7c59ec22b7b2..2ecaface1f64 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -601,6 +601,9 @@ impl Session { }, dynamic_tools: session_configuration.dynamic_tools.clone(), selected_capability_roots: selected_capability_roots.clone(), + environment_selections: session_configuration + .environment_selections() + .to_vec(), multi_agent_version: initial_multi_agent_version, history_mode: session_configuration.history_mode, initial_window_id: initial_auto_compact_window_ids diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 696a8adbd839..2d1e2a168f9c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4119,6 +4119,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), @@ -7015,6 +7016,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), @@ -7092,6 +7094,7 @@ async fn submission_loop_channel_close_runs_full_thread_teardown() { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), @@ -9262,6 +9265,7 @@ async fn attach_in_memory_thread_store( base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: Uuid::now_v7().to_string(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index de37484f8bee..3ae8da939420 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1403,8 +1403,14 @@ impl ThreadManagerState { inherited_environments, inherited_exec_policy, } = options; - let environments = - default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + let environments = initial_history + .get_resumed_environment_selections() + .unwrap_or_else(|| { + default_thread_environment_selections( + self.environment_manager.as_ref(), + &config.cwd, + ) + }); let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index e5923bab7958..30e775f79bce 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -69,6 +69,8 @@ async fn handle_spawn_agent( "Agent depth limit reached. Solve the task yourself.".to_string(), )); } + let environments = + spawn_agent_environment_selections(turn.as_ref(), args.environment_ids.as_deref())?; session .emit_turn_item_started( &turn, @@ -133,7 +135,8 @@ async fn handle_spawn_agent( fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), parent_thread_id: Some(session.thread_id), - environments: Some(turn.environments.to_selections()), + environments: Some(environments), + restricted_environment_ids: args.environment_ids.clone(), }, )) .await @@ -236,6 +239,7 @@ struct SpawnAgentArgs { model: Option, reasoning_effort: Option, service_tier: Option, + environment_ids: Option>, #[serde(default)] fork_context: bool, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index f0ebbd73692a..e6b67baf3ed7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -17,9 +17,11 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashSet; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; @@ -151,6 +153,42 @@ pub(crate) fn parse_collab_input( } } +/// Returns the initial environment selections a spawned agent should inherit. +/// +/// An explicit subset may only narrow the parent's ready turn environments. Keep the parent's +/// selection order so narrowing a child does not implicitly reorder its primary environment. +pub(crate) fn spawn_agent_environment_selections( + turn: &TurnContext, + environment_ids: Option<&[String]>, +) -> Result, FunctionCallError> { + let parent_selections = turn.environments.to_selections(); + let Some(environment_ids) = environment_ids else { + return Ok(parent_selections); + }; + + let mut requested_ids = HashSet::with_capacity(environment_ids.len()); + for environment_id in environment_ids { + if !requested_ids.insert(environment_id.as_str()) { + return Err(FunctionCallError::RespondToModel(format!( + "duplicate spawn environment id `{environment_id}`" + ))); + } + if !parent_selections + .iter() + .any(|selection| selection.environment_id.as_str() == environment_id.as_str()) + { + return Err(FunctionCallError::RespondToModel(format!( + "spawn environment id `{environment_id}` is not a ready parent-turn environment" + ))); + } + } + + Ok(parent_selections + .into_iter() + .filter(|selection| requested_ids.contains(selection.environment_id.as_str())) + .collect()) +} + /// Builds the base config snapshot for a newly spawned sub-agent. /// /// The returned config starts from the parent's effective config and then refreshes the diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 23ac4e0bbcf6..0e5dc838c066 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -16,6 +16,7 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Model override for the new agent. Omit unless an explicit override is needed."; const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Service tier override for the new agent. Omit unless explicitly requested."; +const SPAWN_AGENT_ENVIRONMENT_IDS_DESCRIPTION: &str = "Optional subset of ready environment ids attached to the parent turn to attach when starting the child. Omit to inherit all ready parent environments; pass an empty list to attach none. The child keeps matching environments in the parent's order."; const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; @@ -589,6 +590,13 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap ThreadManager { ) } +fn with_windows_environment(mut turn: TurnContext) -> TurnContext { + let mut windows = turn + .environments + .turn_environments + .first() + .expect("test turn should have a local environment") + .clone(); + windows.environment_id = "windows".to_string(); + turn.environments.turn_environments.push(windows); + turn +} + async fn install_role_with_model_override(turn: &mut TurnContext) -> String { let role_name = "fork-context-role".to_string(); tokio::fs::create_dir_all(&turn.config.codex_home) @@ -171,6 +184,111 @@ where } } +#[tokio::test] +async fn spawn_agent_environment_ids_omitted_inherits_parent_environments() { + let (_session, turn) = make_session_and_context().await; + let turn = with_windows_environment(turn); + + let selections = spawn_agent_environment_selections(&turn, /*environment_ids*/ None) + .expect("omitted ids should inherit"); + + assert_eq!(selections, turn.environments.to_selections()); +} + +#[tokio::test] +async fn spawn_agent_environment_ids_restrict_child_to_requested_environment() { + let (_session, turn) = make_session_and_context().await; + let turn = with_windows_environment(turn); + let environment_ids = vec!["windows".to_string()]; + let expected = vec![ + turn.environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == "windows") + .expect("windows environment should exist") + .selection(), + ]; + + let selections = spawn_agent_environment_selections(&turn, Some(&environment_ids)) + .expect("attached environment id should be accepted"); + + assert_eq!(selections, expected); +} + +#[tokio::test] +async fn spawn_agent_environment_ids_empty_attaches_no_environments() { + let (_session, turn) = make_session_and_context().await; + let turn = with_windows_environment(turn); + let environment_ids = Vec::new(); + + let selections = spawn_agent_environment_selections(&turn, Some(&environment_ids)) + .expect("empty environment ids should be accepted"); + + assert_eq!(selections, Vec::new()); +} + +#[tokio::test] +async fn spawn_agent_environment_ids_rejects_unknown_environment() { + let (_session, turn) = make_session_and_context().await; + let turn = with_windows_environment(turn); + let environment_ids = vec!["missing".to_string()]; + + let err = spawn_agent_environment_selections(&turn, Some(&environment_ids)) + .expect_err("unknown environment id should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "spawn environment id `missing` is not a ready parent-turn environment".to_string() + ) + ); +} + +#[tokio::test] +async fn spawn_agent_environment_ids_rejects_duplicate_environment() { + let (_session, turn) = make_session_and_context().await; + let turn = with_windows_environment(turn); + let environment_ids = vec!["windows".to_string(), "windows".to_string()]; + + let err = spawn_agent_environment_selections(&turn, Some(&environment_ids)) + .expect_err("duplicate environment id should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel("duplicate spawn environment id `windows`".to_string()) + ); +} + +#[tokio::test] +async fn spawn_agent_rejects_unknown_environment_before_started_event() { + let (session, turn, rx) = make_session_and_context_with_rx().await; + let session = Arc::new(session); + let invocation = invocation( + Arc::clone(&session), + turn, + "spawn_agent", + function_payload(json!({ + "message": "hello", + "environment_ids": ["missing"] + })), + ); + + let Err(err) = SpawnAgentHandler::default().handle(invocation).await else { + panic!("unknown environment id should be rejected"); + }; + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "spawn environment id `missing` is not a ready parent-turn environment".to_string() + ) + ); + assert!(matches!( + rx.try_recv(), + Err(async_channel::TryRecvError::Empty) + )); +} + #[derive(Debug, Deserialize)] struct ListAgentsResult { agents: Vec, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 1c68849c5e06..2123af05847f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -91,6 +91,8 @@ async fn handle_spawn_agent( ) .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + let environments = + spawn_agent_environment_selections(turn.as_ref(), args.environment_ids.as_deref())?; let spawn_source = thread_spawn_source( session.thread_id, @@ -123,7 +125,8 @@ async fn handle_spawn_agent( fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), fork_mode, parent_thread_id: Some(session.thread_id), - environments: Some(turn.environments.to_selections()), + environments: Some(environments), + restricted_environment_ids: args.environment_ids.clone(), }, ), ) @@ -184,6 +187,7 @@ struct SpawnAgentArgs { model: Option, reasoning_effort: Option, service_tier: Option, + environment_ids: Option>, fork_turns: Option, fork_context: Option, } diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0ca3e1b2a9c5..e241a20c79de 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -76,6 +76,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, @@ -130,6 +131,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 726d74ebfdad..f06d20cd7842 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -373,6 +373,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 88b3f08c6cc9..7d708737ea8c 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -2,6 +2,8 @@ use anyhow::Result; use codex_core::StartThreadOptions; use codex_core::ThreadConfigSnapshot; use codex_core::config::AgentRoleConfig; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_features::Feature; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; @@ -17,6 +19,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -29,6 +32,7 @@ use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; +use core_test_support::skip_if_no_remote_env; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; @@ -49,6 +53,7 @@ use tracing_test::internal::MockWriter; use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; +const LOCAL_EXEC_CALL_ID: &str = "child-local-exec-call"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; @@ -497,6 +502,136 @@ async fn spawn_child_and_capture_snapshot( .await) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawned_agent_can_restrict_inherited_environments() -> Result<()> { + skip_if_no_remote_env!(Ok(())); + + let server = start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "child", + "environment_ids": [REMOTE_ENVIRONMENT_ID], + }))?; + + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), + sse(vec![ + ev_response_created("resp-child-1"), + ev_function_call( + LOCAL_EXEC_CALL_ID, + "exec_command", + &serde_json::to_string(&json!({ + "cmd": "touch local-only-marker", + "environment_id": LOCAL_ENVIRONMENT_ID, + "login": false, + }))?, + ), + ev_completed("resp-child-1"), + ]), + ) + .await; + + let child_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, LOCAL_EXEC_CALL_ID) + && body_contains(req, "unknown turn environment id") + }, + sse(vec![ + ev_response_created("resp-child-2"), + ev_assistant_message("msg-child-2", "child done"), + ev_completed("resp-child-2"), + ]), + ) + .await; + + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !request_has_input_type(req, "agent_message") + }, + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.use_experimental_unified_exec_tool = true; + config.model = Some(INHERITED_MODEL.to_string()); + config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + let expected_remote_selection = test + .codex + .environment_selections() + .await + .into_iter() + .find(|selection| selection.environment_id == REMOTE_ENVIRONMENT_ID) + .ok_or_else(|| anyhow::anyhow!("parent should have the remote environment selected"))?; + + test.submit_turn(TURN_1_PROMPT).await?; + let _ = wait_for_requests(&child_request_log).await?; + let local_exec_output = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(output) = child_followup.function_call_output_text(LOCAL_EXEC_CALL_ID) { + return output; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await?; + + let spawned_id = wait_for_spawned_thread_id(&test).await?; + let child_thread = test + .thread_manager + .get_thread(ThreadId::from_string(&spawned_id)?) + .await?; + + assert_eq!( + child_thread.environment_selections().await, + vec![expected_remote_selection] + ); + assert!( + local_exec_output.contains("unknown turn environment id `local`"), + "unexpected local exec rejection: {local_exec_output}" + ); + assert!(!test.cwd_path().join("local-only-marker").exists()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_start_replaces_session_start_and_injects_context() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 57a36c3312c9..4d1ac0c51722 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -130,7 +130,7 @@ pub fn strip_user_message_prefix(text: &str) -> &str { // TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment // identifiers. -#[derive(Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: PathUri, @@ -2658,6 +2658,11 @@ impl InitialHistory { .and_then(|meta| meta.thread_source.clone()) } + pub fn get_resumed_environment_selections(&self) -> Option> { + self.get_resumed_session_meta() + .and_then(|meta| meta.environment_selections.clone()) + } + pub fn get_session_originator(&self) -> Option { self.get_session_meta() .map(|meta| meta.originator.clone()) @@ -3050,6 +3055,11 @@ pub struct SessionMeta { /// Capability roots selected for this thread by the hosting platform. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub selected_capability_roots: Vec, + /// Initial environment selections attached to this thread. + /// + /// `None` is reserved for rollouts created before selections were persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_selections: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub memory_mode: Option, #[serde(default)] @@ -3082,6 +3092,7 @@ impl Default for SessionMeta { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: ThreadHistoryMode::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 46c90d0120d3..8448df1370aa 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -473,6 +473,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 2e1ea98f3aca..a7dff198f27c 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -51,6 +51,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: ThreadHistoryMode::Paginated, multi_agent_version: None, @@ -145,6 +146,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, @@ -416,6 +418,7 @@ fn write_rollout_in_sessions_with_cwd( base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 87e97997a245..ebffbf7db83c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -63,6 +63,7 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSource; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_state::StateRuntime; use codex_utils_path as path_utils; @@ -82,6 +83,9 @@ pub struct RolloutRecorder { } #[derive(Clone)] +// These params are consumed once during recorder startup, so boxing the larger creation +// variant would add allocation churn without reducing long-lived storage. +#[allow(clippy::large_enum_variant)] pub enum RolloutRecorderParams { Create { session_id: SessionId, @@ -94,6 +98,7 @@ pub enum RolloutRecorderParams { base_instructions: BaseInstructions, dynamic_tools: Vec, selected_capability_roots: Vec, + environment_selections: Vec, multi_agent_version: Option, history_mode: ThreadHistoryMode, initial_window_id: Option, @@ -187,6 +192,7 @@ impl RolloutRecorderParams { base_instructions, dynamic_tools, selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: Default::default(), initial_window_id: None, @@ -214,6 +220,20 @@ impl RolloutRecorderParams { self } + pub fn with_environment_selections( + mut self, + environment_selections: Vec, + ) -> Self { + if let Self::Create { + environment_selections: selections, + .. + } = &mut self + { + *selections = environment_selections; + } + self + } + pub fn with_multi_agent_version( mut self, multi_agent_version: Option, @@ -763,6 +783,7 @@ impl RolloutRecorder { base_instructions, dynamic_tools, selected_capability_roots, + environment_selections, multi_agent_version, history_mode, initial_window_id, @@ -802,6 +823,7 @@ impl RolloutRecorder { Some(dynamic_tools) }, selected_capability_roots, + environment_selections: Some(environment_selections), memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), history_mode, multi_agent_version, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index ba07a4f5c0c2..042be841a363 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -103,6 +103,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 6eb1fa037463..48ed94ba025e 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -42,6 +42,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 18566d887ebc..4bd62782440c 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -175,6 +175,7 @@ fn write_rollout_with_user_message( base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 23f72cb9473e..9bca338f746b 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1333,6 +1333,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ac4201de9f0d..3a777787592b 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -358,6 +358,7 @@ mod tests { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, @@ -557,6 +558,7 @@ mod tests { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: ThreadHistoryMode::Legacy, multi_agent_version: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c80b2b671679..7164b47652b7 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -2181,6 +2181,7 @@ mod tests { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: Some("polluted".to_string()), history_mode: Default::default(), multi_agent_version: None, @@ -2246,6 +2247,7 @@ mod tests { base_instructions: None, dynamic_tools: None, selected_capability_roots: Vec::new(), + environment_selections: None, memory_mode: None, history_mode: Default::default(), multi_agent_version: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 0d6ea80f2a01..32e0d104450b 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -130,6 +130,7 @@ mod tests { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: ThreadHistoryMode::Legacy, initial_window_id: uuid::Uuid::now_v7().to_string(), @@ -325,6 +326,7 @@ mod tests { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode, initial_window_id: uuid::Uuid::now_v7().to_string(), @@ -439,6 +441,7 @@ impl InMemoryThreadStore { base_instructions: Some(params.base_instructions.clone()), dynamic_tools: (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()), selected_capability_roots: params.selected_capability_roots.clone(), + environment_selections: Some(params.environment_selections.clone()), memory_mode: matches!(params.metadata.memory_mode, ThreadMemoryMode::Disabled) .then_some("disabled".to_string()), history_mode: params.history_mode, diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 9b370ef095f2..006074e516a0 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -41,6 +41,7 @@ pub(super) async fn create_thread( ) .with_session_id(params.session_id) .with_selected_capability_roots(params.selected_capability_roots) + .with_environment_selections(params.environment_selections) .with_multi_agent_version(params.multi_agent_version) .with_history_mode(params.history_mode) .with_initial_window_id(params.initial_window_id), diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 627b001c494e..f25bb84c0758 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1281,6 +1281,7 @@ mod tests { base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), + environment_selections: Vec::new(), multi_agent_version: None, history_mode: ThreadHistoryMode::Legacy, initial_window_id: uuid::Uuid::now_v7().to_string(), diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 4e688eca6f1f..27959338aa50 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnEnvironmentSelection; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -90,6 +91,9 @@ pub struct CreateThreadParams { /// Environment-qualified capability roots selected for this thread. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub selected_capability_roots: Vec, + /// Initial environment selections attached to this thread. + #[serde(default)] + pub environment_selections: Vec, /// Multi-agent runtime selected when the thread was created. pub multi_agent_version: Option, /// Persisted thread history contract selected when the thread was created.