Merge remote-tracking branch 'openai/main'

This commit is contained in:
2026-02-14 11:40:49 -08:00
11 changed files with 478 additions and 13 deletions
+6
View File
@@ -945,6 +945,9 @@ impl Session {
cwd.clone(),
session_configuration.sandbox_policy.get(),
session_configuration.windows_sandbox_level,
per_turn_config
.features
.enabled(Feature::UseLinuxSandboxBwrap),
));
TurnContext {
sub_id,
@@ -4072,6 +4075,9 @@ async fn spawn_review_thread(
parent_turn_context.cwd.clone(),
&parent_turn_context.sandbox_policy,
parent_turn_context.windows_sandbox_level,
parent_turn_context
.features
.enabled(Feature::UseLinuxSandboxBwrap),
));
let review_turn_context = TurnContext {
+54
View File
@@ -6,6 +6,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
pub(crate) fn sandbox_tag(
policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
use_linux_sandbox_bwrap: bool,
) -> &'static str {
if matches!(policy, SandboxPolicy::DangerFullAccess) {
return "none";
@@ -17,8 +18,61 @@ pub(crate) fn sandbox_tag(
{
return "windows_elevated";
}
if cfg!(target_os = "linux") && use_linux_sandbox_bwrap {
return "linux_bubblewrap";
}
get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled)
.map(SandboxType::as_metric_tag)
.unwrap_or("none")
}
#[cfg(test)]
mod tests {
use super::sandbox_tag;
use crate::exec::SandboxType;
use crate::protocol::SandboxPolicy;
use crate::safety::get_platform_sandbox;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::NetworkAccess;
use pretty_assertions::assert_eq;
#[test]
fn danger_full_access_is_untagged_even_when_bubblewrap_is_enabled() {
let actual = sandbox_tag(
&SandboxPolicy::DangerFullAccess,
WindowsSandboxLevel::Disabled,
true,
);
assert_eq!(actual, "none");
}
#[test]
fn external_sandbox_keeps_external_tag_when_bubblewrap_is_enabled() {
let actual = sandbox_tag(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
},
WindowsSandboxLevel::Disabled,
true,
);
assert_eq!(actual, "external");
}
#[test]
fn bubblewrap_feature_sets_distinct_linux_tag() {
let actual = sandbox_tag(
&SandboxPolicy::new_read_only_policy(),
WindowsSandboxLevel::Disabled,
true,
);
let expected = if cfg!(target_os = "linux") {
"linux_bubblewrap"
} else {
get_platform_sandbox(false)
.map(SandboxType::as_metric_tag)
.unwrap_or("none")
};
assert_eq!(actual, expected);
}
}
+11 -2
View File
@@ -4,6 +4,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::client_common::tools::ToolSpec;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::protocol::SandboxPolicy;
use crate::sandbox_tags::sandbox_tag;
@@ -88,6 +89,10 @@ impl ToolRegistry {
sandbox_tag(
&invocation.turn.sandbox_policy,
invocation.turn.windows_sandbox_level,
invocation
.turn
.features
.enabled(Feature::UseLinuxSandboxBwrap),
),
),
(
@@ -356,8 +361,12 @@ async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) {
success: dispatch.success,
duration_ms: u64::try_from(dispatch.duration.as_millis()).unwrap_or(u64::MAX),
mutating: dispatch.mutating,
sandbox: sandbox_tag(&turn.sandbox_policy, turn.windows_sandbox_level)
.to_string(),
sandbox: sandbox_tag(
&turn.sandbox_policy,
turn.windows_sandbox_level,
turn.features.enabled(Feature::UseLinuxSandboxBwrap),
)
.to_string(),
sandbox_policy: sandbox_policy_tag(&turn.sandbox_policy).to_string(),
output_preview: dispatch.output_preview.clone(),
},
+57 -1
View File
@@ -132,9 +132,17 @@ impl TurnMetadataState {
cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
use_linux_sandbox_bwrap: bool,
) -> Self {
let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned());
let sandbox = Some(sandbox_tag(sandbox_policy, windows_sandbox_level).to_string());
let sandbox = Some(
sandbox_tag(
sandbox_policy,
windows_sandbox_level,
use_linux_sandbox_bwrap,
)
.to_string(),
);
let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None);
let base_header = base_metadata
.to_header_value()
@@ -290,4 +298,52 @@ mod tests {
Some(false)
);
}
#[test]
fn turn_metadata_state_respects_linux_bubblewrap_toggle() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().to_path_buf();
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let without_bubblewrap = TurnMetadataState::new(
"turn-a".to_string(),
cwd.clone(),
&sandbox_policy,
WindowsSandboxLevel::Disabled,
false,
);
let with_bubblewrap = TurnMetadataState::new(
"turn-b".to_string(),
cwd,
&sandbox_policy,
WindowsSandboxLevel::Disabled,
true,
);
let without_bubblewrap_header = without_bubblewrap
.current_header_value()
.expect("without_bubblewrap_header");
let with_bubblewrap_header = with_bubblewrap
.current_header_value()
.expect("with_bubblewrap_header");
let without_bubblewrap_json: Value =
serde_json::from_str(&without_bubblewrap_header).expect("without_bubblewrap_json");
let with_bubblewrap_json: Value =
serde_json::from_str(&with_bubblewrap_header).expect("with_bubblewrap_json");
let without_bubblewrap_sandbox = without_bubblewrap_json
.get("sandbox")
.and_then(Value::as_str);
let with_bubblewrap_sandbox = with_bubblewrap_json.get("sandbox").and_then(Value::as_str);
let expected_with_bubblewrap =
sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, true);
assert_eq!(with_bubblewrap_sandbox, Some(expected_with_bubblewrap));
if cfg!(target_os = "linux") {
assert_eq!(with_bubblewrap_sandbox, Some("linux_bubblewrap"));
assert_ne!(with_bubblewrap_sandbox, without_bubblewrap_sandbox);
}
}
}
@@ -20,6 +20,7 @@ use codex_core::features::Features;
use codex_core::protocol::ElicitationAction;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::FileChange;
use codex_core::protocol::NetworkApprovalContext;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::mcp::RequestId;
@@ -42,6 +43,7 @@ pub(crate) enum ApprovalRequest {
id: String,
command: Vec<String>,
reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ApplyPatch {
@@ -108,11 +110,23 @@ impl ApprovalOverlay {
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec {
network_approval_context,
proposed_execpolicy_amendment,
..
} => (
exec_options(proposed_execpolicy_amendment.clone()),
"Would you like to run the following command?".to_string(),
exec_options(
proposed_execpolicy_amendment.clone(),
network_approval_context.as_ref(),
),
network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
|network_approval_context| {
format!(
"Do you want to approve access to \"{}\"?",
network_approval_context.host
)
},
),
),
ApprovalVariant::ApplyPatch { .. } => (
patch_options(),
@@ -342,6 +356,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
id,
command,
reason,
network_approval_context,
proposed_execpolicy_amendment,
} => {
let mut header: Vec<Line<'static>> = Vec::new();
@@ -359,6 +374,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
variant: ApprovalVariant::Exec {
id,
command,
network_approval_context,
proposed_execpolicy_amendment,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
@@ -414,6 +430,7 @@ enum ApprovalVariant {
Exec {
id: String,
command: Vec<String>,
network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ApplyPatch {
@@ -447,7 +464,33 @@ impl ApprovalOption {
}
}
fn exec_options(proposed_execpolicy_amendment: Option<ExecPolicyAmendment>) -> Vec<ApprovalOption> {
fn exec_options(
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
network_approval_context: Option<&NetworkApprovalContext>,
) -> Vec<ApprovalOption> {
if network_approval_context.is_some() {
return vec![
ApprovalOption {
label: "Yes, just this once".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Yes, and allow this host for this session".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
];
}
vec![ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
@@ -531,6 +574,7 @@ fn elicitation_options() -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::NetworkApprovalProtocol;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -539,6 +583,7 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
network_approval_context: None,
proposed_execpolicy_amendment: None,
}
}
@@ -581,6 +626,7 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string()],
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".to_string(),
])),
@@ -619,6 +665,7 @@ mod tests {
id: "test".into(),
command,
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
};
@@ -641,6 +688,67 @@ mod tests {
);
}
#[test]
fn network_exec_options_use_expected_labels_and_hide_execpolicy_amendment() {
let network_context = NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
};
let options = exec_options(
Some(ExecPolicyAmendment::new(vec!["curl".to_string()])),
Some(&network_context),
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
assert_eq!(
labels,
vec![
"Yes, just this once".to_string(),
"Yes, and allow this host for this session".to_string(),
"No, and tell Codex what to do differently".to_string(),
]
);
}
#[test]
fn network_exec_prompt_title_includes_host() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec {
id: "test".into(),
command: vec!["curl".into(), "https://example.com".into()],
reason: Some("network request blocked".into()),
network_approval_context: Some(NetworkApprovalContext {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
}),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])),
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("Do you want to approve access to \"example.com\"?")),
"expected network title to include host, got {rendered:?}"
);
assert!(
!rendered.iter().any(|line| line.contains("don't ask again")),
"network prompt should not show execpolicy option, got {rendered:?}"
);
}
#[test]
fn exec_history_cell_wraps_with_two_space_indent() {
let command = vec![
+1
View File
@@ -1004,6 +1004,7 @@ mod tests {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
}
}
+31 -7
View File
@@ -2400,6 +2400,7 @@ impl ChatWidget {
id: ev.call_id,
command: ev.command,
reason: ev.reason,
network_approval_context: ev.network_approval_context,
proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment,
};
self.bottom_pane
@@ -5428,15 +5429,23 @@ impl ChatWidget {
});
})]
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
name.clone(),
)
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
name.clone(),
)
}
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
Self::approval_preset_actions(preset.approval, preset.sandbox.clone(), name.clone())
};
items.push(SelectionItem {
name,
@@ -5490,6 +5499,7 @@ impl ChatWidget {
fn approval_preset_actions(
approval: AskForApproval,
sandbox: SandboxPolicy,
label: String,
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
let sandbox_clone = sandbox.clone();
@@ -5506,6 +5516,9 @@ impl ChatWidget {
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone));
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(format!("Permissions updated to {label}"), None),
)));
})]
}
@@ -5552,6 +5565,7 @@ impl ChatWidget {
preset: ApprovalPreset,
return_to_permissions: bool,
) {
let selected_name = preset.label.to_string();
let approval = preset.approval;
let sandbox = preset.sandbox;
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
@@ -5568,12 +5582,14 @@ impl ChatWidget {
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone());
let mut accept_actions =
Self::approval_preset_actions(approval, sandbox.clone(), selected_name.clone());
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox);
let mut accept_and_remember_actions =
Self::approval_preset_actions(approval, sandbox, selected_name);
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
@@ -5683,7 +5699,11 @@ impl ChatWidget {
}));
}
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
accept_actions.extend(Self::approval_preset_actions(approval, sandbox));
accept_actions.extend(Self::approval_preset_actions(
approval,
sandbox,
mode_label.to_string(),
));
}
let mut accept_and_remember_actions: Vec<SelectionAction> = Vec::new();
@@ -5692,7 +5712,11 @@ impl ChatWidget {
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
}));
if let (Some(approval), Some(sandbox)) = (approval, sandbox) {
accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox));
accept_and_remember_actions.extend(Self::approval_preset_actions(
approval,
sandbox,
mode_label.to_string(),
));
}
let items = vec![
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Full Access
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Default
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Default (non-admin sandbox)
+192
View File
@@ -20,6 +20,8 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::Constrained;
use codex_core::config::ConstraintError;
#[cfg(target_os = "windows")]
use codex_core::config::types::WindowsSandboxModeToml;
use codex_core::config_loader::RequirementSource;
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
@@ -5465,6 +5467,196 @@ async fn approvals_popup_navigation_skips_disabled() {
);
}
#[tokio::test]
async fn permissions_selection_emits_history_cell_when_selection_changes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.notices.hide_full_access_warning = Some(true);
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
assert_eq!(
cells.len(),
1,
"expected one permissions selection history cell"
);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Permissions updated to"),
"expected permissions selection history message, got: {rendered}"
);
}
#[tokio::test]
async fn permissions_selection_history_snapshot_after_mode_switch() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.notices.hide_full_access_warning = Some(true);
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
#[cfg(target_os = "windows")]
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one mode-switch history cell");
assert_snapshot!(
"permissions_selection_history_after_mode_switch",
lines_to_single_string(&cells[0])
);
}
#[tokio::test]
async fn permissions_selection_history_snapshot_full_access_to_default() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.notices.hide_full_access_warning = Some(true);
chat.config
.permissions
.approval_policy
.set(AskForApproval::Never)
.expect("set approval policy");
chat.config
.permissions
.sandbox_policy
.set(SandboxPolicy::DangerFullAccess)
.expect("set sandbox policy");
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one mode-switch history cell");
let rendered = lines_to_single_string(&cells[0]);
#[cfg(target_os = "windows")]
insta::with_settings!({ snapshot_suffix => "windows" }, {
assert_snapshot!("permissions_selection_history_full_access_to_default", rendered);
});
#[cfg(not(target_os = "windows"))]
assert_snapshot!(
"permissions_selection_history_full_access_to_default",
rendered
);
}
#[tokio::test]
async fn permissions_selection_emits_history_cell_when_current_is_selected() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.expect("set approval policy");
chat.config
.permissions
.sandbox_policy
.set(SandboxPolicy::new_workspace_write_policy())
.expect("set sandbox policy");
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
assert_eq!(
cells.len(),
1,
"expected history cell even when selecting current permissions"
);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Permissions updated to"),
"expected permissions update history message, got: {rendered}"
);
}
#[tokio::test]
async fn permissions_full_access_history_cell_emitted_only_after_confirmation() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
#[cfg(target_os = "windows")]
{
chat.config.notices.hide_world_writable_warning = Some(true);
chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated));
}
chat.config.notices.hide_full_access_warning = None;
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
#[cfg(target_os = "windows")]
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let mut open_confirmation_event = None;
let mut cells_before_confirmation = Vec::new();
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
cells_before_confirmation.push(cell.display_lines(80));
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
} => {
open_confirmation_event = Some((preset, return_to_permissions));
}
_ => {}
}
}
if cfg!(not(target_os = "windows")) {
assert!(
cells_before_confirmation.is_empty(),
"did not expect history cell before confirming full access"
);
}
let (preset, return_to_permissions) =
open_confirmation_event.expect("expected full access confirmation event");
chat.open_full_access_confirmation(preset, return_to_permissions);
let popup = render_bottom_popup(&chat, 80);
assert!(
popup.contains("Enable full access?"),
"expected full access confirmation popup, got: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells_after_confirmation = drain_insert_history(&mut rx);
let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len();
assert_eq!(
total_history_cells, 1,
"expected one full access history cell total"
);
let rendered = if !cells_before_confirmation.is_empty() {
lines_to_single_string(&cells_before_confirmation[0])
} else {
lines_to_single_string(&cells_after_confirmation[0])
};
assert!(
rendered.contains("Permissions updated to Full Access"),
"expected full access update history message, got: {rendered}"
);
}
//
// Snapshot test: command approval modal
//