Restore permission profiles when resuming threads (#39153)

## Why

Cold resumes and forks could lose the thread's active permission profile and
fall back to the current configured default.

## What changed

- Restore the latest persisted approval policy, approvals reviewer, and active
  permission-profile ID when resuming or forking a thread.
- Re-resolve persisted profile IDs through current configuration and
  requirements. Missing or invalid profiles fall back to the configured
  default, while threads without a persisted profile continue to use current
  configuration.
- Keep explicit permission and approval overrides ahead of persisted settings.

## Testing

Added coverage for legacy and paginated histories, settings updates, explicit
overrides, removed or changed profiles, requirement fallbacks, and forks.

GitOrigin-RevId: 2289af2f3206bf131a6ffe0e8447e8d402e76c47
This commit is contained in:
Shijie Rao
2026-08-18 06:22:04 +00:00
committed by copyberry
parent 6f95f19103
commit 539a09cb28
11 changed files with 935 additions and 268 deletions
@@ -1,3 +1,4 @@
use super::persisted_resume_settings::PersistedResumeSettings;
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;
@@ -226,42 +227,6 @@ fn merge_persisted_resume_metadata(
}
}
fn latest_persisted_approval_policy(
history: &[RolloutItem],
) -> Option<codex_protocol::protocol::AskForApproval> {
history
.iter()
.enumerate()
.rev()
.find_map(|(index, item)| match item {
RolloutItem::TurnContext(turn_context) => {
let updated_policy = turn_context.turn_id.as_ref().and_then(|turn_id| {
let turn_start = history[..index].iter().rposition(|item| {
matches!(
item,
RolloutItem::EventMsg(EventMsg::TurnStarted(event))
if &event.turn_id == turn_id
)
})?;
history[turn_start + 1..index]
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => {
Some(event.thread_settings.approval_policy)
}
_ => None,
})
});
Some(updated_policy.unwrap_or(turn_context.approval_policy))
}
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => {
Some(event.thread_settings.approval_policy)
}
_ => None,
})
}
fn normalize_thread_list_cwd_filters(
cwd: Option<ThreadListCwdFilter>,
) -> Result<Option<Vec<PathBuf>>, JSONRPCErrorError> {
@@ -297,6 +262,18 @@ fn has_model_resume_override(
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
}
fn has_permission_override(
request_overrides: Option<&HashMap<String, serde_json::Value>>,
typesafe_overrides: &ConfigOverrides,
) -> bool {
typesafe_overrides.sandbox_mode.is_some()
|| typesafe_overrides.permission_profile.is_some()
|| typesafe_overrides.default_permissions.is_some()
|| request_overrides.is_some_and(|overrides| {
overrides.contains_key("sandbox_mode") || overrides.contains_key("default_permissions")
})
}
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
@@ -3931,18 +3908,23 @@ impl ThreadRequestProcessor {
let InitialHistory::Resumed(resumed_history) = thread_history else {
return None;
};
if typesafe_overrides.approvals_reviewer.is_none()
&& !request_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"))
if let Some(persisted_settings) = latest_persisted_resume_settings(&resumed_history.history)
{
typesafe_overrides.approvals_reviewer =
latest_persisted_resume_settings(&resumed_history.history)
.and_then(|settings| settings.approvals_reviewer);
}
if typesafe_overrides.approval_policy.is_none() {
typesafe_overrides.approval_policy =
latest_persisted_approval_policy(&resumed_history.history);
if typesafe_overrides.approval_policy.is_none() {
typesafe_overrides.approval_policy = Some(persisted_settings.approval_policy);
}
if typesafe_overrides.approvals_reviewer.is_none()
&& !request_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"))
{
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
.map(|profile| profile.id);
}
}
let state_db_ctx = self.state_db.clone()?;
let persisted_metadata = state_db_ctx
@@ -4669,44 +4651,66 @@ impl ThreadRequestProcessor {
/*personality*/ None,
);
typesafe_overrides.ephemeral = ephemeral.then_some(true);
let latest_context = if paginated_source
&& typesafe_overrides.approvals_reviewer.is_none()
let restore_approval_policy = typesafe_overrides.approval_policy.is_none();
let restore_approvals_reviewer = typesafe_overrides.approvals_reviewer.is_none()
&& !request_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"))
{
.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"));
let restore_permission_profile =
!has_permission_override(request_overrides.as_ref(), &typesafe_overrides);
let needs_latest_settings =
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 {
typesafe_overrides.approvals_reviewer =
Some(parent.config_snapshot().await.approvals_reviewer);
None
} else if last_turn_id.is_some() || before_turn_id.is_some() {
Some(
self.thread_store
.load_latest_model_context(StoreLoadThreadHistoryParams {
thread_id: source_thread_id,
include_archived: true,
})
.await
.map_err(thread_store_resume_read_error)?
.items,
)
let snapshot = parent.config_snapshot().await;
Some(PersistedResumeSettings {
approval_policy: snapshot.approval_policy,
approvals_reviewer: Some(snapshot.approvals_reviewer),
active_permission_profile: snapshot.active_permission_profile,
})
} else {
None
}
} else {
None
};
if typesafe_overrides.approvals_reviewer.is_none()
&& !request_overrides
.as_ref()
.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"))
let latest_context = if paginated_source
&& needs_latest_settings
&& loaded_parent_settings.is_none()
&& (last_turn_id.is_some() || before_turn_id.is_some())
{
typesafe_overrides.approvals_reviewer = latest_persisted_resume_settings(
Some(
self.thread_store
.load_latest_model_context(StoreLoadThreadHistoryParams {
thread_id: source_thread_id,
include_archived: true,
})
.await
.map_err(thread_store_resume_read_error)?
.items,
)
} else {
None
};
let persisted_settings = loaded_parent_settings.or_else(|| {
latest_persisted_resume_settings(
latest_context
.as_deref()
.unwrap_or_else(|| source_history_items.as_ref()),
)
.and_then(|settings| settings.approvals_reviewer);
});
if let Some(persisted_settings) = persisted_settings {
if restore_approval_policy {
typesafe_overrides.approval_policy = Some(persisted_settings.approval_policy);
}
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);
}
}
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self