Preserve acknowledged permissions when resuming and forking threads (#57227)

## Why

A running turn can record stale permissions after the thread accepts a settings update. Resuming or forking from that history should preserve the accepted approval and permission settings.

## What changed

- Prefer settings snapshots owned by the source thread over stale turn contexts, including later legacy updates associated with that thread's checkpoint.
- Restore unnamed permission profiles from attributable settings updates while honoring explicit request overrides. Avoid restoring raw permission grants from history without an identifiable owner.
- Share permission restoration logic between resume and fork.

## Testing

Add regression coverage for stale turn contexts, legacy updates across thread ownership boundaries, and unnamed permission restoration on resume and fork.

GitOrigin-RevId: 450bc1688be78c7ec685f6dc56bcc5e5dd2d130a
This commit is contained in:
Charlie Marsh
2026-10-26 20:17:38 +00:00
committed by copyberry
parent ca8b7a0737
commit 3047741b63
5 changed files with 318 additions and 65 deletions
@@ -5,8 +5,8 @@ mod daemon_continuation;
mod daemon_snapshot;
use super::persisted_resume_settings::PersistedResumeSettings;
use super::persisted_resume_settings::latest_owned_thread_settings;
use super::persisted_resume_settings::latest_persisted_model_settings;
use super::persisted_resume_settings::latest_persisted_resume_settings;
use super::thread_enrichment::enrich_loaded_threads;
use super::thread_fork_goal::inherit_thread_goal_snapshot;
use super::thread_input::can_accept_direct_input;
@@ -38,7 +38,6 @@ use codex_protocol::SanitizedGitUrl;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_thread_store::PersistContext;
@@ -105,7 +104,6 @@ struct ResumeConfigState {
workspace_roots: Option<Vec<AbsolutePathBuf>>,
persisted_metadata: Option<ThreadMetadata>,
persisted_settings: Option<PersistedResumeSettings>,
persisted_permission_profile: Option<PermissionProfile>,
}
struct PreparedResumeConfig {
@@ -309,6 +307,29 @@ fn has_permission_override(
})
}
fn apply_persisted_permission_profile(
settings: &PersistedResumeSettings,
request_overrides: Option<&HashMap<String, serde_json::Value>>,
config_overrides: &mut ConfigOverrides,
) {
if has_permission_override(request_overrides, config_overrides) {
return;
}
config_overrides.persisted_permission_profile_id = settings
.active_permission_profile
.as_ref()
.map(|profile| profile.id.clone());
if !request_overrides.is_some_and(|overrides| {
overrides.keys().any(|key| {
key == "sandbox_workspace_write" || key.starts_with("sandbox_workspace_write.")
})
}) {
config_overrides
.persisted_permission_profile
.clone_from(&settings.unnamed_permission_profile);
}
}
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
@@ -4139,16 +4160,9 @@ impl ThreadRequestProcessor {
}
// Copied or referenced history can contain another thread's settings. Only snapshots
// explicitly owned by this thread can restore its permissions, cwd, and workspace folders.
// explicitly owned by this thread can restore its cwd and workspace folders.
let history_settings = if let InitialHistory::Resumed(resumed) = &thread_history {
resumed.history.iter().rev().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event))
if event.thread_id == Some(resumed.conversation_id) =>
{
Some(&event.thread_settings)
}
_ => None,
})
latest_owned_thread_settings(&resumed.history, resumed.conversation_id)
} else {
None
};
@@ -4242,27 +4256,12 @@ impl ThreadRequestProcessor {
let restore_summary = !has_override("model_reasoning_summary");
let restore_personality =
typesafe_overrides.personality.is_none() && !has_override("personality");
typesafe_overrides.persisted_permission_profile = history_settings
.filter(|settings| {
settings.active_permission_profile.is_none()
&& !has_permission_override(request_overrides.as_ref(), &typesafe_overrides)
&& !request_overrides.as_ref().is_some_and(|overrides| {
overrides.keys().any(|key| {
key == "sandbox_workspace_write"
|| key.starts_with("sandbox_workspace_write.")
})
})
})
.map(|settings| settings.permission_profile.clone());
let mut persisted_settings = match &thread_history {
InitialHistory::Resumed(resumed) => latest_persisted_resume_settings(&resumed.history),
let persisted_settings = match &thread_history {
InitialHistory::Resumed(resumed) => {
PersistedResumeSettings::from_history(&resumed.history, resumed.conversation_id)
}
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
};
// A TurnContext can describe an executor's profile, so an owned snapshot
// is authoritative for named permissions too.
if let (Some(settings), Some(snapshot)) = (&mut persisted_settings, history_settings) {
settings.active_permission_profile = snapshot.active_permission_profile.clone();
}
let persisted_metadata = self
.load_and_apply_persisted_resume_metadata(
&thread_history,
@@ -4281,7 +4280,6 @@ impl ThreadRequestProcessor {
workspace_roots: typesafe_overrides.workspace_roots.clone(),
persisted_metadata,
persisted_settings,
persisted_permission_profile: typesafe_overrides.persisted_permission_profile.clone(),
};
let mut config = match prepared_config.take() {
Some(prepared) if prepared.state == config_state => prepared.config,
@@ -4604,12 +4602,11 @@ impl ThreadRequestProcessor {
{
typesafe_overrides.approvals_reviewer = persisted_settings.approvals_reviewer;
}
if !has_permission_override(request_overrides.as_ref(), typesafe_overrides) {
typesafe_overrides.persisted_permission_profile_id = persisted_settings
.active_permission_profile
.as_ref()
.map(|profile| profile.id.clone());
}
apply_persisted_permission_profile(
persisted_settings,
request_overrides.as_ref(),
typesafe_overrides,
);
}
let state_db_ctx = self.state_db.clone()?;
let persisted_metadata = state_db_ctx
@@ -5526,11 +5523,7 @@ impl ThreadRequestProcessor {
let loaded_parent_settings = if paginated_source && needs_latest_settings {
if let Some(parent) = loaded_parent.as_ref() {
let snapshot = parent.thread_settings_snapshot().await;
Some(PersistedResumeSettings {
approval_policy: snapshot.approval_policy,
approvals_reviewer: Some(snapshot.approvals_reviewer),
active_permission_profile: snapshot.active_permission_profile,
})
Some(PersistedResumeSettings::from(&snapshot))
} else {
None
}
@@ -5570,11 +5563,10 @@ impl ThreadRequestProcessor {
.and_then(|parent| parent.multi_agent_version())
});
let persisted_settings = loaded_parent_settings.or_else(|| {
latest_persisted_resume_settings(
latest_context
.as_deref()
.unwrap_or_else(|| source_history_items.as_ref()),
)
let history = latest_context
.as_deref()
.unwrap_or_else(|| source_history_items.as_ref());
PersistedResumeSettings::from_history(history, source_thread_id)
});
if let Some(persisted_settings) = persisted_settings {
if restore_approval_policy {
@@ -5583,11 +5575,11 @@ impl ThreadRequestProcessor {
if restore_approvals_reviewer {
typesafe_overrides.approvals_reviewer = persisted_settings.approvals_reviewer;
}
if restore_permission_profile {
typesafe_overrides.persisted_permission_profile_id = persisted_settings
.active_permission_profile
.map(|profile| profile.id);
}
apply_persisted_permission_profile(
&persisted_settings,
request_overrides.as_ref(),
&mut typesafe_overrides,
);
}
let (inherited_options, inherited_settings) = inherited_fork.unzip();
let options = if let Some(options) = inherited_options {