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**

<img width="832" alt="Profile mode permissions picker with custom
profiles"
src="https://github.com/user-attachments/assets/58b72431-418c-4839-9e39-575076db4c8f"
/>

**Legacy mode remains anonymous preset picker**

<img width="1232" alt="Legacy permissions picker"
src="https://github.com/user-attachments/assets/95f413ab-4cee-411c-9afb-92580a885c97"
/>

<img width="1296" height="906" alt="image"
src="https://github.com/user-attachments/assets/ea381a78-9904-4aa2-828f-b7f2e43f60f2"
/>

<img width="705" height="207" alt="Screenshot 2026-05-18 at 2 58 00 PM"
src="https://github.com/user-attachments/assets/2fa6dd71-0296-449e-a6de-a72d78a1cb70"
/>

## Validation

- `git diff --cached --check` before commit.
- Full test run skipped at the user request while pushing the split
stack.
This commit is contained in:
viyatb-oai authored and GitHub committed 2026-05-26 16:39:55 +00:00
1 parent ef6528c6c7
commit f6fd753039
9 files changed
+424 -10

No files matched your search

@@ -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());
@@ -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()
}
}
}
@@ -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
@@ -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
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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]