Compare commits

...

4 Commits

Author SHA1 Message Date
viyatb-oai
e7e0e569cb fix(tui): align windows permission override args
Co-authored-by: Codex noreply@openai.com
2026-05-22 07:51:09 -07:00
viyatb-oai
4ac608f8a5 feat(tui): show custom permission profile descriptions
Co-authored-by: Codex noreply@openai.com
2026-05-22 07:51:07 -07:00
viyatb-oai
64c4ce59aa fix(tui): use permission profile snapshots
Co-authored-by: Codex noreply@openai.com
2026-05-22 07:39:05 -07:00
viyatb-oai
d809c40fa7 feat(tui): add permission profile picker
Co-authored-by: Codex noreply@openai.com
2026-05-22 07:39:05 -07:00
9 changed files with 424 additions and 10 deletions

View File

@@ -2075,7 +2075,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
@@ -2723,7 +2723,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 {
@@ -2748,8 +2748,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"),

View File

@@ -585,8 +585,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<String>,
/// User-defined permission profiles available from effective config.
pub custom_permission_profiles: Vec<CustomPermissionProfileSummary>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
@@ -1889,6 +1889,12 @@ pub struct AgentRoleConfig {
pub nickname_candidates: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomPermissionProfileSummary {
pub id: String,
pub description: Option<String>,
}
fn resolve_tool_suggest_config(
config_toml: &ConfigToml,
config_layer_stack: &ConfigLayerStack,
@@ -2624,11 +2630,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();
@@ -3361,7 +3374,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,

View File

@@ -179,7 +179,7 @@ fn new_config(model: Option<String>, 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,

View File

@@ -371,6 +371,7 @@ mod model_popups;
mod notifications;
use self::notifications::Notification;
mod permission_popups;
mod permissions_menu;
mod protocol;
mod protocol_requests;
mod rate_limits;

View File

@@ -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());

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

@@ -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]