tui: pass active permission profiles through app commands (#22891)

## Why

This continues the permissions migration by keeping the TUI command
boundary aligned with the app-server protocol direction from #22795:
callers should select a permission profile by id instead of passing a
concrete `PermissionProfile` value around as the turn configuration.

`AppCommand` is internal to the TUI, but it is the path that eventually
becomes `thread/turn/start`, so carrying concrete profile details there
made it too easy for UI code to keep relying on the old whole-profile
replacement model.

## What changed

- `AppCommand::UserTurn` and `AppCommand::OverrideTurnContext` now carry
`Option<ActivePermissionProfile>` instead of `PermissionProfile`.
- Composer submissions copy the active permission profile id from the
current session snapshot; legacy snapshots intentionally submit no
active profile id.
- Permission preset UI events now carry only the active built-in profile
id. The app derives the concrete built-in `PermissionProfile` internally
only when updating its local config/status snapshot.
- Permission presets expose their built-in active profile id, and preset
selection preserves that id in both the immediate turn override and the
local TUI config snapshot.
- Turn routing sends `TurnPermissionsOverride::ActiveProfile` when an
active id is present, and only falls back to the legacy sandbox
projection for the remaining runtime override path.

## How to review

Start with `codex-rs/tui/src/app_command.rs` to verify the command shape
no longer exposes `PermissionProfile`.

Then read `codex-rs/tui/src/app/thread_routing.rs` to verify the
app-server turn-start conversion: active ids go through as ids, while
the legacy sandbox fallback is still constrained to the existing runtime
override case.

Finally, check `codex-rs/tui/src/chatwidget/permission_popups.rs`,
`codex-rs/tui/src/app/event_dispatch.rs`,
`codex-rs/tui/src/app/config_persistence.rs`, and
`codex-rs/utils/approval-presets/src/lib.rs` to see how preset
selections stay id-only across TUI events while the local display/config
mirror still gets a concrete built-in profile.

## Verification

Latest local verification after the id-only `AppEvent` cleanup:

- `cargo check -p codex-tui --tests`
- `cargo test -p codex-tui
permissions_selection_sends_approvals_reviewer_in_override_turn_context`
- `cargo test -p codex-tui update_feature_flags_enabling_guardian`
- `cargo test -p codex-utils-approval-presets`
- `just fmt`
- `just fix -p codex-tui -p codex-utils-approval-presets`

Earlier in the same PR, before the final event-shape cleanup:

- `cargo test -p codex-tui turn_permissions_`
- `cargo test -p codex-tui submission_`
- `cargo test -p codex-tui
session_configured_syncs_widget_config_permissions_and_cwd`
- `RUST_MIN_STACK=16777216 cargo test -p codex-tui`
This commit is contained in:
Michael Bolin
2026-05-15 22:42:35 +00:00
committed by GitHub
parent 8543e39885
commit bbb5c2811d
21 changed files with 243 additions and 114 deletions
@@ -94,7 +94,7 @@ async fn submission_preserves_text_elements_and_local_images() {
}
#[tokio::test]
async fn submission_includes_configured_permission_profile() {
async fn submission_includes_configured_active_permission_profile() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
@@ -120,6 +120,7 @@ async fn submission_includes_configured_permission_profile() {
},
}
.into();
let expected_active_permission_profile = ActivePermissionProfile::new("custom");
let configured = crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
@@ -130,8 +131,8 @@ async fn submission_includes_configured_permission_profile() {
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
active_permission_profile: None,
permission_profile: expected_permission_profile,
active_permission_profile: Some(expected_active_permission_profile.clone()),
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
@@ -150,17 +151,21 @@ async fn submission_includes_configured_permission_profile() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let permission_profile = match next_submit_op(&mut op_rx) {
let active_permission_profile = match next_submit_op(&mut op_rx) {
Op::UserTurn {
permission_profile, ..
} => permission_profile,
active_permission_profile,
..
} => active_permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, expected_permission_profile);
assert_eq!(
active_permission_profile,
Some(expected_active_permission_profile)
);
}
#[tokio::test]
async fn submission_keeps_profile_when_legacy_projection_is_external() {
async fn submission_omits_active_permission_profile_for_legacy_snapshot() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
@@ -180,7 +185,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
permission_profile: expected_permission_profile,
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
@@ -197,13 +202,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
.set_composer_text("submit".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let permission_profile = match next_submit_op(&mut op_rx) {
let active_permission_profile = match next_submit_op(&mut op_rx) {
Op::UserTurn {
permission_profile, ..
} => permission_profile,
active_permission_profile,
..
} => active_permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, expected_permission_profile);
assert_eq!(active_permission_profile, None);
}
#[tokio::test]
@@ -296,8 +296,11 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
let updated_profile = PermissionProfile::workspace_write();
chat.set_permission_profile(updated_profile.clone())
.expect("set permission profile");
chat.set_permission_profile_from_session_snapshot(
updated_profile.clone(),
/*active_profile*/ None,
)
.expect("set permission profile");
assert_eq!(
chat.config_ref().permissions.permission_profile(),
&updated_profile,
@@ -742,7 +742,9 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
cwd: None,
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
permission_profile: Some(PermissionProfile::workspace_write()),
active_permission_profile: Some(ActivePermissionProfile::new(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
windows_sandbox_level: None,
model: None,
effort: None,
@@ -752,6 +754,20 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
personality: None,
}
);
let active_permission_profile_update = std::iter::from_fn(|| rx.try_recv().ok())
.find_map(|event| match event {
AppEvent::UpdateActivePermissionProfile(active_permission_profile) => {
Some(active_permission_profile)
}
_ => None,
})
.expect("expected UpdateActivePermissionProfile event");
assert_eq!(
active_permission_profile_update,
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE)
);
}
#[tokio::test]