diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ee8d91b2ab..08a0bb0010 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4,7 +4,7 @@ use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::request_processors::populate_thread_turns_from_history; use crate::request_processors::thread_from_stored_thread; -use crate::request_processors::thread_settings_from_core_snapshot; +use crate::request_processors::thread_settings_from_config_snapshot; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; @@ -1205,9 +1205,9 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ThreadQueueChanged(_) => {} - EventMsg::ThreadSettingsApplied(thread_settings_event) => { + EventMsg::ThreadSettingsApplied(_) => { let thread_settings = - thread_settings_from_core_snapshot(thread_settings_event.thread_settings); + thread_settings_from_config_snapshot(&conversation.config_snapshot().await); let changed = { let mut state = thread_state.lock().await; state.note_thread_settings(thread_settings.clone()) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 975d016297..e1e359f173 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -684,7 +684,6 @@ pub(crate) use self::thread_summary::read_summary_from_rollout; #[cfg(test)] pub(crate) use self::thread_summary::summary_to_thread; pub(crate) use self::thread_summary::thread_settings_from_config_snapshot; -pub(crate) use self::thread_summary::thread_settings_from_core_snapshot; pub(crate) fn build_legacy_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { let mut builder = ThreadHistoryBuilder::new(); diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index ae46a4a5b4..3ffb6a54cc 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -161,10 +161,7 @@ impl ThreadGoalRequestProcessor { Some(path) if codex_rollout::existing_rollout_path(&path).await.is_none() => { // Goal-first threads need their settings captured when the goal creates the // rollout. Once materialized, normal settings updates own this event. - let persisted_settings = thread - .config_snapshot() - .await - .into_thread_settings_snapshot(); + let persisted_settings = thread.thread_settings_snapshot().await; let items = [ thread_settings_applied_item(persisted_settings.clone()), outcome.thread_goal_updated_item(), @@ -173,10 +170,7 @@ impl ThreadGoalRequestProcessor { Err(err) => Err(err), Ok(()) => { // Catch up a settings update queued while the rollout materialized. - let current_settings = thread - .config_snapshot() - .await - .into_thread_settings_snapshot(); + let current_settings = thread.thread_settings_snapshot().await; if current_settings == persisted_settings { Ok(()) } else { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 313e14576e..afea864aff 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -82,7 +82,7 @@ struct ThreadListFilters { struct ThreadRevertRuntimeSnapshot { config: Config, - settings: ThreadConfigSnapshot, + settings: CodexThreadSettingsOverrides, client_mcp_extensions: ClientMcpExtensions, } @@ -2019,7 +2019,7 @@ impl ThreadRequestProcessor { } let runtime_snapshot = ThreadRevertRuntimeSnapshot { config: thread.config().await.as_ref().clone(), - settings: config_snapshot, + settings: thread.restorable_thread_settings().await, client_mcp_extensions: thread.client_mcp_extensions(), }; @@ -4662,7 +4662,7 @@ impl ThreadRequestProcessor { restore_approval_policy || restore_approvals_reviewer || restore_permission_profile; let loaded_parent_settings = if paginated_source && needs_latest_settings { if let Ok(parent) = self.thread_manager.get_thread(source_thread_id).await { - let snapshot = parent.config_snapshot().await; + let snapshot = parent.thread_settings_snapshot().await; Some(PersistedResumeSettings { approval_policy: snapshot.approval_policy, approvals_reviewer: Some(snapshot.approvals_reviewer), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index fd1792bb1e..422867f20a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -194,47 +194,6 @@ pub(crate) fn thread_settings_from_config_snapshot( } } -pub(crate) fn thread_settings_from_core_snapshot( - snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, -) -> ThreadSettings { - let codex_protocol::protocol::ThreadSettingsSnapshot { - model, - model_provider_id, - service_tier, - approval_policy, - approvals_reviewer, - permission_profile, - active_permission_profile, - cwd, - reasoning_effort, - reasoning_summary, - personality, - collaboration_mode, - } = snapshot; - let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - cwd.as_path(), - ) - .into(); - ThreadSettings { - sandbox_policy, - cwd, - approval_policy: approval_policy.into(), - approvals_reviewer: approvals_reviewer.into(), - active_permission_profile: thread_response_active_permission_profile( - active_permission_profile, - ), - model, - model_provider: model_provider_id, - service_tier, - effort: reasoning_effort, - summary: reasoning_summary, - collaboration_mode, - multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, - personality, - } -} - #[cfg(test)] fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 3980462a46..822dcace40 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -5,7 +5,6 @@ use codex_agent_extension::AgentRunner; use codex_protocol::error::CodexErrorDetails; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::MultiAgentVersion; @@ -551,14 +550,6 @@ impl TurnRequestProcessor { }, ) .await?; - let parent_permission_profile_override = - thread_settings.permission_profile.clone().or_else(|| { - thread_settings - .sandbox_policy - .as_ref() - .map(PermissionProfile::from_legacy_sandbox_policy) - }); - let submission = thread .start_or_steer_turn( TurnInputRequest::new(TurnInput::UserInput { @@ -592,17 +583,17 @@ impl TurnRequestProcessor { if turn_has_input && started { let config_snapshot = thread.config_snapshot().await; - let parent_permission_profile = - parent_permission_profile_override.unwrap_or(config_snapshot.permission_profile); - codex_memories_write::start_memories_startup_task( - Arc::clone(&self.thread_manager), - Arc::clone(&self.auth_manager), - thread_id, - Arc::clone(&thread), - thread.config().await, - parent_permission_profile, - &config_snapshot.session_source, - ); + if config_snapshot.is_primary_environment_configured() { + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&thread), + thread.config().await, + config_snapshot.permission_profile, + &config_snapshot.session_source, + ); + } } self.outgoing diff --git a/codex-rs/core/src/agent/control/residency.rs b/codex-rs/core/src/agent/control/residency.rs index 99fa05112a..94bf5e5e65 100644 --- a/codex-rs/core/src/agent/control/residency.rs +++ b/codex-rs/core/src/agent/control/residency.rs @@ -144,6 +144,13 @@ impl V2Residency { self.touch(candidate_thread_id); continue; } + let environments = candidate_thread.environment_selections().await; + candidate_thread + .session + .services + .agent_control + .state + .save_evicted_environments(candidate_thread_id, environments); let _ = manager.remove_thread(&candidate_thread_id).await; return true; } diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 4ba5140851..e6427d3e5f 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -267,6 +267,7 @@ impl AgentControl { if self.state.agent_metadata_for_thread(thread_id).is_none() { return Err(CodexErr::ThreadNotFound(thread_id)); } + let environment_selections = self.state.evicted_environments(thread_id); let stored_thread = state .read_stored_thread(ReadThreadParams { @@ -365,18 +366,21 @@ impl AgentControl { agent_control: self.clone(), session_source, parent_thread_id, + environment_selections, inherited_environments, inherited_exec_policy, }) .await { Ok(reloaded_thread) => { + self.state.clear_evicted_environments(thread_id); residency_slot.commit(reloaded_thread.thread_id); state.notify_thread_created(reloaded_thread.thread_id); Ok(()) } Err(err) => { if state.get_thread(thread_id).await.is_ok() { + self.state.clear_evicted_environments(thread_id); drop(residency_slot); self.touch_loaded_v2_residency(&state, thread_id).await; return Ok(()); @@ -1009,6 +1013,7 @@ impl AgentControl { agent_control: self.clone(), session_source, parent_thread_id, + environment_selections: None, inherited_environments, inherited_exec_policy, }) diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index e1ceccae1f..e72661488c 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -5,6 +5,7 @@ use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::Result; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TurnEnvironmentSelection; use rand::prelude::IndexedRandom; use std::collections::HashMap; use std::collections::HashSet; @@ -29,11 +30,25 @@ pub(crate) struct AgentRegistry { #[derive(Default)] struct ActiveAgents { agent_tree: HashMap, - thread_paths: HashMap, + thread_paths: HashMap, used_agent_nicknames: HashSet, nickname_reset_count: usize, } +struct RegisteredAgent { + path: String, + evicted_environments: Option>, +} + +impl RegisteredAgent { + fn new(path: String) -> Self { + Self { + path, + evicted_environments: None, + } + } +} + #[derive(Clone, Debug, Default)] pub(crate) struct AgentMetadata { pub(crate) agent_id: Option, @@ -108,7 +123,7 @@ impl AgentRegistry { active_agents .thread_paths .remove(&thread_id) - .and_then(|key| active_agents.agent_tree.remove(key.as_str())) + .and_then(|agent| active_agents.agent_tree.remove(agent.path.as_str())) .is_some_and(|metadata| { !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) }) @@ -134,7 +149,9 @@ impl AgentRegistry { }) .agent_id; if let Some(root_thread_id) = root_thread_id { - active_agents.thread_paths.insert(root_thread_id, root_path); + active_agents + .thread_paths + .insert(root_thread_id, RegisteredAgent::new(root_path)); } } @@ -155,10 +172,48 @@ impl AgentRegistry { active_agents .thread_paths .get(&thread_id) - .and_then(|path| active_agents.agent_tree.get(path)) + .and_then(|agent| active_agents.agent_tree.get(&agent.path)) .cloned() } + pub(crate) fn save_evicted_environments( + &self, + thread_id: ThreadId, + environments: Vec, + ) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(agent) = active_agents.thread_paths.get_mut(&thread_id) { + agent.evicted_environments = Some(environments); + } + } + + pub(crate) fn evicted_environments( + &self, + thread_id: ThreadId, + ) -> Option> { + let active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active_agents + .thread_paths + .get(&thread_id) + .and_then(|agent| agent.evicted_environments.clone()) + } + + pub(crate) fn clear_evicted_environments(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(agent) = active_agents.thread_paths.get_mut(&thread_id) { + agent.evicted_environments = None; + } + } + pub(crate) fn live_agents(&self) -> Vec { self.active_agents .lock() @@ -189,10 +244,14 @@ impl AgentRegistry { if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() { active_agents.used_agent_nicknames.insert(agent_nickname); } - if let Some(previous_key) = active_agents.thread_paths.insert(thread_id, key.clone()) - && previous_key != key + if let Some(previous_agent) = active_agents + .thread_paths + .insert(thread_id, RegisteredAgent::new(key.clone())) + && previous_agent.path != key { - active_agents.agent_tree.remove(previous_key.as_str()); + active_agents + .agent_tree + .remove(previous_agent.path.as_str()); } if let Some(previous_metadata) = active_agents.agent_tree.insert(key, agent_metadata) && let Some(previous_thread_id) = previous_metadata.agent_id diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 86b5607fad..2f5403c604 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -28,6 +28,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EnvironmentConfig; +use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::Event; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; @@ -102,46 +103,24 @@ impl ThreadConfigSnapshot { &self.environments.environments } + /// Whether the primary environment has resolved configuration, if one is selected. + pub fn is_primary_environment_configured(&self) -> bool { + self.environment_selections() + .first() + .is_none_or(|selection| { + matches!( + selection.config, + EnvironmentConfigState::FromThread | EnvironmentConfigState::Ready(_) + ) + }) + } + pub fn sandbox_policy(&self) -> SandboxPolicy { codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( &self.permission_profile, self.cwd().as_path(), ) } - - pub fn into_thread_settings_snapshot(self) -> ThreadSettingsSnapshot { - let cwd = self.cwd().clone(); - ThreadSettingsSnapshot { - model: self.model, - model_provider_id: self.model_provider_id, - service_tier: self.service_tier, - approval_policy: self.approval_policy, - approvals_reviewer: self.approvals_reviewer, - permission_profile: self.permission_profile, - active_permission_profile: self.active_permission_profile, - cwd, - reasoning_effort: self.reasoning_effort, - reasoning_summary: self.reasoning_summary, - personality: self.personality, - collaboration_mode: self.collaboration_mode, - } - } - - fn into_thread_settings_overrides(self) -> CodexThreadSettingsOverrides { - CodexThreadSettingsOverrides { - environments: Some(self.environments), - profile_workspace_roots: Some(self.profile_workspace_roots), - approval_policy: Some(self.approval_policy), - approvals_reviewer: Some(self.approvals_reviewer), - permission_profile: Some(self.permission_profile), - active_permission_profile: self.active_permission_profile, - summary: self.reasoning_summary, - service_tier: Some(self.service_tier), - collaboration_mode: Some(self.collaboration_mode), - personality: self.personality, - ..Default::default() - } - } } /// Thread settings overrides that app-server validates before starting a turn. @@ -423,17 +402,15 @@ impl CodexThread { self.session.preview_settings(&updates).await } - /// Restores effective mutable settings captured from another loaded runtime. + /// Restores thread-owned mutable settings captured from another loaded runtime. /// /// Runtime replacement uses this after resume so clients keep their current thread settings /// rather than reverting to the original layer-backed config. pub async fn restore_thread_settings( &self, - snapshot: ThreadConfigSnapshot, + settings: CodexThreadSettingsOverrides, ) -> ConstraintResult<()> { - let updates = self - .thread_settings_update(snapshot.into_thread_settings_overrides()) - .await; + let updates = self.thread_settings_update(settings).await; self.session.update_settings(updates).await } @@ -648,6 +625,16 @@ impl CodexThread { self.session.thread_config_snapshot().await } + /// Returns thread-owned settings suitable for rollout persistence and resume. + pub async fn thread_settings_snapshot(&self) -> ThreadSettingsSnapshot { + self.session.thread_settings_snapshot().await + } + + /// Captures thread-owned settings and environment selections for runtime restoration. + pub async fn restorable_thread_settings(&self) -> CodexThreadSettingsOverrides { + self.session.restorable_thread_settings().await + } + /// Returns the MCP extensions declared by the client that created this runtime. pub fn client_mcp_extensions(&self) -> ClientMcpExtensions { self.session.services.client_mcp_extensions.clone() diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index e40fcc5171..39a598bff9 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -14,6 +14,7 @@ use codex_exec_server::ExecutorFileSystem; use codex_exec_server::SelectedCapabilityRootsStatus; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::EnvironmentConnectionEvent; @@ -360,6 +361,18 @@ impl ThreadEnvironments { }) } + /// Returns installed owner configuration without treating pending attachments as ready. + pub(crate) fn primary_config_for( + selections: &[TurnEnvironmentSelection], + ) -> Option<&EnvironmentConfig> { + match &selections.first()?.config { + EnvironmentConfigState::Ready(config) => Some(config), + EnvironmentConfigState::FromThread + | EnvironmentConfigState::Pending + | EnvironmentConfigState::Failed(_) => None, + } + } + pub(crate) fn primary_workspace_roots_for( selections: &[TurnEnvironmentSelection], ) -> Vec { @@ -713,6 +726,16 @@ impl TurnEnvironmentSnapshot { self.turn_environments().next() } + /// Returns the primary environment's resolved permissions, or the provided fallback. + pub(crate) fn permission_profile_or_else( + &self, + fallback: impl FnOnce() -> PermissionProfile, + ) -> PermissionProfile { + self.primary() + .map(TurnEnvironment::permission_profile_with_workspace_roots) + .unwrap_or_else(fallback) + } + pub(crate) fn local(&self) -> Option<&TurnEnvironment> { self.turn_environments() .find(|environment| !environment.environment.is_remote()) diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 663b51aa4c..1cbcce7f33 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -14,7 +14,6 @@ use crate::context::NodeReplReviewEvidenceMode; use crate::context::node_repl_review_evidence_mode; use crate::event_mapping::is_contextual_user_message_content; use crate::session::session::Session; -use crate::session::turn_context::TurnEnvironment; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_bytes_for_tokens; use codex_utils_output_truncation::approx_token_count; @@ -302,9 +301,9 @@ fn parent_turn_denied_reads_context(context: &GuardianReviewContext) -> Option codex_mcp::McpConfig { - let mut config = (*mcp_config_for_test(&turn_context.config)).clone(); - config.permission_profile = turn_context.permission_profile(); - config + (*mcp_config_for_test(&turn_context.config)).clone() } fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> { diff --git a/codex-rs/core/src/session/environment.rs b/codex-rs/core/src/session/environment.rs index 4efb79af9a..09b7803cc2 100644 --- a/codex-rs/core/src/session/environment.rs +++ b/codex-rs/core/src/session/environment.rs @@ -139,6 +139,12 @@ impl Session { }; environment.config = config; + if matches!(environment.config, EnvironmentConfigState::Ready(_)) { + state + .session_configuration + .validate(&environments) + .map_err(|error| CodexErr::InvalidRequest(error.to_string()))?; + } // Invalidate MCP before installed configuration can wake a waiting turn. self.mark_mcp_runtime_dirty(); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 6cea89c6e7..e438fff07f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -123,6 +123,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; @@ -178,6 +179,7 @@ use tracing::warn; use uuid::Uuid; use crate::client::ModelClient; +use crate::codex_thread::CodexThreadSettingsOverrides; use crate::codex_thread::ThreadConfigSnapshot; #[cfg(test)] use crate::compact::collect_user_messages; @@ -1652,6 +1654,20 @@ impl Session { .thread_config_snapshot(self.services.turn_environments.selections()) } + pub(crate) async fn thread_settings_snapshot(&self) -> ThreadSettingsSnapshot { + let state = self.state.lock().await; + state + .session_configuration + .thread_settings_snapshot(&self.services.turn_environments.selections()) + } + + pub(crate) async fn restorable_thread_settings(&self) -> CodexThreadSettingsOverrides { + let state = self.state.lock().await; + state + .session_configuration + .restorable_thread_settings(self.services.turn_environments.selections()) + } + pub(crate) async fn set_app_server_client_info( &self, app_server_client_name: Option, @@ -3162,9 +3178,8 @@ impl Session { if !discovery.sandbox_contexts().is_empty() { extension_data.insert(discovery.sandbox_contexts().clone()); } - } else if !turn_context - .config - .permissions + } else if !environments + .permission_profile_or_else(|| turn_context.permission_profile()) .file_system_sandbox_policy() .has_full_disk_read_access() { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7bdfb1c706..1d999d63af 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -137,10 +137,6 @@ impl SessionConfiguration { &self.codex_home } - pub(super) fn permission_profile_state(&self) -> &PermissionProfileState { - &self.permission_profile_state - } - pub(super) fn inferred_environment_config(&self) -> EnvironmentConfig { EnvironmentConfig { allow_login_shell: self.allow_login_shell, @@ -170,10 +166,6 @@ impl SessionConfiguration { self.permission_profile_state.active_permission_profile() } - pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { - self.permission_profile_state.profile_workspace_roots() - } - pub(super) fn apply_permission_profile_to_permissions( &self, permissions: &mut crate::config::Permissions, @@ -221,20 +213,26 @@ impl SessionConfiguration { ) -> ThreadConfigSnapshot { let workspace_roots = ThreadEnvironments::primary_workspace_roots_for(&environment_selections); + let permission_profile = ThreadEnvironments::primary_config_for(&environment_selections) + .map(|config| config.permission_profile.clone()) + .unwrap_or_else(|| self.permission_profile_state.snapshot()); ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, - permission_profile: self.materialized_permission_profile(&environment_selections), - active_permission_profile: self.active_permission_profile(), + permission_profile: permission_profile + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots), + active_permission_profile: permission_profile.active_permission_profile(), environments: TurnEnvironmentSelections::new( self.legacy_fallback_cwd.clone(), environment_selections, ), workspace_roots, - profile_workspace_roots: self.profile_workspace_roots().to_vec(), + profile_workspace_roots: permission_profile.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), reasoning_summary: self.model_reasoning_summary, @@ -249,6 +247,54 @@ impl SessionConfiguration { } } + /// Captures thread-owned settings for persistence and resume. + pub(super) fn thread_settings_snapshot( + &self, + environment_selections: &[TurnEnvironmentSelection], + ) -> ThreadSettingsSnapshot { + ThreadSettingsSnapshot { + model: self.collaboration_mode.model().to_string(), + model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), + service_tier: self.service_tier.clone(), + approval_policy: self.approval_policy.value(), + approvals_reviewer: self.approvals_reviewer, + permission_profile: self.materialized_permission_profile(environment_selections), + active_permission_profile: self.active_permission_profile(), + cwd: self.legacy_fallback_cwd.clone(), + reasoning_effort: self.collaboration_mode.reasoning_effort(), + reasoning_summary: self.model_reasoning_summary, + personality: self.personality, + collaboration_mode: self.collaboration_mode.clone(), + } + } + + /// Captures thread-owned settings and their separately owned environments. + pub(super) fn restorable_thread_settings( + &self, + environment_selections: Vec, + ) -> CodexThreadSettingsOverrides { + CodexThreadSettingsOverrides { + environments: Some(TurnEnvironmentSelections::new( + self.legacy_fallback_cwd.clone(), + environment_selections, + )), + profile_workspace_roots: Some( + self.permission_profile_state + .profile_workspace_roots() + .to_vec(), + ), + approval_policy: Some(self.approval_policy.value()), + approvals_reviewer: Some(self.approvals_reviewer), + permission_profile: Some(self.permission_profile()), + active_permission_profile: self.active_permission_profile(), + summary: self.model_reasoning_summary, + service_tier: Some(self.service_tier.clone()), + collaboration_mode: Some(self.collaboration_mode.clone()), + personality: self.personality, + ..Default::default() + } + } + pub(super) fn validate( &self, environments: &[TurnEnvironmentSelection], @@ -258,7 +304,7 @@ impl SessionConfiguration { ConstraintError::InvalidValue { field_name: "environments", candidate: "environment configuration".to_string(), - allowed: format!("valid selected capability roots ({error})"), + allowed: format!("valid environment configuration ({error})"), requirement_source: codex_config::RequirementSource::Unknown, } }) @@ -281,9 +327,16 @@ impl SessionConfiguration { return Ok(()); } + let permission_profile = ThreadEnvironments::primary_config_for(environments) + .map(|config| config.permission_profile.permission_profile()) + .unwrap_or_else(|| self.permission_profile_state.permission_profile()) + .clone() + .materialize_project_roots_with_workspace_roots( + &ThreadEnvironments::primary_workspace_roots_for(environments), + ); if self.approvals_reviewer == ApprovalsReviewer::AutoReview - && !self - .file_system_sandbox_policy(environments) + && !permission_profile + .file_system_sandbox_policy() .has_full_disk_write_access() && self .original_config_do_not_use @@ -1392,33 +1445,32 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let thread_config = + session_configuration.thread_config_snapshot(turn_environments.selections()); let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, thread_id, - forked_from_id, - parent_thread_id, - thread_source: session_configuration.thread_source.clone(), + cwd: thread_config.cwd().clone(), + forked_from_id: thread_config.forked_from_thread_id, + parent_thread_id: thread_config.parent_thread_id, + thread_source: thread_config.thread_source, thread_name: session_configuration.thread_name.clone(), - model: session_configuration.collaboration_mode.model().to_string(), - model_provider_id: config.model_provider_id.clone(), - service_tier: session_configuration.service_tier.clone(), - approval_policy: session_configuration.approval_policy.value(), - approvals_reviewer: session_configuration.approvals_reviewer, - permission_profile: session_configuration - .materialized_permission_profile(environment_selections), - active_permission_profile: session_configuration.active_permission_profile(), - cwd: session_configuration.cwd().clone(), - reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), - initial_messages, + model: thread_config.model, + model_provider_id: thread_config.model_provider_id, + service_tier: thread_config.service_tier, + approval_policy: thread_config.approval_policy, + approvals_reviewer: thread_config.approvals_reviewer, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( - session_configuration - .permission_profile_state() - .permission_profile(), + &thread_config.permission_profile, ) }), + permission_profile: thread_config.permission_profile, + active_permission_profile: thread_config.active_permission_profile, + reasoning_effort: thread_config.reasoning_effort, + initial_messages, rollout_path, }), }) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8e0638e609..0138cda4ca 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9592,14 +9592,15 @@ async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() async fn turn_context_item_stores_active_permission_profile() { let (_session, mut turn_context) = make_session_and_context().await; let active_permission_profile = ActivePermissionProfile::read_only(); - let config = Arc::make_mut(&mut turn_context.config); - config - .permissions - .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( - PermissionProfile::read_only(), - active_permission_profile.clone(), - )) - .expect("test setup should allow updating permission profile"); + let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] + else { + panic!("turn environment should be ready"); + }; + environment.config_origin = EnvironmentConfigOrigin::Owner; + environment.config_mut().permission_profile = PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + active_permission_profile.clone(), + ); assert_eq!( turn_context @@ -9618,10 +9619,12 @@ async fn turn_context_item_stores_split_file_system_sandbox_policy_when_differen &file_system_sandbox_policy, turn_context.network_sandbox_policy(), ); - Arc::make_mut(&mut turn_context.config) - .permissions - .set_permission_profile(permission_profile) - .expect("test setup should allow updating permission profile"); + let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] + else { + panic!("turn environment should be ready"); + }; + environment.config_mut().permission_profile = + PermissionProfileSnapshot::legacy(permission_profile); let item = turn_context.to_turn_context_item(); @@ -9791,10 +9794,12 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi &file_system_sandbox_policy, turn_context.network_sandbox_policy(), ); - Arc::make_mut(&mut turn_context.config) - .permissions - .set_permission_profile(permission_profile) - .expect("test setup should allow updating permission profile"); + let TurnEnvironmentState::Ready(environment) = &mut turn_context.environments.environments[0] + else { + panic!("turn environment should be ready"); + }; + environment.config_mut().permission_profile = + PermissionProfileSnapshot::legacy(permission_profile); let rollout_path = attach_thread_persistence(&mut session).await; let turn_context = Arc::new(turn_context); diff --git a/codex-rs/core/src/session/thread_settings.rs b/codex-rs/core/src/session/thread_settings.rs index 6320ab37c9..286cf78e06 100644 --- a/codex-rs/core/src/session/thread_settings.rs +++ b/codex-rs/core/src/session/thread_settings.rs @@ -83,7 +83,7 @@ pub(super) async fn prepare_update( } } -/// Applies persistent settings and emits the resulting effective snapshot. +/// Applies persistent settings and emits the resulting thread-owned snapshot. pub(super) async fn apply_update( session: &Session, submission_id: String, @@ -94,7 +94,7 @@ pub(super) async fn apply_update( Ok(()) } -/// Emits the effective thread settings after a successful update. +/// Emits the thread-owned settings after a successful update. pub(super) async fn emit_applied(session: &Session, submission_id: String) { let msg = applied_event(session).await; session @@ -105,11 +105,10 @@ pub(super) async fn emit_applied(session: &Session, submission_id: String) { .await; } -/// Builds the effective thread-settings event used by live updates and +/// Builds the thread-owned settings event used by live updates and /// synthesized fork history. pub(super) async fn applied_event(session: &Session) -> EventMsg { - let snapshot = session.thread_config_snapshot().await; EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { - thread_settings: snapshot.into_thread_settings_snapshot(), + thread_settings: session.thread_settings_snapshot().await, }) } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8366cd94aa..728749a410 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -278,21 +278,49 @@ impl TurnContext { } } + /// Returns the selected environment's permissions, or the thread's permissions when none is ready. pub(crate) fn permission_profile(&self) -> PermissionProfile { - self.config.permissions.effective_permission_profile() + self.environments + .permission_profile_or_else(|| self.config.permissions.effective_permission_profile()) } pub(crate) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.config.permissions.file_system_sandbox_policy() + self.permission_profile().file_system_sandbox_policy() } pub(crate) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.config.permissions.network_sandbox_policy() + self.permission_profile().network_sandbox_policy() } pub(crate) fn sandbox_policy(&self) -> SandboxPolicy { #[allow(deprecated)] - self.config.permissions.legacy_sandbox_policy(&self.cwd) + codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &self.permission_profile(), + &self.cwd, + ) + } + + /// Combines the selected environment's workspace roots with its permission profile roots. + pub(crate) fn effective_workspace_roots(&self) -> Vec { + let Some(environment) = self.environments.primary() else { + return self.config.effective_workspace_roots(); + }; + + let mut workspace_roots = environment + .workspace_roots() + .iter() + .filter_map(|root| root.to_abs_path().ok()) + .collect::>(); + for root in environment + .config() + .permission_profile + .profile_workspace_roots() + { + if !workspace_roots.contains(root) { + workspace_roots.push(root.clone()); + } + } + workspace_roots } pub(crate) fn effective_reasoning_effort(&self) -> Option { @@ -465,7 +493,7 @@ impl TurnContext { } pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { - let workspace_roots = self.config.effective_workspace_roots(); + let workspace_roots = self.effective_workspace_roots(); #[allow(deprecated)] let cwd = self.cwd.clone(); TurnContextItem { @@ -478,7 +506,10 @@ impl TurnContext { approvals_reviewer: Some(self.config.approvals_reviewer), sandbox_policy: self.sandbox_policy(), permission_profile: Some(self.permission_profile()), - active_permission_profile: self.config.permissions.active_permission_profile(), + active_permission_profile: self.environments.primary().map_or_else( + || self.config.permissions.active_permission_profile(), + TurnEnvironment::active_permission_profile, + ), network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), @@ -629,7 +660,9 @@ impl Session { per_turn_config.features.enabled(Feature::FastMode), &model_info, ); - let permission_profile = per_turn_config.permissions.effective_permission_profile(); + let permission_profile = environments.permission_profile_or_else(|| { + per_turn_config.permissions.effective_permission_profile() + }); let auto_review_enabled = crate::guardian::routes_approval_policy_to_guardian( per_turn_config.permissions.approval_policy.value(), per_turn_config.approvals_reviewer, @@ -837,6 +870,10 @@ impl Session { .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = self.build_per_turn_config(&session_configuration, cwd.clone()); + let network_permission_profile = primary_turn_environment + .map(TurnEnvironment::permission_profile) + .cloned() + .unwrap_or_else(|| session_configuration.permission_profile()); let model_info = self .services .models_manager @@ -902,7 +939,7 @@ impl Session { .as_ref() .and_then(|started_proxy| { Self::managed_network_proxy_active_for_permission_profile( - &session_configuration.permission_profile(), + &network_permission_profile, ) .then(|| started_proxy.proxy()) }), diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 532a925fd9..62a9efae26 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -148,19 +148,9 @@ impl Session { .current_for_prefix_rules(turn_context.allow_prefix_rules()); if turn_context.config.include_permissions_instructions { let environment = step_context.environments.primary(); - let permission_profile = environment - .map(|environment| { - let workspace_roots = environment - .workspace_roots() - .iter() - .filter_map(|workspace_root| workspace_root.to_abs_path().ok()) - .collect::>(); - environment - .permission_profile() - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots) - }) - .unwrap_or_else(|| turn_context.permission_profile()); + let permission_profile = step_context + .environments + .permission_profile_or_else(|| turn_context.permission_profile()); #[allow(deprecated)] let cwd = environment .and_then(|environment| environment.cwd().to_abs_path().ok()) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e2d60cd6f4..f340080978 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -217,7 +217,7 @@ pub(crate) async fn execute_user_shell_command( capture_policy: ExecCapturePolicy::ShellTool, sandbox: SandboxType::None, windows_sandbox_policy_cwd: cwd.clone().into(), - windows_sandbox_workspace_roots: turn_context.config.effective_workspace_roots(), + windows_sandbox_workspace_roots: turn_context.effective_workspace_roots(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context .config diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6b1e5db805..e91143d561 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -325,6 +325,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions { pub(crate) agent_control: AgentControl, pub(crate) session_source: SessionSource, pub(crate) parent_thread_id: Option, + pub(crate) environment_selections: Option>, pub(crate) inherited_environments: Option, pub(crate) inherited_exec_policy: Option>, } @@ -1682,15 +1683,17 @@ impl ThreadManagerState { agent_control, session_source, parent_thread_id, + environment_selections, inherited_environments, inherited_exec_policy, } = options; let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await; let thread_source = initial_history.get_resumed_thread_source(); - let environments = inherited_environments - .as_ref() - .filter(|_| initial_history.get_multi_agent_version() == Some(MultiAgentVersion::V2)) - .map(TurnEnvironmentSnapshot::to_selections); + let environments = environment_selections.or_else(|| { + inherited_environments + .as_ref() + .map(TurnEnvironmentSnapshot::to_selections) + }); let options = StartThreadOptions { initial_history, session_source: Some(session_source), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index df69c903c4..8e97b2afbd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -2,7 +2,6 @@ use super::*; use crate::agent::next_thread_spawn_depth; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool; use codex_tools::ToolSpec; use std::sync::Arc; @@ -36,7 +35,6 @@ async fn handle_resume_agent( let ToolInvocation { session, turn, - step_context, payload, call_id, .. @@ -90,7 +88,6 @@ async fn handle_resume_agent( match Box::pin(try_resume_closed_agent( &session, &turn, - step_context.environments.primary(), receiver_thread_id, child_depth, )) @@ -191,11 +188,10 @@ impl ToolOutput for ResumeAgentResult { async fn try_resume_closed_agent( session: &Arc, turn: &Arc, - environment: Option<&TurnEnvironment>, receiver_thread_id: ThreadId, child_depth: i32, ) -> Result<(), FunctionCallError> { - let config = build_agent_resume_config(turn.as_ref(), environment)?; + let config = build_agent_resume_config(turn.as_ref())?; Box::pin(session.services.agent_control.resume_agent_from_rollout( config, receiver_thread_id, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index a5e1c454d7..87e4aba5ed 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -34,7 +34,6 @@ impl Handler { let ToolInvocation { session, turn, - step_context, payload, call_id, .. @@ -49,8 +48,7 @@ impl Handler { .agent_control .get_agent_metadata(receiver_thread_id); if receiver_agent.is_some() { - let resume_config = - build_agent_resume_config(turn.as_ref(), step_context.environments.primary())?; + let resume_config = build_agent_resume_config(turn.as_ref())?; session .services .agent_control 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 02b370b3d1..73443cdf42 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -86,11 +86,8 @@ async fn handle_spawn_agent( }), ) .await; - let mut config = build_agent_spawn_config( - &session.get_base_instructions().await, - turn.as_ref(), - step_context.environments.primary(), - )?; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; if let Some(service_tier) = args.service_tier.as_ref() { config.service_tier = Some(service_tier.clone()); } @@ -115,11 +112,7 @@ async fn handle_spawn_agent( args.service_tier.as_deref(), ) .await?; - apply_spawn_agent_runtime_overrides( - &mut config, - turn.as_ref(), - step_context.environments.primary(), - )?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( config, 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 81ec369413..843030b274 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -5,7 +5,6 @@ use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -170,36 +169,29 @@ pub(crate) fn parse_collab_input( /// 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 -/// runtime-owned fields carried by the turn and selected environment, including model selection, -/// reasoning settings, approval policy, sandbox, and cwd. Role-specific overrides are layered +/// runtime-owned fields carried by the turn, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered /// after this step; skipping this helper and cloning stale config state directly can send the child /// agent out with the wrong provider or runtime policy. pub(crate) fn build_agent_spawn_config( base_instructions: &BaseInstructions, turn: &TurnContext, - environment: Option<&TurnEnvironment>, ) -> Result { - let mut config = build_agent_shared_config(turn, environment)?; + let mut config = build_agent_shared_config(turn)?; config.base_instructions = Some(base_instructions.text.clone()); config.base_instructions_provenance = base_instructions.provenance.clone(); Ok(config) } -pub(crate) fn build_agent_resume_config( - turn: &TurnContext, - environment: Option<&TurnEnvironment>, -) -> Result { - let mut config = build_agent_shared_config(turn, environment)?; +pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result { + let mut config = build_agent_shared_config(turn)?; // For resume, keep base instructions sourced from rollout/session metadata. config.base_instructions = None; config.base_instructions_provenance = None; Ok(config) } -fn build_agent_shared_config( - turn: &TurnContext, - environment: Option<&TurnEnvironment>, -) -> Result { +fn build_agent_shared_config(turn: &TurnContext) -> Result { let base_config = turn.config.clone(); let mut config = (*base_config).clone(); config.model = Some(turn.model_info.slug.clone()); @@ -219,7 +211,7 @@ fn build_agent_shared_config( { config.developer_instructions = Some(developer_instructions); } - apply_spawn_agent_runtime_overrides(&mut config, turn, environment)?; + apply_spawn_agent_runtime_overrides(&mut config, turn)?; Ok(config) } @@ -237,13 +229,11 @@ pub(crate) fn reject_full_fork_agent_type_override( /// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. /// -/// These values are chosen by the live turn and selected environment rather than persisted config, -/// so leaving them stale can make a child agent disagree with its parent about approval policy, -/// cwd, or sandboxing. +/// These values are chosen by the live turn rather than persisted config, so leaving them stale can +/// make a child agent disagree with its parent about approval policy, cwd, or sandboxing. pub(crate) fn apply_spawn_agent_runtime_overrides( config: &mut Config, turn: &TurnContext, - environment: Option<&TurnEnvironment>, ) -> Result<(), FunctionCallError> { config .permissions @@ -256,12 +246,14 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); config.cwd = turn_cwd; - let permission_profile = environment - .map(|environment| environment.permission_profile().clone()) - .unwrap_or_else(|| turn.permission_profile()); config .permissions - .set_permission_profile(permission_profile) + .set_permission_profile_from_session_snapshot( + turn.config + .permissions + .permission_profile_state() + .snapshot(), + ) .map_err(|err| { FunctionCallError::RespondToModel(format!("permission_profile is invalid: {err}")) })?; 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 036a14f3c9..455ff92528 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4,6 +4,7 @@ use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::PermissionProfileSnapshot; +use crate::environment_selection::EnvironmentConfigOrigin; use crate::environment_selection::TurnEnvironmentState; use crate::function_tool::FunctionCallError; use crate::init_state_db; @@ -2301,6 +2302,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { }; environment.config_mut().permission_profile = PermissionProfileSnapshot::legacy(expected_permission_profile.clone()); + environment.config_origin = EnvironmentConfigOrigin::Owner; assert_ne!( role_config.permissions.effective_permission_profile(), expected_permission_profile, @@ -2308,7 +2310,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { ); assert_ne!( expected_permission_profile, - turn.permission_profile(), + turn.config.permissions.effective_permission_profile(), "test requires an environment profile that differs from the thread profile" ); @@ -2360,6 +2362,10 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { expected_network_sandbox_policy ); assert_eq!(child_turn.permission_profile(), expected_permission_profile); + assert_ne!( + child_turn.config.permissions.permission_profile(), + &expected_permission_profile + ); } #[tokio::test] @@ -4240,6 +4246,10 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let (_session, turn) = make_session_and_context().await; let mut config = turn.config.as_ref().clone(); config.agent_max_depth = 3; + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .expect("test config should allow workspace writes"); config .features .enable(Feature::Sqlite) @@ -4269,6 +4279,22 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr .expect("parent thread should start"); let parent_thread_id = parent.thread_id; let parent_session = parent.thread.session.clone(); + let parent_turn = parent_session.new_default_turn().await; + let mut owner_config = parent_turn + .environments + .primary() + .expect("parent should have an environment") + .config() + .clone(); + let owner_permission_profile = PermissionProfile::read_only(); + owner_config.permission_profile = + PermissionProfileSnapshot::legacy(owner_permission_profile.clone()); + let parent_environment = parent.thread.environment_selections().await.remove(0); + parent + .thread + .environment_ready(&parent_environment, owner_config) + .await + .expect("owner environment should be installed"); let child_turn = parent_session.new_default_turn().await; let child_spawn_output = SpawnAgentHandler::default() @@ -4360,6 +4386,16 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr manager.agent_control().get_status(child_thread_id).await, AgentStatus::NotFound ); + assert_eq!( + manager + .get_thread(child_thread_id) + .await + .expect("resumed child thread should exist") + .config_snapshot() + .await + .permission_profile, + owner_permission_profile + ); assert_ne!( manager .agent_control() @@ -4519,8 +4555,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { .set(AskForApproval::OnRequest) .expect("approval policy set"); - let config = build_agent_spawn_config(&base_instructions, &turn, turn.environments.primary()) - .expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); let mut expected = (*turn.config).clone(); expected.base_instructions_provenance = base_instructions.provenance.clone(); expected.base_instructions = Some(base_instructions.text); @@ -4538,10 +4573,6 @@ async fn build_agent_spawn_config_uses_turn_context_values() { .approval_policy .set(AskForApproval::OnRequest) .expect("approval policy set"); - expected - .permissions - .set_permission_profile(turn.permission_profile()) - .expect("permission profile set"); assert_eq!(config, expected); } @@ -4574,10 +4605,9 @@ async fn build_agent_resume_config_clears_base_instructions() { panic!("parent environment should be ready"); }; environment.config_mut().permission_profile = - PermissionProfileSnapshot::legacy(environment_permission_profile.clone()); + PermissionProfileSnapshot::legacy(environment_permission_profile); - let config = - build_agent_resume_config(&turn, turn.environments.primary()).expect("resume config"); + let config = build_agent_resume_config(&turn).expect("resume config"); let mut expected = (*turn.config).clone(); expected.base_instructions = None; @@ -4596,9 +4626,5 @@ async fn build_agent_resume_config_clears_base_instructions() { .approval_policy .set(AskForApproval::OnRequest) .expect("approval policy set"); - expected - .permissions - .set_permission_profile(environment_permission_profile) - .expect("permission profile set"); assert_eq!(config, expected); } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index b217fa2dc1..e49ce37d89 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -59,7 +59,6 @@ pub(crate) async fn handle_message_string_tool( let ToolInvocation { session, turn, - step_context, call_id, source, .. @@ -83,8 +82,7 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let resume_config = - build_agent_resume_config(turn.as_ref(), step_context.environments.primary())?; + let resume_config = build_agent_resume_config(turn.as_ref())?; session .services .agent_control 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 b4cc6f6fd7..898064d2e0 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 @@ -62,11 +62,8 @@ async fn handle_spawn_agent( let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - let mut config = build_agent_spawn_config( - &session.get_base_instructions().await, - turn.as_ref(), - step_context.environments.primary(), - )?; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; if let Some(service_tier) = args.service_tier.as_ref() { config.service_tier = Some(service_tier.clone()); } @@ -94,11 +91,7 @@ async fn handle_spawn_agent( args.service_tier.as_deref(), ) .await?; - apply_spawn_agent_runtime_overrides( - &mut config, - turn.as_ref(), - step_context.environments.primary(), - )?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; let spawn_source = thread_spawn_source( session.thread_id, diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index 014e2f48a3..353ca07633 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -1,5 +1,9 @@ use anyhow::Result; use codex_features::Feature; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; +use codex_protocol::protocol::EnvironmentConfig; +use codex_protocol::protocol::EnvironmentConfigState; use codex_protocol::protocol::EventMsg; use core_test_support::responses::ResponseMock; use core_test_support::responses::ev_assistant_message; @@ -236,12 +240,7 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu .with_model("gpt-5.6-sol") .with_exec_server_url("none") .with_config(|config| { - for feature in [ - Feature::Collab, - Feature::MultiAgentV2, - Feature::UnifiedExec, - Feature::DeferredExecutor, - ] { + for feature in [Feature::Collab, Feature::MultiAgentV2, Feature::UnifiedExec] { config .features .enable(feature) @@ -249,9 +248,22 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu } config.use_experimental_unified_exec_tool = true; config.multi_agent_v2.max_concurrent_threads_per_session = 2; + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .expect("thread permissions should allow workspace writes"); }); let test = builder.build_with_remote_and_local_env(&server).await?; - let child_environment = test.executor_environment().selection().clone(); + let mut child_environment = test.executor_environment().selection().clone(); + child_environment.config = EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: test.config.permissions.allow_login_shell, + permission_profile: PermissionProfileSnapshot::legacy(PermissionProfile::read_only()), + shell_environment_policy: Default::default(), + exec_policy: None, + mcp_policy: None, + network_policy: None, + selected_capability_roots: Vec::new(), + }); if let Some(exec_server_url) = test.executor_environment().exec_server_url() { test.thread_manager .environment_manager() @@ -272,7 +284,14 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu }) .await; - test.submit_text_turn(EVICT_PROMPT).await?; + let mut sender_environment = child_environment.clone(); + let EnvironmentConfigState::Ready(sender_config) = &mut sender_environment.config else { + unreachable!("child environment config should be ready"); + }; + sender_config.permission_profile = + PermissionProfileSnapshot::legacy(PermissionProfile::workspace_write()); + test.submit_turn_with_environments(EVICT_PROMPT, Some(vec![sender_environment])) + .await?; let replacement_thread_id = created_threads.recv().await?; let replacement_thread = test .thread_manager @@ -303,6 +322,10 @@ async fn v2_residency_reload_preserves_inherited_environment_and_tools() -> Resu .environments, vec![child_environment] ); + assert_eq!( + reloaded_worker.config_snapshot().await.permission_profile, + PermissionProfile::read_only() + ); let worker_tools = |response_mock: &ResponseMock| { response_mock diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index ac27c3d54a..1329863048 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use codex_core::ForkSnapshot; use codex_core::NewThread; -use codex_core::ThreadConfigSnapshot; use codex_core::TurnInputRequest; use codex_core::parse_turn_item; use codex_history::InitialHistory; @@ -14,6 +13,7 @@ use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsAppliedEvent; +use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -110,7 +110,7 @@ async fn fork_thread_twice_drops_to_first_message() { let fork1_path = codex_fork1.rollout_path().expect("rollout path"); expected_after_first.push(thread_settings_applied_item( - codex_fork1.config_snapshot().await, + codex_fork1.thread_settings_snapshot().await, )); // GetHistory on fork1 flushed; the file is ready. @@ -145,7 +145,7 @@ async fn fork_thread_twice_drops_to_first_message() { .unwrap_or(0); let mut expected_after_second: Vec = fork1_items[..cut_last_on_fork1].to_vec(); expected_after_second.push(thread_settings_applied_item( - codex_fork2.config_snapshot().await, + codex_fork2.thread_settings_snapshot().await, )); let fork2_items = read_rollout_items(&fork2_path); pretty_assertions::assert_eq!( @@ -154,10 +154,10 @@ async fn fork_thread_twice_drops_to_first_message() { ); } -fn thread_settings_applied_item(snapshot: ThreadConfigSnapshot) -> RolloutItem { +fn thread_settings_applied_item(snapshot: ThreadSettingsSnapshot) -> RolloutItem { RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( ThreadSettingsAppliedEvent { - thread_settings: snapshot.into_thread_settings_snapshot(), + thread_settings: snapshot, }, )) } diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 427f8f5227..b1aff33f7e 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -46,6 +46,7 @@ use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfileSnapshot; @@ -464,6 +465,13 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> let test = builder.build_with_auto_env(&server).await?; let selection = test.executor_environment().selection().clone(); let marker = selection.cwd.join(FILE_NAME)?; + let owner_active_profile = ActivePermissionProfile::new("owner-read-only"); + let owner_profile_workspace_root = test.config.cwd.join("owner-profile-root"); + let owner_permission_profile = PermissionProfileSnapshot::active_with_profile_workspace_roots( + PermissionProfile::read_only(), + owner_active_profile.clone(), + vec![owner_profile_workspace_root.clone()], + ); let (shell, command) = match test_target_os() { TestTargetOs::Linux => ( @@ -549,30 +557,59 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> vec![TurnEnvironmentSelection { config: EnvironmentConfigState::Ready(EnvironmentConfig { allow_login_shell: test.config.permissions.allow_login_shell, - permission_profile: PermissionProfileSnapshot::legacy( - PermissionProfile::read_only(), - ), + permission_profile: owner_permission_profile, shell_environment_policy: Default::default(), exec_policy: None, mcp_policy: None, network_policy: None, selected_capability_roots: Vec::new(), }), - ..selection + ..selection.clone() }], )), ..Default::default() }, ) .await?; - submit_thread_settings( - &test.codex, - ThreadSettingsOverrides { - permission_profile: Some(PermissionProfile::workspace_write()), - ..Default::default() - }, - ) - .await?; + test.codex + .submit(Op::ThreadSettings { + thread_settings: ThreadSettingsOverrides { + permission_profile: Some(PermissionProfile::workspace_write()), + ..Default::default() + }, + }) + .await?; + let persisted_settings = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ThreadSettingsApplied(event) => Some(event.thread_settings.clone()), + _ => None, + }) + .await; + let snapshot = test.codex.config_snapshot().await; + assert_eq!(snapshot.permission_profile, PermissionProfile::read_only()); + assert_eq!( + snapshot.active_permission_profile, + Some(owner_active_profile.clone()) + ); + assert_eq!( + snapshot.profile_workspace_roots, + vec![owner_profile_workspace_root.clone()] + ); + assert_eq!( + persisted_settings, + test.codex.thread_settings_snapshot().await + ); + assert_ne!( + persisted_settings.active_permission_profile, + snapshot.active_permission_profile + ); + test.codex + .restore_thread_settings(test.codex.restorable_thread_settings().await) + .await?; + let (mcp_config, _) = test.codex.current_mcp_config_and_runtime_context().await; + assert_eq!( + mcp_config.permission_profile, + PermissionProfile::workspace_write() + ); test.submit_text_turn("try to write a file with owner-provided permissions") .await?; @@ -593,6 +630,34 @@ async fn environment_permissions_follow_configuration_ownership() -> Result<()> .is_err(), "read-only attachment unexpectedly wrote {FILE_NAME}" ); + let turn_context = test + .codex + .load_history(/*include_archived*/ false) + .await? + .items + .into_iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(context) => Some(context), + _ => None, + }) + .context("owner turn context")?; + assert!( + turn_context + .workspace_roots + .as_ref() + .is_some_and(|roots| roots.contains(&owner_profile_workspace_root)) + ); + assert_eq!( + ( + turn_context.permission_profile, + turn_context.active_permission_profile + ), + ( + Some(PermissionProfile::read_only()), + Some(owner_active_profile) + ) + ); Ok(()) } @@ -1516,6 +1581,14 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes() .thread .environment_failed(&pending_selection, "configuration unavailable".to_string()) .await?; + for (thread, configured) in [ + (&waiting.thread, false), + (&independent.thread, true), + (&failed.thread, false), + ] { + let snapshot = thread.config_snapshot().await; + assert_eq!(snapshot.is_primary_environment_configured(), configured); + } assert_eq!( failed.thread.environment_selections().await, vec![TurnEnvironmentSelection { @@ -2006,6 +2079,10 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments( .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) .with_config(move |config| { config.project_doc_max_bytes = 0; + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .expect("thread should allow workspace writes"); assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); assert!(config.features.enable(Feature::Collab).is_ok()); if multi_agent_v2 { @@ -2028,11 +2105,25 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments( ) .await .context("thread startup should not wait for the remote environment")??; + let owner_active_profile = ActivePermissionProfile::new("owner-read-only"); + let owner_profile_workspace_root = test.config.cwd.join("owner-profile-root"); let remote_selection = TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd: PathUri::from_abs_path(&test.config.cwd), workspace_roots: vec![PathUri::from_abs_path(&test.config.cwd)], - config: EnvironmentConfigState::FromThread, + config: EnvironmentConfigState::Ready(EnvironmentConfig { + allow_login_shell: test.config.permissions.allow_login_shell, + permission_profile: PermissionProfileSnapshot::active_with_profile_workspace_roots( + PermissionProfile::read_only(), + owner_active_profile.clone(), + vec![owner_profile_workspace_root.clone()], + ), + shell_environment_policy: Default::default(), + exec_policy: None, + mcp_policy: None, + network_policy: None, + selected_capability_roots: Vec::new(), + }), }; let expected_environments = vec![remote_selection, local(test.config.cwd.clone())]; let mut created_threads = test.thread_manager.subscribe_thread_created(); @@ -2068,6 +2159,24 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments( child_thread.environment_selections().await, expected_environments ); + let child_snapshot = child_thread.config_snapshot().await; + let child_settings = child_thread.thread_settings_snapshot().await; + assert_ne!( + child_settings.permission_profile, + child_snapshot.permission_profile + ); + assert_eq!( + ( + child_snapshot.permission_profile, + child_snapshot.active_permission_profile, + child_snapshot.profile_workspace_roots, + ), + ( + PermissionProfile::read_only(), + Some(owner_active_profile), + vec![owner_profile_workspace_root], + ) + ); assert!( response_mock.requests()[1] .function_call_output_content_and_success(wait_call_id) diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 2e8bcfa1c4..80519c6160 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -307,7 +307,7 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> { let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; let call_id = "shell-command-timeout"; let command = if cfg!(windows) { - "timeout /t 5" + "Start-Sleep -Seconds 5" } else { "sleep 5" }; diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 0d61493fff..4fc69e74b6 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -267,7 +267,6 @@ impl MemoryStartupContext { let mut client_session = model_client.new_session(); let window_id = format!("{}:0", self.thread_id); - let permission_profile = config.permissions.effective_permission_profile(); let responses_metadata = detached_memory_responses_metadata( installation_id, session_id_string, @@ -275,7 +274,7 @@ impl MemoryStartupContext { window_id, &session_source, &config.cwd, - &permission_profile, + &config_snapshot.permission_profile, /*sandbox*/ None, ) .await; diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 37dcf1a2c5..b1de7d0af8 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -452,6 +452,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier_and_detached_meta &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), + permission_profile: Some(codex_protocol::models::PermissionProfile::workspace_write()), ..Default::default() }, ) @@ -507,7 +508,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier_and_detached_meta let metadata: serde_json::Value = serde_json::from_str(&metadata_header).expect("turn metadata json"); assert_eq!(metadata["request_kind"].as_str(), Some("memory")); - assert_eq!(metadata["sandbox_mode"].as_str(), Some("read-only")); + assert_eq!(metadata["sandbox_mode"].as_str(), Some("workspace-write")); assert!(metadata.get("session_id").is_none()); assert!(metadata.get("thread_id").is_none()); assert!(metadata.get("turn_id").is_none());