Restore thread-owned permissions on cold resume (#53150)

## Why

Cold resume can replace saved unnamed permissions with current configuration defaults or recover an executor's permissions from turn context. A first-turn context can also predate an acknowledged settings update.

## What changed

- Restore eligible unnamed permission profiles from settings snapshots owned by the resumed thread, honoring explicit caller overrides and current managed requirements.
- Use owned snapshots as the authority for named permission profiles and reapply workspace permissions for the resumed workspace roots.
- Persist a thread-owned settings checkpoint after the first turn's context, serialized with settings updates so older checkpoints cannot supersede newer settings.

## Testing

Extend regression coverage for cold resume with changed defaults, managed permission restrictions, workspace-root overrides, foreign snapshots, and settings updates during first-turn processing.

GitOrigin-RevId: e411d9fd3d586a4162498c9c77c4184459071bd1
This commit is contained in:
Eric Traut
2026-10-11 15:48:35 +00:00
committed by copyberry
parent 97c1f62898
commit 7c9bfb910e
8 changed files with 208 additions and 57 deletions
@@ -4039,7 +4039,7 @@ impl ThreadRequestProcessor {
}
// Copied or referenced history can contain another thread's settings. Only snapshots
// explicitly owned by this thread can override its startup cwd and workspace folders.
// explicitly owned by this thread can restore its permissions, 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))
@@ -4131,9 +4131,31 @@ impl ThreadRequestProcessor {
}
let has_explicit_model_resume_override =
has_model_resume_override(request_overrides.as_ref(), &typesafe_overrides);
let 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),
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,
persisted_settings.as_ref(),
&mut request_overrides,
&mut typesafe_overrides,
)
@@ -4147,12 +4169,7 @@ impl ThreadRequestProcessor {
history_cwd: history_cwd.clone(),
workspace_roots: typesafe_overrides.workspace_roots.clone(),
persisted_metadata,
persisted_settings: match &thread_history {
InitialHistory::Resumed(resumed) => {
latest_persisted_resume_settings(&resumed.history)
}
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
},
persisted_settings,
};
let mut config = match prepared_config.take() {
Some(prepared) if prepared.state == config_state => prepared.config,
@@ -4181,18 +4198,18 @@ impl ThreadRequestProcessor {
match self
.thread_manager
.resume_thread_with_history(
config,
thread_history,
self.auth_manager.clone(),
match target {
.start_thread(StartThreadOptions {
initial_history: thread_history,
persisted_permission_profile,
parent_trace: match target {
ThreadResumeTarget::Client(request_id) => {
self.request_trace_context(request_id).await
}
ThreadResumeTarget::DaemonRecovery(_) => None,
},
client_mcp_extensions,
)
..StartThreadOptions::new(config)
})
.await
{
Ok(NewThread {
@@ -4441,14 +4458,14 @@ impl ThreadRequestProcessor {
async fn load_and_apply_persisted_resume_metadata(
&self,
thread_history: &InitialHistory,
persisted_settings: Option<&PersistedResumeSettings>,
request_overrides: &mut Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: &mut ConfigOverrides,
) -> Option<ThreadMetadata> {
let InitialHistory::Resumed(resumed_history) = thread_history else {
return None;
};
if let Some(persisted_settings) = latest_persisted_resume_settings(&resumed_history.history)
{
if let Some(persisted_settings) = persisted_settings {
if typesafe_overrides.approval_policy.is_none() {
typesafe_overrides.approval_policy = Some(persisted_settings.approval_policy);
}
@@ -4462,7 +4479,8 @@ impl ThreadRequestProcessor {
if !has_permission_override(request_overrides.as_ref(), typesafe_overrides) {
typesafe_overrides.persisted_permission_profile_id = persisted_settings
.active_permission_profile
.map(|profile| profile.id);
.as_ref()
.map(|profile| profile.id.clone());
}
}
let state_db_ctx = self.state_db.clone()?;