Separate thread settings from environment configuration (#39597)

## Why

Environment-owned permission profiles and workspace roots must remain effective
without being persisted or restored as thread-owned settings.

## What changed

- Keep thread settings snapshots and restore data separate from the effective
  configuration supplied by the primary environment.
- Use resolved environment permissions, active profiles, workspace roots, and
  network policy when building turn context and session events.
- Preserve environment selections when agents are evicted, resumed, or spawned,
  and validate owner configuration when it becomes ready.
- Start memory initialization only after the primary environment is configured.

## Testing

- Extend remote-environment and multi-agent tests to cover settings persistence,
  owner permission profiles, workspace roots, spawning, eviction, and resume.

GitOrigin-RevId: d82f126981cd0190233020bbdd61d96c70558868
This commit is contained in:
sayan-oai
2026-08-20 05:29:12 +00:00
committed by copyberry
parent 8f4a48a6ad
commit d75c85f651
35 changed files with 564 additions and 308 deletions
@@ -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())
@@ -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<Turn> {
let mut builder = ThreadHistoryBuilder::new();
@@ -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 {
@@ -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),
@@ -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<DateTime<Utc>> {
timestamp.and_then(|ts| {
@@ -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
@@ -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;
}
+5
View File
@@ -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,
})
+66 -7
View File
@@ -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<String, AgentMetadata>,
thread_paths: HashMap<ThreadId, String>,
thread_paths: HashMap<ThreadId, RegisteredAgent>,
used_agent_nicknames: HashSet<String>,
nickname_reset_count: usize,
}
struct RegisteredAgent {
path: String,
evicted_environments: Option<Vec<TurnEnvironmentSelection>>,
}
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<ThreadId>,
@@ -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<TurnEnvironmentSelection>,
) {
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<Vec<TurnEnvironmentSelection>> {
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<AgentMetadata> {
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
+26 -39
View File
@@ -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()
@@ -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<AbsolutePathBuf> {
@@ -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())
+3 -4
View File
@@ -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<S
let cwd = environment
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| turn.cwd.clone());
let permission_profile = environment
.map(TurnEnvironment::permission_profile_with_workspace_roots)
.unwrap_or_else(|| turn.permission_profile());
let permission_profile = context
.environments()
.permission_profile_or_else(|| turn.permission_profile());
let file_system_policy = permission_profile.file_system_sandbox_policy();
let mut entries = file_system_policy
.get_unreadable_roots_with_cwd(&cwd)
+1 -3
View File
@@ -88,9 +88,7 @@ fn approval_metadata(
}
fn approval_config(turn_context: &TurnContext) -> 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<'_> {
+6
View File
@@ -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();
+18 -3
View File
@@ -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<String>,
@@ -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()
{
+83 -31
View File
@@ -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<TurnEnvironmentSelection>,
) -> 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,
}),
})
+21 -16
View File
@@ -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);
+4 -5
View File
@@ -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,
})
}
+45 -8
View File
@@ -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<AbsolutePathBuf> {
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::<Vec<_>>();
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<ReasoningEffortConfig> {
@@ -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())
}),
+3 -13
View File
@@ -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::<Vec<_>>();
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())
+1 -1
View File
@@ -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
+7 -4
View File
@@ -325,6 +325,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions {
pub(crate) agent_control: AgentControl,
pub(crate) session_source: SessionSource,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environment_selections: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) inherited_environments: Option<TurnEnvironmentSnapshot>,
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
}
@@ -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),
@@ -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<Session>,
turn: &Arc<TurnContext>,
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,
@@ -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
@@ -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,
@@ -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<Config, FunctionCallError> {
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<Config, FunctionCallError> {
let mut config = build_agent_shared_config(turn, environment)?;
pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
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<Config, FunctionCallError> {
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
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}"))
})?;
@@ -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);
}
@@ -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
@@ -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,
+31 -8
View File
@@ -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
+5 -5
View File
@@ -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<RolloutItem> = 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,
},
))
}
+122 -13
View File
@@ -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)
+1 -1
View File
@@ -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"
};
+1 -2
View File
@@ -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;
+2 -1
View File
@@ -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());