diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index f5dd78977f..a68079ab20 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::config::ConfigBuilder; use crate::config::PermissionProfileSnapshot; use crate::context::ContextualUserFragment; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::environment_selection::TurnEnvironmentState; use crate::session::turn_context::TurnEnvironment; @@ -338,20 +339,20 @@ fn resolved_local_environments( environment_id: environment_id.to_string(), cwd: PathUri::from_abs_path(&cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::read_only(), + ), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, Arc::new( Environment::create_for_tests(/*exec_server_url*/ None) .expect("local environment"), ), /*shell*/ None, - EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::legacy( - PermissionProfile::read_only(), - ), - selected_capability_roots: Vec::new(), - }, )) }) .collect(), diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index d256bd03f4..c52b66903d 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -34,6 +34,26 @@ use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; use crate::shell_snapshot::ShellSnapshot; +/// Records whether a normalized config should follow later thread setting updates. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnvironmentConfigOrigin { + Thread, + Owner, +} + +impl EnvironmentConfigOrigin { + /// Reconstructs the input form so another attachment boundary preserves config ownership. + pub(crate) fn into_input_selection( + self, + mut selection: TurnEnvironmentSelection, + ) -> TurnEnvironmentSelection { + if self == Self::Thread { + selection.config = EnvironmentConfigState::FromThread; + } + selection + } +} + pub(crate) fn default_thread_environment_selections( environment_manager: &EnvironmentManager, cwd: &AbsolutePathBuf, @@ -66,7 +86,7 @@ struct ResolvedEnvironment { #[derive(Clone)] struct SelectedTurnEnvironment { selection: TurnEnvironmentSelection, - config: EnvironmentConfig, + config_origin: EnvironmentConfigOrigin, environment: Arc, // Selection clones share one listener; the final handle drop aborts it. connection_events_task: Option>>, @@ -76,24 +96,33 @@ struct SelectedTurnEnvironment { #[derive(Clone)] pub(crate) struct StartingTurnEnvironment { pub(crate) selection: TurnEnvironmentSelection, - config: EnvironmentConfig, + config_origin: EnvironmentConfigOrigin, resolution: TurnEnvironmentResolution, } +/// Resolves config once at the attachment boundary and records who owns future updates. +fn resolve_selection_config( + mut selection: TurnEnvironmentSelection, + thread_config: &EnvironmentConfig, +) -> (TurnEnvironmentSelection, EnvironmentConfigOrigin) { + let (config, origin) = match selection.config { + EnvironmentConfigState::FromThread => { + (thread_config.clone(), EnvironmentConfigOrigin::Thread) + } + EnvironmentConfigState::Ready(config) => (config, EnvironmentConfigOrigin::Owner), + EnvironmentConfigState::Pending => { + unreachable!("pending environment configuration is not supported yet") + } + }; + selection.config = EnvironmentConfigState::Ready(config); + (selection, origin) +} + impl SelectedTurnEnvironment { - fn apply_configuration( - &mut self, - selection_config: EnvironmentConfigState, - thread_config: &EnvironmentConfig, - ) { - self.config = match &selection_config { - EnvironmentConfigState::FromThread => thread_config.clone(), - EnvironmentConfigState::Ready(config) => config.clone(), - EnvironmentConfigState::Pending => { - unreachable!("pending environment configuration is not supported yet") - } - }; - self.selection.config = selection_config; + fn refresh_thread_config(&mut self, config: &EnvironmentConfig) { + if self.config_origin == EnvironmentConfigOrigin::Thread { + self.selection.config = EnvironmentConfigState::Ready(config.clone()); + } } } @@ -139,9 +168,9 @@ impl ThreadEnvironments { let TurnEnvironmentState::Ready(environment) = environment else { return None; }; - let selection = environment.selection(); + let selection = environment.selection; + let config_origin = environment.config_origin; let selected_environment = Arc::clone(&environment.environment); - let inherited_config = environment.config; let resolution: TurnEnvironmentResolution = futures::future::ready(Ok(ResolvedEnvironment { environment: environment.environment, @@ -152,17 +181,13 @@ impl ThreadEnvironments { .shared(); let mut inherited_environment = SelectedTurnEnvironment { selection, - config: inherited_config, + config_origin, environment: selected_environment, connection_events_task: None, resolution, }; - // Child threads get their own settings, but inherit any - // environment-owned policy that was already installed. - inherited_environment.apply_configuration( - inherited_environment.selection.config.clone(), - &thread_environment_config, - ); + // Child threads re-infer thread-owned config while preserving owner config. + inherited_environment.refresh_thread_config(&thread_environment_config); Some(inherited_environment) }) .collect(); @@ -188,6 +213,8 @@ impl ThreadEnvironments { if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) { continue; } + let (selected_environment, config_origin) = + resolve_selection_config(selected_environment.clone(), thread_environment_config); if let Some(environment) = previous.iter().find(|environment| { let previous = &environment.selection; previous.environment_id == selected_environment.environment_id @@ -196,10 +223,8 @@ impl ThreadEnvironments { }) && !matches!(environment.resolution.clone().now_or_never(), Some(Err(_))) { let mut environment = environment.clone(); - environment.apply_configuration( - selected_environment.config.clone(), - thread_environment_config, - ); + environment.selection = selected_environment; + environment.config_origin = config_origin; next.push(environment); continue; } @@ -235,17 +260,13 @@ impl ThreadEnvironments { .remote_handle(); drop(tokio::spawn(resolution_task)); let resolution = resolution.boxed().shared(); - let mut selected = SelectedTurnEnvironment { - selection: selected_environment.clone(), - config: thread_environment_config.clone(), + let selected = SelectedTurnEnvironment { + selection: selected_environment, + config_origin, environment, connection_events_task, resolution, }; - selected.apply_configuration( - selected_environment.config.clone(), - thread_environment_config, - ); next.push(selected); } let removed_connection_tasks = previous @@ -267,11 +288,16 @@ impl ThreadEnvironments { } } + /// Projects canonical selections back to caller input form without losing config ownership. pub(crate) fn selections(&self) -> Vec { self.environments .load() .iter() - .map(|environment| environment.selection.clone()) + .map(|environment| { + environment + .config_origin + .into_input_selection(environment.selection.clone()) + }) .collect() } @@ -299,14 +325,15 @@ impl ThreadEnvironments { .unwrap_or_default() } - pub(crate) fn update_environment_configs(&self, config: &EnvironmentConfig) { + /// Refreshes attachments whose configuration is inferred from the thread. + pub(crate) fn update_thread_config(&self, config: &EnvironmentConfig) { let environments = self .environments .load() .iter() .map(|environment| { let mut environment = environment.clone(); - environment.apply_configuration(environment.selection.config.clone(), config); + environment.refresh_thread_config(config); environment }) .collect(); @@ -331,8 +358,8 @@ impl ThreadEnvironments { ))); }; - let thread_config = environment.config.clone(); - environment.apply_configuration(EnvironmentConfigState::Ready(config), &thread_config); + environment.selection.config = EnvironmentConfigState::Ready(config); + environment.config_origin = EnvironmentConfigOrigin::Owner; self.environments.store(Arc::new(environments)); Ok(()) } @@ -346,8 +373,10 @@ impl ThreadEnvironments { let environments = self.environments.load(); let mut selected_capability_roots = thread_selected_capability_roots.to_vec(); for environment in environments.iter() { - selected_capability_roots - .extend(environment.config.selected_capability_roots.iter().cloned()); + let EnvironmentConfigState::Ready(config) = &environment.selection.config else { + unreachable!("selected environments always carry resolved configuration") + }; + selected_capability_roots.extend(config.selected_capability_roots.iter().cloned()); } let mut seen_root_ids = HashSet::with_capacity(selected_capability_roots.len()); selected_capability_roots.retain(|root| seen_root_ids.insert(root.id.clone())); @@ -491,7 +520,7 @@ impl ThreadEnvironments { if let Some(environment) = TurnEnvironmentState::from_resolution( StartingTurnEnvironment { selection: environment.selection.clone(), - config: environment.config.clone(), + config_origin: environment.config_origin, resolution: environment.resolution.clone(), }, resolved, @@ -522,9 +551,9 @@ impl TurnEnvironmentState { Some(Ok(environment)) => { let mut turn_environment = TurnEnvironment::new( starting.selection, + starting.config_origin, environment.environment, environment.shell, - starting.config, ); turn_environment.shell_snapshot = environment.shell_snapshot; Some(Self::Ready(turn_environment)) @@ -897,7 +926,7 @@ url = "ws://127.0.0.1:8765" let environment = snapshot.primary().expect("local environment"); assert_eq!(environment.shell.as_ref(), Some(&local_shell)); - assert_eq!(environment.config, expected_config); + assert_eq!(environment.config(), &expected_config); } #[tokio::test] @@ -1107,6 +1136,10 @@ url = "ws://127.0.0.1:8765" turn_environments.update_selections(&[remote.clone(), local.clone()], &expected_config); let starting = turn_environments.snapshot().await; + let resolved_remote = TurnEnvironmentSelection { + config: EnvironmentConfigState::Ready(expected_config.clone()), + ..remote.clone() + }; assert_eq!( starting .turn_environments() @@ -1117,7 +1150,7 @@ url = "ws://127.0.0.1:8765" assert_eq!( starting .turn_environments() - .map(|environment| environment.config.clone()) + .map(|environment| environment.config().clone()) .collect::>(), vec![expected_config.clone()] ); @@ -1126,13 +1159,13 @@ url = "ws://127.0.0.1:8765" .starting() .map(|environment| environment.selection.clone()) .collect::>(), - vec![remote.clone()] + vec![resolved_remote.clone()] ); assert_eq!(starting.to_selections(), vec![local.clone()]); assert!(starting.single_local_environment().is_none()); let next_config = test_environment_config(); - turn_environments.update_environment_configs(&next_config); + turn_environments.update_thread_config(&next_config); let next_starting = turn_environments.snapshot().await; let server = tokio::spawn(serve_environment_info(listener)); @@ -1161,7 +1194,7 @@ url = "ws://127.0.0.1:8765" assert_eq!( attached .turn_environments() - .map(|environment| environment.config.clone()) + .map(|environment| environment.config().clone()) .collect::>(), vec![expected_config.clone(), expected_config] ); @@ -1170,7 +1203,7 @@ url = "ws://127.0.0.1:8765" next_starting .refresh_readiness() .turn_environments() - .map(|environment| environment.config.clone()) + .map(|environment| environment.config().clone()) .collect::>(), vec![next_config.clone(), next_config] ); @@ -1240,7 +1273,7 @@ url = "ws://127.0.0.1:8765" allow_login_shell: false, ..test_environment_config() }; - environments.update_environment_configs(&next_config); + environments.update_thread_config(&next_config); assert!( failed_resolution.ptr_eq(&environments.environments.load()[0].resolution), "updating environment config must not retry a failed environment" @@ -1252,7 +1285,14 @@ url = "ws://127.0.0.1:8765" .starting() .next() .expect("expected the replacement environment to be starting"); - assert_eq!(replacement.selection, selection); + assert_eq!( + replacement.selection, + TurnEnvironmentSelection { + config: EnvironmentConfigState::Ready(next_config), + ..selection.clone() + } + ); + assert_eq!(environments.selections(), vec![selection]); assert!(!failed_resolution.ptr_eq(&replacement.resolution)); } @@ -1373,10 +1413,13 @@ url = "ws://127.0.0.1:8765" .expect("inherited environment"), ); let inherited = TurnEnvironment::new( - selection.clone(), + TurnEnvironmentSelection { + config: EnvironmentConfigState::Ready(test_environment_config()), + ..selection.clone() + }, + EnvironmentConfigOrigin::Thread, Arc::clone(&inherited_environment), /*shell*/ None, - test_environment_config(), ); let manager = Arc::new(environment_manager_without_environments()); manager @@ -1411,7 +1454,7 @@ url = "ws://127.0.0.1:8765" let inherited = snapshot.primary().expect("inherited environment"); assert!(Arc::ptr_eq(&inherited.environment, &inherited_environment)); - assert_eq!(inherited.config, child_config); + assert_eq!(inherited.config(), &child_config); } #[tokio::test] @@ -1448,6 +1491,16 @@ url = "ws://127.0.0.1:8765" ), ..test_environment_config() }; + parent.update_thread_config(&child_thread_config); + assert_eq!( + parent + .snapshot() + .await + .primary() + .expect("owner-configured environment") + .config(), + &parent_owner_config + ); let child = ThreadEnvironments::new( manager, crate::shell::default_user_shell(), @@ -1471,10 +1524,10 @@ url = "ws://127.0.0.1:8765" cleared_snapshot .primary() .expect("environment with cleared roots") - .config, - cleared_parent_owner_config + .config(), + &cleared_parent_owner_config ); - assert_eq!(child_environment.config, parent_owner_config); + assert_eq!(child_environment.config(), &parent_owner_config); let changed_selection = TurnEnvironmentSelection { cwd: PathUri::from_abs_path(&cwd.join("changed")), @@ -1484,7 +1537,7 @@ url = "ws://127.0.0.1:8765" parent.update_selections(std::slice::from_ref(&changed_selection), &new_thread_config); let changed_snapshot = parent.snapshot().await; let changed_environment = changed_snapshot.primary().expect("changed environment"); - assert_eq!(changed_environment.config, new_thread_config); + assert_eq!(changed_environment.config(), &new_thread_config); } #[tokio::test] @@ -1514,11 +1567,11 @@ url = "ws://127.0.0.1:8765" environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd_uri.clone(), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(test_environment_config()), }, + EnvironmentConfigOrigin::Thread, remote_environment.clone(), /*shell*/ None, - test_environment_config(), ))], }; let multiple = TurnEnvironmentSnapshot { @@ -1528,11 +1581,11 @@ url = "ws://127.0.0.1:8765" environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: remote_cwd_uri, workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(test_environment_config()), }, + EnvironmentConfigOrigin::Thread, remote_environment, /*shell*/ None, - test_environment_config(), )), TurnEnvironmentState::Ready(local.primary().expect("local environment").clone()), ], @@ -1555,9 +1608,9 @@ url = "ws://127.0.0.1:8765" environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(test_environment_config()), }, - config: test_environment_config(), + config_origin: EnvironmentConfigOrigin::Thread, resolution: futures::future::pending().boxed().shared(), })], }; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 9d548e5867..0d3691d8a9 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1057,18 +1057,22 @@ async fn run_review_on_session( .permissions .permission_profile_state() .snapshot(); - let mut parent_turn_environments = params.parent_context.environments().to_selections(); - // FromThread inherits the Guardian profile below. Once Core normalizes FromThread - // to Ready(config), every selection will carry explicit config and this conditional can be - // removed. - for selection in &mut parent_turn_environments { - if let EnvironmentConfigState::Ready(config) = &mut selection.config { + // Guardian must receive read-only permissions for every inherited environment. + let parent_turn_environments = params + .parent_context + .environments() + .turn_environments() + .map(|environment| { + let mut selection = environment.selection(); + let mut config = environment.config().clone(); config.permission_profile = PermissionProfileSnapshot::legacy(read_only_guardian_permission_profile( config.permission_profile.permission_profile(), )); - } - } + selection.config = EnvironmentConfigState::Ready(config); + selection + }) + .collect(); // TODO(anp): Migrate guardian review thread settings to a PathUri fallback cwd so foreign // parent environments do not fall back to the host-native config cwd. let parent_turn_legacy_fallback_cwd = params diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index c421c94dff..b83419ad22 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -628,7 +628,7 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re let TurnEnvironmentState::Ready(environment) = &mut turn.environments.environments[0] else { panic!("parent environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(environment_permission_profile); environment.selection.workspace_roots = vec![ PathUri::from_abs_path(&workspace_root), diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index c6f3d37eb1..e0a0cda2d0 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -367,7 +367,7 @@ mod tests { .primary() .expect("ready primary environment"); let selection = environment.selection(); - let environment_config = environment.config.clone(); + let environment_config = environment.config().clone(); let environments = crate::environment_selection::ThreadEnvironments::new( session.services.turn_environments.environment_manager(), crate::shell::default_user_shell(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 7195ebb7b2..2160515213 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::config::ConfigBuilder; use crate::config::ManagedFeatures; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentState; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; @@ -1112,19 +1113,19 @@ async fn mcp_sandbox_cwd_uses_matching_server_environment_uri() -> anyhow::Resul environment_id: "remote".to_string(), cwd: secondary_cwd.clone(), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: turn_context + .config + .permissions + .permission_profile_state() + .snapshot(), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, environment, /*shell*/ None, - EnvironmentConfig { - allow_login_shell: true, - permission_profile: turn_context - .config - .permissions - .permission_profile_state() - .snapshot(), - selected_capability_roots: Vec::new(), - }, ))); let step_context = StepContext::for_test(Arc::new(turn_context)); diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cfddb30f17..e12857e65d 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -448,7 +448,7 @@ impl Session { .chain( environments .turn_environments() - .flat_map(|environment| environment.config.selected_capability_roots.clone()), + .flat_map(|environment| environment.config().selected_capability_roots.clone()), ) .enumerate() { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 33af3adc42..9bb00c2d0a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1596,7 +1596,7 @@ impl Session { } else if state.session_configuration.turn_environment_config() != environment_config { self.services .turn_environments - .update_environment_configs(&environment_config); + .update_thread_config(&environment_config); } state.session_configuration = updated; let new_config = notify_config_contributors diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 46f351e0bb..beb48f4a38 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7,6 +7,7 @@ use crate::config::ConfigOverrides; use crate::config::test_config; use crate::context::ContextualUserFragment; use crate::context::TurnAborted; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::ThreadEnvironments; use crate::environment_selection::TurnEnvironmentState; use crate::function_tool::FunctionCallError; @@ -5450,7 +5451,7 @@ async fn permission_profile_updates_apply_to_next_turn_environment() { .environments .primary() .expect("active turn environment") - .config + .config() .clone(); let profile_root = active_turn.config.cwd.join("profile-root"); let active_profile = ActivePermissionProfile::read_only(); @@ -5485,14 +5486,14 @@ async fn permission_profile_updates_apply_to_next_turn_environment() { vec![profile_root], ); - assert_eq!(next_environment.config, expected_environment_config); + assert_eq!(next_environment.config(), &expected_environment_config); assert_eq!( active_turn .environments .primary() .expect("active turn environment") - .config, - active_environment_config + .config(), + &active_environment_config ); } } @@ -6639,17 +6640,18 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir .primary() .expect("primary environment") .clone(); + let current_environment_config = current_environment.config().clone(); turn_context_mut.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( TurnEnvironmentSelection { environment_id: "remote".to_string(), cwd: PathUri::from_abs_path(&environment_cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(current_environment_config), }, + current_environment.config_origin, current_environment.environment, current_environment.shell, - current_environment.config, )); let call_id = "call-1".to_string(); @@ -7237,6 +7239,7 @@ async fn primary_environment_uses_first_turn_environment() { #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd); + let first_environment_config = first_environment.config().clone(); turn_context .environments .environments @@ -7245,11 +7248,11 @@ async fn primary_environment_uses_first_turn_environment() { environment_id: "second".to_string(), cwd: second_cwd_uri.clone(), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(first_environment_config), }, + first_environment.config_origin, Arc::clone(&first_environment.environment), /*shell*/ None, - first_environment.config.clone(), ))); assert_eq!( @@ -8624,7 +8627,7 @@ async fn conflicting_ready_environment_root_ids_keep_first_location() { environment_id, .. } = &selected_root.location; - let mut environment_config = local_environment.config.clone(); + let mut environment_config = local_environment.config().clone(); environment_config.selected_capability_roots = vec![selected_root.clone()]; turn_environments.push(TurnEnvironment::new( TurnEnvironmentSelection { @@ -8633,12 +8636,12 @@ async fn conflicting_ready_environment_root_ids_keep_first_location() { workspace_roots: local_environment.workspace_roots().to_vec(), config: EnvironmentConfigState::Ready(environment_config.clone()), }, + EnvironmentConfigOrigin::Owner, Arc::new( codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) .expect("create test environment"), ), local_environment.shell.clone(), - environment_config, )); } let environments = TurnEnvironmentSnapshot { @@ -8691,7 +8694,7 @@ async fn capability_discovery_uses_environment_permission_profile() { access: FileSystemAccessMode::Deny, missing_path_behavior: None, }); - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, @@ -8888,17 +8891,18 @@ async fn record_context_updates_emits_environment_item_for_cwd_changes() { .primary() .expect("primary environment") .clone(); + let environment_config = environment.config().clone(); current_context.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( TurnEnvironmentSelection { environment_id: environment.selection.environment_id, cwd: PathUri::from_abs_path(&cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(environment_config), }, + environment.config_origin, environment.environment, environment.shell, - environment.config, )); let update_items = @@ -8927,7 +8931,7 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace .primary() .expect("primary environment") .clone(); - previous_environment.config.permission_profile = + previous_environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::Disabled); previous_context.environments.environments[0] = TurnEnvironmentState::Ready(previous_environment); @@ -8945,7 +8949,7 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace .clone(); let cwd = environment.cwd().clone(); let workspace_root = current_context.config.cwd.join("selected-workspace"); - let mut environment_config = environment.config; + let mut environment_config = environment.config().clone(); environment_config.permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write()); current_context.environments.environments[0] = @@ -8954,11 +8958,11 @@ async fn record_context_updates_use_environment_permission_profile_and_workspace environment_id: environment.selection.environment_id, cwd, workspace_roots: vec![PathUri::from_abs_path(&workspace_root)], - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(environment_config), }, + environment.config_origin, environment.environment, environment.shell, - environment_config, )); let update_items = @@ -9024,17 +9028,18 @@ async fn record_context_updates_omits_environment_item_when_disabled() { .primary() .expect("primary environment") .clone(); + let environment_config = environment.config().clone(); current_context.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( TurnEnvironmentSelection { environment_id: environment.selection.environment_id, cwd: PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(environment_config), }, + environment.config_origin, environment.environment, environment.shell, - environment.config, )); let update_items = @@ -9497,16 +9502,17 @@ async fn turn_context_item_stores_local_cwd() { .expect("primary environment") .clone(); let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI"); + let environment_config = environment.config().clone(); turn_context.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( TurnEnvironmentSelection { environment_id: "remote".to_string(), cwd, workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(environment_config), }, + environment.config_origin, environment.environment, environment.shell, - environment.config, )); #[allow(deprecated)] diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 12ccbf92d0..dd0efd1857 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -349,7 +349,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli else { panic!("primary environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = config.permissions.permission_profile_state().snapshot(); config.codex_linux_sandbox_exe = codex_linux_sandbox_exe_or_skip!(); config @@ -471,7 +471,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ else { panic!("primary environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = config.permissions.permission_profile_state().snapshot(); config.approvals_reviewer = ApprovalsReviewer::User; config.model_provider.base_url = Some(format!("{}/v1", server.uri())); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a11bef12f4..03f204c75a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,4 +1,5 @@ use super::*; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::exec_policy::AllowPrefixRules; use crate::shell_snapshot::ShellSnapshotFile; @@ -15,6 +16,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::EnvironmentConfig; +use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadHistoryMode; @@ -34,28 +36,44 @@ pub(crate) type ShellSnapshotTask = Shared, pub(crate) shell: Option, - pub(crate) config: EnvironmentConfig, pub(crate) shell_snapshot: ShellSnapshotTask, } impl TurnEnvironment { pub(crate) fn new( selection: TurnEnvironmentSelection, + config_origin: EnvironmentConfigOrigin, environment: Arc, shell: Option, - config: EnvironmentConfig, ) -> Self { + debug_assert!(matches!(selection.config, EnvironmentConfigState::Ready(_))); Self { selection, + config_origin, environment, shell, - config, shell_snapshot: futures::future::ready(None).boxed().shared(), } } + pub(crate) fn config(&self) -> &EnvironmentConfig { + let EnvironmentConfigState::Ready(config) = &self.selection.config else { + unreachable!("ready turn environments always carry resolved configuration") + }; + config + } + + #[cfg(test)] + pub(crate) fn config_mut(&mut self) -> &mut EnvironmentConfig { + let EnvironmentConfigState::Ready(config) = &mut self.selection.config else { + unreachable!("ready turn environments always carry resolved configuration") + }; + config + } + pub(crate) fn shell_snapshot(&self, cwd: &AbsolutePathBuf) -> Option { if self.selection.cwd != PathUri::from_abs_path(cwd) { return None; @@ -75,11 +93,11 @@ impl TurnEnvironment { } pub(crate) fn permission_profile(&self) -> &PermissionProfile { - self.config.permission_profile.permission_profile() + self.config().permission_profile.permission_profile() } pub(crate) fn active_permission_profile(&self) -> Option { - self.config.permission_profile.active_permission_profile() + self.config().permission_profile.active_permission_profile() } pub(crate) fn permission_profile_with_workspace_roots(&self) -> PermissionProfile { @@ -94,7 +112,8 @@ impl TurnEnvironment { } pub(crate) fn selection(&self) -> TurnEnvironmentSelection { - self.selection.clone() + self.config_origin + .into_input_selection(self.selection.clone()) } } @@ -106,7 +125,8 @@ impl std::fmt::Debug for TurnEnvironment { .field("cwd", &self.selection.cwd) .field("workspace_roots", &self.selection.workspace_roots) .field("shell", &self.shell) - .field("config", &self.config) + .field("config", self.config()) + .field("config_origin", &self.config_origin) .finish_non_exhaustive() } } @@ -690,7 +710,7 @@ impl Session { { self.services .turn_environments - .update_environment_configs(&environment_config); + .update_thread_config(&environment_config); } if mcp_inputs_changed { self.mark_mcp_runtime_dirty(); 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 c18e2ae016..93a6d92aa9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2298,7 +2298,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { else { panic!("parent environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(expected_permission_profile.clone()); assert_ne!( role_config.permissions.effective_permission_profile(), @@ -4572,7 +4572,7 @@ async fn build_agent_resume_config_clears_base_instructions() { else { panic!("parent environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(environment_permission_profile.clone()); let config = diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 257e678da8..04d3011c3e 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -101,8 +101,10 @@ impl ShellCommandHandler { .shell .as_ref() .unwrap_or(session_shell.as_ref()); - let use_login_shell = - Self::resolve_use_login_shell(params.login, turn_environment.config.allow_login_shell)?; + let use_login_shell = Self::resolve_use_login_shell( + params.login, + turn_environment.config().allow_login_shell, + )?; let command = Self::base_command(shell, ¶ms.command, use_login_shell); let mut env = create_env( diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 1a7fed0194..600888eec5 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -6,6 +6,7 @@ use codex_protocol::models::ShellCommandToolCallParams; use pretty_assertions::assert_eq; use crate::config::PermissionProfileSnapshot; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; use crate::exec_env::create_env; use crate::exec_env::inject_permission_profile_env; @@ -109,8 +110,16 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { environment_id: "selected-environment".to_string(), cwd: PathUri::from_abs_path(&selected_cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::active( + permission_profile, + active_permission_profile.clone(), + ), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, Arc::clone( &turn_context .environments @@ -119,14 +128,6 @@ async fn shell_command_handler_to_exec_params_uses_selected_environment() { .environment, ), Some(selected_shell), - EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::active( - permission_profile, - active_permission_profile.clone(), - ), - selected_capability_roots: Vec::new(), - }, ); let mut expected_env = create_env( &turn_context.config.permissions.shell_environment_policy, @@ -210,7 +211,7 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() { .primary() .expect("primary environment") .clone(); - turn_environment.config.allow_login_shell = false; + turn_environment.config_mut().allow_login_shell = false; let cwd = turn_environment .cwd() .to_abs_path() diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index e4e5624ee0..f7a24e86d4 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -233,7 +233,7 @@ impl ExecCommandHandler { &args, shell, &shell_mode, - turn_environment.config.allow_login_shell, + turn_environment.config().allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; let command = resolved_command.command; diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 626bb90414..eee95fd43a 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -183,7 +183,7 @@ async fn exec_command_rejects_login_when_selected_environment_disallows_it() { else { panic!("primary environment should be ready"); }; - environment.config.allow_login_shell = false; + environment.config_mut().allow_login_shell = false; let turn = Arc::new(turn); let invocation = ToolInvocation { diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 075460de7c..5e6bf3b0b4 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -290,9 +290,9 @@ mod tests { selection.workspace_roots.clear(); turn.environments.environments[0] = TurnEnvironmentState::Ready(TurnEnvironment::new( selection, + current.config_origin, current.environment, current.shell, - current.config, )); } @@ -358,7 +358,7 @@ mod tests { else { panic!("primary environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::read_only()); let turn = Arc::new(turn); @@ -430,7 +430,7 @@ mod tests { else { panic!("primary environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::Disabled); let turn = Arc::new(turn); @@ -467,7 +467,7 @@ mod tests { else { panic!("primary environment should be ready"); }; - environment.config.permission_profile = + environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(PermissionProfile::Disabled); let turn = Arc::new(turn); diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index b62882c471..0b4fe66c55 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::PermissionProfileSnapshot; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::tools::sandboxing::SandboxAttempt; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -24,15 +25,17 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: environment_id: environment_id.to_string(), cwd: PathUri::from_abs_path(&std::env::temp_dir().abs()), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::read_only(), + ), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), /*shell*/ None, - EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), - selected_capability_roots: Vec::new(), - }, ) } diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index a336983a53..04301bf669 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::PermissionProfileSnapshot; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::tools::approvals::ApprovalCacheKey; use codex_exec_server::Environment; use codex_protocol::models::PermissionProfile; @@ -21,17 +22,17 @@ async fn approval_key_uses_path_uri_and_includes_environment_id() { environment_id: "remote".to_string(), cwd: PathUri::from_abs_path(&cwd), workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::read_only(), + ), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, Arc::new(Environment::default_for_tests()), /*shell*/ None, - EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::legacy( - PermissionProfile::read_only(), - ), - selected_capability_roots: Vec::new(), - }, ), shell_type: None, hook_command: "echo hello".to_string(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b49843feba..1daebe95d2 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -514,6 +514,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt mod tests { use super::*; use crate::config::PermissionProfileSnapshot; + use crate::environment_selection::EnvironmentConfigOrigin; use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use crate::tools::sandboxing::ToolRuntime; use codex_exec_server::Environment; @@ -536,17 +537,17 @@ mod tests { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd, workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: true, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::read_only(), + ), + selected_capability_roots: Vec::new(), + }), }, + EnvironmentConfigOrigin::Thread, Arc::new(Environment::default_for_tests()), /*shell*/ None, - EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::legacy( - PermissionProfile::read_only(), - ), - selected_capability_roots: Vec::new(), - }, ) } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index e094213a23..3da01c0023 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -954,7 +954,7 @@ fn tool_environment_mode(environments: &TurnEnvironmentSnapshot) -> ToolEnvironm fn any_environment_allows_login_shell(environments: &TurnEnvironmentSnapshot) -> bool { environments .turn_environments() - .any(|environment| environment.config.allow_login_shell) + .any(|environment| environment.config().allow_login_shell) } #[instrument(level = "trace", skip_all)] diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 8eca4327aa..97b3c9e7d4 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -699,7 +699,7 @@ async fn login_shell_parameter_follows_selected_environment() { else { panic!("primary environment should be ready"); }; - environment.config.allow_login_shell = allow_login_shell; + environment.config_mut().allow_login_shell = allow_login_shell; if guardian { turn.session_source = codex_protocol::protocol::SessionSource::SubAgent( codex_protocol::protocol::SubAgentSource::Other( @@ -731,7 +731,7 @@ async fn login_shell_parameter_is_available_when_any_environment_allows_it() { let TurnEnvironmentState::Ready(environment) = environment else { panic!("environment should be ready"); }; - environment.config.allow_login_shell = index == 1; + environment.config_mut().allow_login_shell = index == 1; } }) .await; @@ -908,8 +908,19 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava environment_id: "remote".to_string(), cwd: remote_cwd, workspace_roots: Vec::new(), - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready( + codex_protocol::protocol::EnvironmentConfig { + allow_login_shell: true, + permission_profile: turn + .config + .permissions + .permission_profile_state() + .snapshot(), + selected_capability_roots: Vec::new(), + }, + ), }, + crate::environment_selection::EnvironmentConfigOrigin::Thread, Arc::new( codex_exec_server::Environment::create_for_tests(Some( "ws://127.0.0.1:1/remote-exec-server".to_string(), @@ -917,15 +928,6 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava .expect("remote test environment"), ), /*shell*/ None, - codex_protocol::protocol::EnvironmentConfig { - allow_login_shell: true, - permission_profile: turn - .config - .permissions - .permission_profile_state() - .snapshot(), - selected_capability_roots: Vec::new(), - }, ), )); }) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 69604aab33..1b31111a7d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -437,8 +437,9 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ready_environment_permissions_override_thread_permissions() -> Result<()> { - const CALL_ID: &str = "attachment-specific-permissions"; +async fn environment_permissions_follow_configuration_ownership() -> Result<()> { + const THREAD_CONFIG_CALL_ID: &str = "thread-config-permissions"; + const OWNER_CONFIG_CALL_ID: &str = "owner-config-permissions"; const FILE_NAME: &str = "attachment-read-only-marker.txt"; skip_if_target_windows!( @@ -459,13 +460,8 @@ async fn ready_environment_permissions_override_thread_permissions() -> Result<( .expect("thread should allow workspace writes"); }); let test = builder.build_with_auto_env(&server).await?; - let mut selection = test.executor_environment().selection().clone(); + let selection = test.executor_environment().selection().clone(); let marker = selection.cwd.join(FILE_NAME)?; - selection.config = EnvironmentConfigState::Ready(EnvironmentConfig { - allow_login_shell: test.config.permissions.allow_login_shell, - permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), - selected_capability_roots: Vec::new(), - }); let (shell, command) = match test_target_os() { TestTargetOs::Linux => ( @@ -499,7 +495,7 @@ async fn ready_environment_permissions_override_thread_permissions() -> Result<( vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(CALL_ID, "exec_command", &arguments), + ev_function_call(THREAD_CONFIG_CALL_ID, "exec_command", &arguments), ev_completed("resp-1"), ]), sse(vec![ @@ -507,17 +503,77 @@ async fn ready_environment_permissions_override_thread_permissions() -> Result<( ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call(OWNER_CONFIG_CALL_ID, "exec_command", &arguments), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-4"), + ]), ], ) .await; - test.submit_turn_with_environments("try to write a file", Some(vec![selection])) + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + permission_profile: Some(PermissionProfile::read_only()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("try to write a file with thread-owned permissions") .await?; let output = response_mock .last_request() .context("model should receive the command output")? - .function_call_output_text(CALL_ID) + .function_call_output_text(THREAD_CONFIG_CALL_ID) + .context("shell tool result should be present")?; + assert!( + output.contains("WRITE_DENIED"), + "unexpected output: {output}" + ); + assert!(!output.contains("WRITE_SUCCEEDED")); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + environments: Some(TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![TurnEnvironmentSelection { + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: test.config.permissions.allow_login_shell, + permission_profile: PermissionProfileSnapshot::legacy( + PermissionProfile::read_only(), + ), + selected_capability_roots: Vec::new(), + }), + ..selection + }], + )), + ..Default::default() + }, + ) + .await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + permission_profile: Some(PermissionProfile::workspace_write()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("try to write a file with owner-provided permissions") + .await?; + + let output = response_mock + .last_request() + .context("model should receive the command output")? + .function_call_output_text(OWNER_CONFIG_CALL_ID) .context("shell tool result should be present")?; assert!( output.contains("WRITE_DENIED"),