diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e81f4476f4..9b405eb9d1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2208,7 +2208,7 @@ async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> { ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2368,7 +2368,7 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2591,7 +2591,7 @@ async fn inactive_thread_approval_badge_clears_after_turn_completion_notificatio ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2645,7 +2645,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -2758,7 +2758,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -2827,7 +2827,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - permission_profile: Some(PermissionProfile::workspace_write()), + permission_profile: PermissionProfile::workspace_write(), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; app.primary_session_configured = Some(primary_session); @@ -2858,10 +2858,17 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { assert_eq!(session.thread_id, read_thread_id); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); + let expected_permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &app.config + .permissions + .legacy_sandbox_policy(thread.cwd.as_path()), + thread.cwd.as_path(), + ); assert_eq!( - session.permission_profile, None, - "thread/read does not return an authoritative permission profile; reusing the primary \ - session profile would reinterpret cwd-bound entries against the read thread cwd" + session.permission_profile, expected_permission_profile, + "thread/read does not return authoritative server permissions; the fallback profile must \ + be rebuilt from local legacy settings against the read thread cwd rather than reusing \ + the primary session profile" ); } @@ -3727,7 +3734,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: Some(PermissionProfile::read_only()), + permission_profile: PermissionProfile::read_only(), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index daf743b467..931869f19e 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -303,7 +303,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: Some(PermissionProfile::read_only()), + permission_profile: PermissionProfile::read_only(), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index b4d0fb2684..db02eca7dc 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -3,6 +3,7 @@ use crate::app_server_session::ThreadSessionState; use crate::read_session_model; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; impl App { pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) { @@ -16,12 +17,11 @@ impl App { .config .permissions .legacy_sandbox_policy(self.config.cwd.as_path()); - let permission_profile = Some( - self.chat_widget - .config_ref() - .permissions - .permission_profile(), - ); + let permission_profile = self + .chat_widget + .config_ref() + .permissions + .permission_profile(); let update_session = |session: &mut ThreadSessionState| { session.approval_policy = approval_policy; session.approvals_reviewer = approvals_reviewer; @@ -51,7 +51,7 @@ impl App { let sandbox_policy = self .config .permissions - .legacy_sandbox_policy(self.config.cwd.as_path()); + .legacy_sandbox_policy(thread.cwd.as_path()); let mut session = self .primary_session_configured .clone() @@ -66,7 +66,7 @@ impl App { approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: self.config.approvals_reviewer, sandbox_policy, - permission_profile: None, + permission_profile: self.legacy_permission_profile_for_cwd(thread.cwd.as_path()), cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), @@ -79,7 +79,11 @@ impl App { session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); session.cwd = thread.cwd.clone(); - session.permission_profile = None; + session.sandbox_policy = self + .config + .permissions + .legacy_sandbox_policy(thread.cwd.as_path()); + session.permission_profile = self.legacy_permission_profile_for_cwd(thread.cwd.as_path()); session.instruction_source_paths = Vec::new(); session.rollout_path = thread.path.clone(); if let Some(model) = @@ -93,6 +97,11 @@ impl App { session.history_entry_count = 0; session } + + fn legacy_permission_profile_for_cwd(&self, cwd: &std::path::Path) -> PermissionProfile { + let sandbox_policy = self.config.permissions.legacy_sandbox_policy(cwd); + PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd) + } } #[cfg(test)] @@ -128,7 +137,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + permission_profile: PermissionProfile::read_only(), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, @@ -202,7 +211,7 @@ mod tests { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, sandbox_policy: expected_sandbox_policy, - permission_profile: Some(expected_permission_profile), + permission_profile: expected_permission_profile, ..main_session }; assert_eq!( @@ -256,7 +265,7 @@ mod tests { NetworkSandboxPolicy::Restricted, ); let session = ThreadSessionState { - permission_profile: Some(profile.clone()), + permission_profile: profile.clone(), ..test_thread_session(thread_id, test_path_buf("/tmp/main")) }; @@ -276,7 +285,7 @@ mod tests { let expected_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, - permission_profile: Some(profile), + permission_profile: profile, ..session }; assert_eq!( diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 11779c204c..5a33896dd3 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -156,13 +156,13 @@ pub(crate) struct ThreadSessionState { pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - /// Legacy sandbox projection kept for compatibility. Use this only when - /// `permission_profile` is `None`. + /// Legacy sandbox projection kept only for compatibility fields that have + /// not migrated to `PermissionProfile` yet. pub(crate) sandbox_policy: SandboxPolicy, - /// Canonical active permissions when available. Consumers should prefer - /// this over `sandbox_policy`; `None` means the session only has a legacy - /// sandbox projection. - pub(crate) permission_profile: Option, + /// Canonical active permissions for this session. Legacy app-server + /// responses are converted to a profile at ingestion time using the + /// response cwd so cached sessions do not reinterpret cwd-bound grants. + pub(crate) permission_profile: PermissionProfile, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -1407,6 +1407,8 @@ async fn thread_session_state_from_thread_response( .map_err(|err| format!("forked_from_id is invalid: {err}"))?; let (history_log_id, history_entry_count) = message_history_metadata(config).await; let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); + let permission_profile = + permission_profile_from_response_permissions(&sandbox_policy, permission_profile, &cwd); Ok(ThreadSessionState { thread_id, @@ -1430,6 +1432,16 @@ async fn thread_session_state_from_thread_response( }) } +fn permission_profile_from_response_permissions( + sandbox_policy: &SandboxPolicy, + permission_profile: Option, + cwd: &AbsolutePathBuf, +) -> PermissionProfile { + permission_profile.unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.as_path()) + }) +} + pub(crate) fn app_server_rate_limit_snapshots_to_core( response: GetAccountRateLimitsResponse, ) -> Vec { @@ -1770,7 +1782,11 @@ mod tests { ); assert_eq!( started.session.permission_profile, - response.permission_profile.clone().map(Into::into) + response + .permission_profile + .clone() + .map(Into::into) + .expect("response includes profile") ); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1c08c77486..c167d04b49 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1618,7 +1618,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, sandbox_policy: session.sandbox_policy, - permission_profile: session.permission_profile, + permission_profile: Some(session.permission_profile), cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id,