From f6fd753039ae93651e37b1fbc0535b301e6027d4 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 26 May 2026 09:39:55 -0700 Subject: [PATCH] tui: add named permission profile picker (#21559) ## Why Users who opt into named permission profiles through `default_permissions` or `[permissions.*]` should stay in named-profile semantics when they open `/permissions`. The legacy picker rewrites those users into anonymous preset state, which loses the active profile identity and hides custom configured profiles. ## What changed - Switch `/permissions` to a profile-aware picker when profile mode is active. - Show friendly built-in labels instead of raw `:` profile syntax. - Include configured custom profiles and their descriptions in the picker. - Route selections through the split TUI profile-selection flow below this PR. - Add TUI snapshots and regression coverage for built-ins, custom profiles, and conflicting legacy runtime overrides. ## Stack 1. [#22931](https://github.com/openai/codex/pull/22931): runtime/session/network propagation for active permission profiles. 2. [#23708](https://github.com/openai/codex/pull/23708): TUI selection plumbing and guardrail flow. 3. **This PR**: profile-aware `/permissions` menu and custom profile display. ## UX impact In profile mode, `/permissions` shows the same human-facing built-ins users already know: ```text Default Auto-review Full Access Read Only locked-down web-enabled ``` Selecting `locked-down` keeps `active_permission_profile = Some("locked-down")`; selecting a built-in keeps the friendly label while switching to its named built-in profile. ## Screenshots Live `$test-tui` smoke screenshots uploaded through GitHub attachments: **Profile mode with built-ins and custom profiles** Profile mode permissions picker with custom
profiles **Legacy mode remains anonymous preset picker** Legacy permissions picker image Screenshot 2026-05-18 at 2 58 00 PM ## Validation - `git diff --cached --check` before commit. - Full test run skipped at the user request while pushing the split stack. --- codex-rs/core/src/config/config_tests.rs | 11 +- codex-rs/core/src/config/mod.rs | 23 ++- codex-rs/thread-manager-sample/src/main.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 1 + .../tui/src/chatwidget/permission_popups.rs | 5 + .../tui/src/chatwidget/permissions_menu.rs | 171 +++++++++++++++++ ...__profile_permissions_selection_popup.snap | 20 ++ ..._selection_popup_with_custom_profiles.snap | 22 +++ .../tui/src/chatwidget/tests/permissions.rs | 179 ++++++++++++++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/permissions_menu.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2d5843ffda..e8866aab3a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2078,7 +2078,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl .await?; assert!(config.explicit_permission_profile_mode); - assert!(config.custom_permission_profile_ids.is_empty()); + assert!(config.custom_permission_profiles.is_empty()); let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config @@ -2726,7 +2726,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { - description: None, + description: Some("Workspace access.".to_string()), extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { @@ -2751,8 +2751,11 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() .await?; assert_eq!( - config.custom_permission_profile_ids, - vec!["dev".to_string()] + config.custom_permission_profiles, + vec![CustomPermissionProfileSummary { + id: "dev".to_string(), + description: Some("Workspace access.".to_string()), + }] ); let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( codex_home.path().join("memories"), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 97e0fd993f..3115921c2d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -586,8 +586,8 @@ pub struct Config { /// of the legacy `sandbox_mode` syntax. pub explicit_permission_profile_mode: bool, - /// User-defined permission profile IDs available from effective config. - pub custom_permission_profile_ids: Vec, + /// User-defined permission profiles available from effective config. + pub custom_permission_profiles: Vec, /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as @@ -1902,6 +1902,12 @@ pub struct AgentRoleConfig { pub nickname_candidates: Option>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomPermissionProfileSummary { + pub id: String, + pub description: Option, +} + fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -2638,11 +2644,18 @@ impl Config { permission_config_syntax, Some(PermissionConfigSyntax::Profiles) ); - let custom_permission_profile_ids = cfg + let custom_permission_profiles = cfg .permissions .as_ref() .map_or_else(Vec::new, |permissions| { - permissions.entries.keys().cloned().collect() + permissions + .entries + .iter() + .map(|(id, profile)| CustomPermissionProfileSummary { + id: id.clone(), + description: profile.description.clone(), + }) + .collect() }); let using_implicit_builtin_profile = permission_config_syntax.is_none() && effective_permission_selection.selected_profile_id.is_none(); @@ -3377,7 +3390,7 @@ impl Config { windows_sandbox_private_desktop, }, explicit_permission_profile_mode, - custom_permission_profile_ids, + custom_permission_profiles, approvals_reviewer: constrained_approvals_reviewer.value(), enforce_residency: enforce_residency.value, notify: cfg.notify, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 634327d007..56938f01e4 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -179,7 +179,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R Constrained::allow_any(PermissionProfile::read_only()), )?, explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), + custom_permission_profiles: Vec::new(), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a063372c36..2b86ab3d71 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -372,6 +372,7 @@ mod model_popups; mod notifications; use self::notifications::Notification; mod permission_popups; +mod permissions_menu; mod protocol; mod protocol_requests; mod rate_limits; diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index d908cf6775..b271422788 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -14,6 +14,11 @@ impl ChatWidget { /// Open a popup to choose the permissions mode. pub(crate) fn open_permissions_popup(&mut self) { + if self.config.explicit_permission_profile_mode { + self.open_permission_profiles_popup(); + return; + } + let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs new file mode 100644 index 0000000000..7c0477116c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -0,0 +1,171 @@ +use super::*; + +impl ChatWidget { + pub(super) fn open_permission_profiles_popup(&mut self) { + let active_profile_id = self + .config + .permissions + .active_permission_profile() + .map(|profile| profile.id); + let presets = builtin_approval_presets(); + let Some(read_only) = presets.iter().find(|preset| preset.id == "read-only") else { + self.add_error_message( + "Internal error: missing the 'read-only' approval preset.".to_string(), + ); + return; + }; + let Some(default) = presets.iter().find(|preset| preset.id == "auto") else { + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + let Some(full_access) = presets.iter().find(|preset| preset.id == "full-access") else { + self.add_error_message( + "Internal error: missing the 'full-access' approval preset.".to_string(), + ); + return; + }; + let mut items = vec![ + self.builtin_permission_mode_selection_item( + default, + ":workspace", + default + .description + .replace(" (Identical to Agent mode)", ""), + AskForApproval::from(default.approval), + ApprovalsReviewer::User, + ), + ]; + if self.config.features.enabled(Feature::GuardianApproval) { + items.push(self.builtin_permission_mode_selection_item( + default, + ":workspace", + AUTO_REVIEW_DESCRIPTION.to_string(), + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + )); + } + items.push(self.builtin_permission_mode_selection_item( + full_access, + ":danger-no-sandbox", + full_access.description.to_string(), + AskForApproval::from(full_access.approval), + ApprovalsReviewer::User, + )); + items.push(self.builtin_permission_mode_selection_item( + read_only, + ":read-only", + read_only.description.to_string(), + AskForApproval::from(read_only.approval), + ApprovalsReviewer::User, + )); + items.extend( + self.config + .custom_permission_profiles + .iter() + .map(|profile| { + Self::permission_profile_selection_item( + &profile.id, + &profile.id, + profile + .description + .as_deref() + .unwrap_or("Configured permission profile."), + active_profile_id.as_deref(), + ) + }), + ); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Update Model Permissions".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(()), + ..Default::default() + }); + } + + fn builtin_permission_mode_selection_item( + &self, + preset: &ApprovalPreset, + id: &str, + description: String, + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + ) -> SelectionItem { + let label = if approvals_reviewer == ApprovalsReviewer::AutoReview { + "Auto-review" + } else { + preset.label + }; + let active_profile_id = self + .config + .permissions + .active_permission_profile() + .map(|profile| profile.id); + let current_approval = + AskForApproval::from(self.config.permissions.approval_policy.value()); + let current_reviewer = self.config.approvals_reviewer; + let profile_id = id.to_string(); + let selection = PermissionProfileSelection { + profile_id, + approval_policy: Some(approval_policy), + approvals_reviewer: Some(approvals_reviewer), + display_label: label.to_string(), + }; + SelectionItem { + name: label.to_string(), + description: Some(description), + is_current: active_profile_id.as_deref() == Some(id) + && current_approval == approval_policy + && current_reviewer == approvals_reviewer, + actions: self.permission_mode_actions( + preset, + label.to_string(), + approvals_reviewer, + Some(selection), + /*return_to_permissions*/ true, + ), + dismiss_on_select: true, + disabled_reason: self + .config + .permissions + .approval_policy + .can_set(&approval_policy.to_core()) + .err() + .map(|err| err.to_string()) + .or_else(|| { + self.config + .permissions + .can_set_permission_profile(&preset.permission_profile) + .err() + .map(|err| err.to_string()) + }), + ..Default::default() + } + } + + fn permission_profile_selection_item( + label: &str, + id: &str, + description: &str, + active_profile_id: Option<&str>, + ) -> SelectionItem { + let id_for_action = id.to_string(); + let selection = PermissionProfileSelection { + profile_id: id_for_action.clone(), + approval_policy: None, + approvals_reviewer: None, + display_label: id_for_action, + }; + SelectionItem { + name: label.to_string(), + description: Some(description.to_string()), + is_current: active_profile_id == Some(id), + actions: Self::permission_profile_selection_actions(selection), + dismiss_on_select: true, + ..Default::default() + } + } +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap new file mode 100644 index 0000000000..c0a23815a0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Update Model Permissions + +› 1. Default (current) Codex can read and edit files in the current + workspace, and run commands. Approval is required to + access the internet or edit other files. + 2. Auto-review Same workspace-write permissions as Default, but + eligible `on-request` approvals are routed through the + auto-reviewer subagent. + 3. Full Access Codex can edit files outside this workspace and access + the internet without asking for approval. Exercise + caution when using. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap new file mode 100644 index 0000000000..3ca5753795 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Update Model Permissions + + 1. Default Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Auto-review Same workspace-write permissions as Default, but + eligible `on-request` approvals are routed through + the auto-reviewer subagent. + 3. Full Access Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. +› 5. locked-down (current) Inspect and patch only approved workspace files. + 6. web-enabled Workspace profile with network access. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index df59db17cb..f79fe3defc 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,4 +1,6 @@ use super::*; +use crate::legacy_core::config::CustomPermissionProfileSummary; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -63,6 +65,183 @@ async fn approvals_selection_popup_snapshot() { assert_chatwidget_snapshot!("approvals_selection_popup", popup); } +#[tokio::test] +async fn profile_permissions_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(":workspace"), + )) + .expect("set active profile"); + + chat.open_permissions_popup(); + + assert_chatwidget_snapshot!( + "profile_permissions_selection_popup", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + +#[tokio::test] +async fn profile_permissions_selection_popup_with_custom_profiles_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config.custom_permission_profiles = vec![ + CustomPermissionProfileSummary { + id: "locked-down".to_string(), + description: Some("Inspect and patch only approved workspace files.".to_string()), + }, + CustomPermissionProfileSummary { + id: "web-enabled".to_string(), + description: Some("Workspace profile with network access.".to_string()), + }, + ]; + chat.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new("locked-down"), + )) + .expect("set active profile"); + + chat.open_permissions_popup(); + + assert_chatwidget_snapshot!( + "profile_permissions_selection_popup_with_custom_profiles", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + +#[tokio::test] +async fn profile_permissions_selection_emits_named_profile_event_only() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + #[cfg(target_os = "windows")] + { + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.explicit_permission_profile_mode = true; + chat.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(":workspace"), + )) + .expect("set active profile"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + AppEvent::SelectPermissionProfile(PermissionProfileSelection { + profile_id, + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label, + }) if profile_id == ":workspace" && display_label == "Default" + )); +} + +#[tokio::test] +async fn profile_permissions_selection_emits_active_custom_profile() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config.custom_permission_profiles = vec![CustomPermissionProfileSummary { + id: "locked-down".to_string(), + description: None, + }]; + chat.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new("locked-down"), + )) + .expect("set active profile"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + AppEvent::SelectPermissionProfile(PermissionProfileSelection { + profile_id, + approval_policy: None, + approvals_reviewer: None, + display_label, + }) if profile_id == "locked-down" && display_label == "locked-down" + )); +} + +#[tokio::test] +async fn profile_permissions_selection_emits_auto_review_mode_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + #[cfg(target_os = "windows")] + { + chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); + } + chat.config.explicit_permission_profile_mode = true; + chat.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(":workspace"), + )) + .expect("set active profile"); + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + AppEvent::SelectPermissionProfile(PermissionProfileSelection { + profile_id, + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + display_label, + }) if profile_id == ":workspace" && display_label == "Auto-review" + )); +} + +#[tokio::test] +async fn profile_permissions_full_access_opens_confirmation() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); + chat.config.notices.hide_full_access_warning = None; + + chat.open_permissions_popup(); + chat.handle_key_event(KeyEvent::from(KeyCode::Up)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + AppEvent::OpenFullAccessConfirmation { + preset, + return_to_permissions: true, + profile_selection: Some(PermissionProfileSelection { + profile_id, + approval_policy: Some(AskForApproval::Never), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label, + }), + } if preset.id == "full-access" + && profile_id == ":danger-no-sandbox" + && display_label == "Full Access" + )); +} + #[cfg(target_os = "windows")] #[tokio::test] #[serial]