From 6ca038bbd121b1a20d8d469ac3e57b3619a3c888 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 17:00:29 -0700 Subject: [PATCH] rollout: persist turn permission profiles (#18281) ## Why Resume and reconstruction need to preserve the permissions that were active for each user turn. If rollouts only keep legacy sandbox fields, replay cannot faithfully represent profile-shaped overrides introduced earlier in the stack. ## What changed This records `permission_profile` on user-turn rollout events, reconstructs it through history/state extraction, and updates rollout reconstruction and related fixtures to keep the field explicit. ## Verification - `cargo test -p codex-core --test all permissions_messages -- --nocapture` - `cargo test -p codex-core --test all request_permissions -- --nocapture` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18281). * #18288 * #18287 * #18286 * #18285 * #18284 * #18283 * #18282 * __->__ #18281 --- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 8 +++++++ codex-rs/core/src/session/tests.rs | 9 ++++++++ codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 21 +++++++++++++++++++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 3 +++ codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/lib.rs | 1 + 11 files changed, 48 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8b..bd8e77fd2 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem { timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0b8b7a588..862b2698d 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -49,7 +49,7 @@ fn build_permissions_update_item( } let prev = previous?; - if prev.sandbox_policy == *next.sandbox_policy.get() + if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { return None; diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index a3cb9a735..a865fa497 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -108,6 +109,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -917,6 +919,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -994,6 +997,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1024,6 +1028,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1138,6 +1143,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: current_model.to_string(), @@ -1251,6 +1257,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1402,6 +1409,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b88b8a408..4c5526869 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1606,6 +1606,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -5207,6 +5208,10 @@ async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() let item = turn_context.to_turn_context_item(); assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] @@ -5221,6 +5226,10 @@ async fn turn_context_item_stores_split_file_system_sandbox_policy_when_differen item.file_system_sandbox_policy, Some(file_system_sandbox_policy) ); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index f5a897904..af52361df 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -272,6 +272,7 @@ impl TurnContext { timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), + permission_profile: Some(self.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(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0368d9baf..e1e9220fa 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -33,6 +33,7 @@ fn resume_history( timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9ef9b9f84..c50671df1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3002,6 +3002,8 @@ pub struct TurnContextItem { pub timezone: Option, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3026,6 +3028,24 @@ pub struct TurnContextItem { pub truncation_policy: Option, } +impl TurnContextItem { + pub fn permission_profile(&self) -> PermissionProfile { + self.permission_profile.clone().unwrap_or_else(|| { + let file_system_sandbox_policy = + self.file_system_sandbox_policy.clone().unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &self.sandbox_policy, + &self.cwd, + ) + }); + PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&self.sandbox_policy), + ) + }) + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "mode", content = "limit", rename_all = "snake_case")] pub enum TruncationPolicy { @@ -5162,6 +5182,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index e5e898857..151698766 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -947,6 +947,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "test-model".to_string(), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 6d36f211a..f0fe3c693 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -301,6 +301,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -340,6 +341,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -373,6 +375,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 674e3204d..891c9649d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2689,6 +2689,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re timezone: None, approval_policy: primary_session.approval_policy, sandbox_policy: primary_session.sandbox_policy.clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-agent".to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8814cddda..ee50d0a17 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2197,6 +2197,7 @@ mod tests { timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model,