mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
## Why Collapsed patch details should remain accessible while an approval prompt is open, and reviewing them should not leave the transcript stuck behind new output after approval. ## What changed Allow clicking transcript disclosure controls while a modal prompt is open. When expanding details during patch approval interrupts following the latest output, resume following once that approval ends or advances to another patch. Preserve manual scrolling and text selection instead of automatically jumping to the latest output. ## Testing Extend patch approval coverage with an inline expansion snapshot and checks for queued approvals and subsequent output. Add a regression test that preserves manual scroll position when approval ends. GitOrigin-RevId: 249aa6bb2a84364a8cb111454fa5b6a3dce218b3
2599 lines
95 KiB
Rust
2599 lines
95 KiB
Rust
//! Approval modal rendering and decision routing for high-risk operations.
|
|
//!
|
|
//! This module converts agent approval requests (exec/apply-patch/MCP
|
|
//! elicitation) into a list-selection view with action-specific options and
|
|
//! shortcuts. It owns two important contracts:
|
|
//!
|
|
//! 1. Selection always emits an explicit decision event back to the app.
|
|
//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom
|
|
//! keybindings, so dismissal never silently becomes "continue without info".
|
|
//!
|
|
//! This module does not evaluate whether an action is safe to run; it only
|
|
//! presents choices and routes user decisions.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
|
#[cfg(test)]
|
|
use crate::app_command::AppCommand as Op;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::BottomPaneView;
|
|
use crate::bottom_pane::CancellationEvent;
|
|
use crate::bottom_pane::list_selection_view::ListSelectionView;
|
|
use crate::bottom_pane::list_selection_view::SelectionItem;
|
|
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
|
use crate::bottom_pane::popup_consts::accept_cancel_hint_line;
|
|
use crate::diff_model::FileChange;
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
use crate::history_cell;
|
|
use crate::history_cell::ReviewDecision;
|
|
use crate::key_hint;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::key_hint::KeyBindingListExt;
|
|
use crate::keymap::ApprovalKeymap;
|
|
use crate::keymap::ListAction;
|
|
use crate::keymap::ListKeymap;
|
|
use crate::render::highlight::highlight_bash_to_lines;
|
|
use crate::render::renderable::ColumnRenderable;
|
|
use crate::render::renderable::Renderable;
|
|
use codex_app_server_protocol::AdditionalPermissionProfile;
|
|
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
|
use codex_app_server_protocol::CommandExecutionApprovalKind;
|
|
use codex_app_server_protocol::FileChangeApprovalDecision;
|
|
use codex_app_server_protocol::FileSystemAccessMode;
|
|
use codex_app_server_protocol::FileSystemPath;
|
|
use codex_app_server_protocol::FileSystemSandboxEntry;
|
|
use codex_app_server_protocol::FileSystemSpecialPath;
|
|
use codex_app_server_protocol::McpServerElicitationAction;
|
|
use codex_app_server_protocol::NetworkApprovalContext;
|
|
use codex_app_server_protocol::NetworkApprovalProtocol;
|
|
use codex_app_server_protocol::NetworkPolicyRuleAction;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_features::Features;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::LegacyAppPathString;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use crossterm::event::KeyModifiers;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Wrap;
|
|
|
|
/// Request coming from the agent that needs user approval.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum ApprovalRequest {
|
|
Exec(ExecApprovalRequest),
|
|
Permissions(PermissionsApprovalRequest),
|
|
ApplyPatch(ApplyPatchApprovalRequest),
|
|
McpElicitation(McpElicitationApprovalRequest),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ExecApprovalRequest {
|
|
pub kind: CommandExecutionApprovalKind,
|
|
pub thread_id: ThreadId,
|
|
pub thread_label: Option<String>,
|
|
pub id: String,
|
|
pub environment_id: Option<String>,
|
|
pub command: Vec<String>,
|
|
pub reason: Option<String>,
|
|
pub available_decisions: Vec<CommandExecutionApprovalDecision>,
|
|
pub network_approval_context: Option<NetworkApprovalContext>,
|
|
pub additional_permissions: Option<AdditionalPermissionProfile>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct PermissionsApprovalRequest {
|
|
pub thread_id: ThreadId,
|
|
pub thread_label: Option<String>,
|
|
pub call_id: String,
|
|
pub environment_id: Option<String>,
|
|
pub reason: Option<String>,
|
|
pub permissions: RequestPermissionProfile,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct ApplyPatchApprovalRequest {
|
|
pub thread_id: ThreadId,
|
|
pub thread_label: Option<String>,
|
|
pub id: String,
|
|
pub reason: Option<String>,
|
|
pub cwd: AbsolutePathBuf,
|
|
pub changes: HashMap<PathBuf, FileChange>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct McpElicitationApprovalRequest {
|
|
pub thread_id: ThreadId,
|
|
pub thread_label: Option<String>,
|
|
pub server_name: String,
|
|
pub request_id: RequestId,
|
|
pub message: String,
|
|
}
|
|
|
|
impl ApprovalRequest {
|
|
pub(super) fn program_status_blocked_kind(
|
|
&self,
|
|
) -> crate::terminal_program_status::ProgramStatusKind {
|
|
use crate::terminal_program_status::ProgramStatusKind;
|
|
match self {
|
|
Self::Exec(_) | Self::Permissions(_) | Self::ApplyPatch(_) => {
|
|
ProgramStatusKind::Permission
|
|
}
|
|
Self::McpElicitation(_) => ProgramStatusKind::Question,
|
|
}
|
|
}
|
|
|
|
pub(super) fn thread_id(&self) -> ThreadId {
|
|
match self {
|
|
ApprovalRequest::Exec(request) => request.thread_id,
|
|
ApprovalRequest::Permissions(request) => request.thread_id,
|
|
ApprovalRequest::ApplyPatch(request) => request.thread_id,
|
|
ApprovalRequest::McpElicitation(request) => request.thread_id,
|
|
}
|
|
}
|
|
|
|
fn thread_label(&self) -> Option<&str> {
|
|
match self {
|
|
ApprovalRequest::Exec(request) => request.thread_label.as_deref(),
|
|
ApprovalRequest::Permissions(request) => request.thread_label.as_deref(),
|
|
ApprovalRequest::ApplyPatch(request) => request.thread_label.as_deref(),
|
|
ApprovalRequest::McpElicitation(request) => request.thread_label.as_deref(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn matches_resolved_request(&self, request: &ResolvedAppServerRequest) -> bool {
|
|
match (self, request) {
|
|
(
|
|
ApprovalRequest::Exec(request),
|
|
ResolvedAppServerRequest::ExecApproval { thread_id, id },
|
|
) => request.thread_id.to_string() == *thread_id && request.id == *id,
|
|
(
|
|
ApprovalRequest::Permissions(request),
|
|
ResolvedAppServerRequest::PermissionsApproval { thread_id, id },
|
|
) => request.thread_id.to_string() == *thread_id && request.call_id == *id,
|
|
(
|
|
ApprovalRequest::ApplyPatch(request),
|
|
ResolvedAppServerRequest::FileChangeApproval { thread_id, id },
|
|
) => request.thread_id.to_string() == *thread_id && request.id == *id,
|
|
(
|
|
ApprovalRequest::McpElicitation(request),
|
|
ResolvedAppServerRequest::McpElicitation {
|
|
server_name: resolved_server_name,
|
|
request_id: resolved_request_id,
|
|
},
|
|
) => {
|
|
request.server_name == *resolved_server_name
|
|
&& request.request_id == *resolved_request_id
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Modal overlay asking the user to approve or deny one or more requests.
|
|
pub(crate) struct ApprovalOverlay {
|
|
current_request: Option<ApprovalRequest>,
|
|
queue: Vec<ApprovalRequest>,
|
|
app_event_tx: AppEventSender,
|
|
list: ListSelectionView,
|
|
options: Vec<ApprovalOption>,
|
|
current_complete: bool,
|
|
done: bool,
|
|
features: Features,
|
|
approval_keymap: ApprovalKeymap,
|
|
list_keymap: ListKeymap,
|
|
}
|
|
|
|
impl ApprovalOverlay {
|
|
pub fn new(
|
|
request: ApprovalRequest,
|
|
app_event_tx: AppEventSender,
|
|
features: Features,
|
|
approval_keymap: ApprovalKeymap,
|
|
list_keymap: ListKeymap,
|
|
) -> Self {
|
|
let mut view = Self {
|
|
current_request: None,
|
|
queue: Vec::new(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()),
|
|
options: Vec::new(),
|
|
current_complete: false,
|
|
done: false,
|
|
features,
|
|
approval_keymap,
|
|
list_keymap,
|
|
};
|
|
view.set_current(request);
|
|
view
|
|
}
|
|
|
|
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
|
|
self.queue.push(req);
|
|
}
|
|
|
|
fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
|
let queue_len = self.queue.len();
|
|
self.queue
|
|
.retain(|queued_request| !queued_request.matches_resolved_request(request));
|
|
if self
|
|
.current_request
|
|
.as_ref()
|
|
.is_some_and(|current_request| current_request.matches_resolved_request(request))
|
|
{
|
|
self.current_complete = true;
|
|
self.advance_queue();
|
|
return true;
|
|
}
|
|
|
|
self.queue.len() != queue_len
|
|
}
|
|
|
|
fn set_current(&mut self, request: ApprovalRequest) {
|
|
self.current_complete = false;
|
|
let header = build_header(&request);
|
|
let (options, params) = Self::build_options(
|
|
&request,
|
|
header,
|
|
&self.features,
|
|
&self.approval_keymap,
|
|
&self.list_keymap,
|
|
);
|
|
self.current_request = Some(request);
|
|
self.options = options;
|
|
self.list =
|
|
ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone());
|
|
}
|
|
|
|
fn build_options(
|
|
request: &ApprovalRequest,
|
|
header: Box<dyn Renderable>,
|
|
_features: &Features,
|
|
approval_keymap: &ApprovalKeymap,
|
|
list_keymap: &ListKeymap,
|
|
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
|
let (options, title) = match request {
|
|
ApprovalRequest::Exec(request) => {
|
|
let title = if request.kind == CommandExecutionApprovalKind::WriteStdin {
|
|
request.command.get(2).map_or_else(
|
|
|| "Would you like to send input to the existing terminal?".to_string(),
|
|
|process_id| {
|
|
format!("Would you like to send input to terminal {process_id}?")
|
|
},
|
|
)
|
|
} else {
|
|
request.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 network access to \"{}\"?",
|
|
network_approval_context.host
|
|
)
|
|
},
|
|
)
|
|
};
|
|
(
|
|
exec_options(
|
|
&request.available_decisions,
|
|
request.network_approval_context.as_ref(),
|
|
request.additional_permissions.as_ref(),
|
|
approval_keymap,
|
|
),
|
|
title,
|
|
)
|
|
}
|
|
ApprovalRequest::Permissions(_) => (
|
|
permissions_options(approval_keymap),
|
|
"Would you like to grant these permissions?".to_string(),
|
|
),
|
|
ApprovalRequest::ApplyPatch(_) => (
|
|
patch_options(approval_keymap),
|
|
"Would you like to make the following edits?".to_string(),
|
|
),
|
|
ApprovalRequest::McpElicitation(request) => (
|
|
elicitation_options(approval_keymap),
|
|
format!("{} needs your approval.", request.server_name),
|
|
),
|
|
};
|
|
|
|
let header = Box::new(ColumnRenderable::with([
|
|
Paragraph::new(title.bold())
|
|
.wrap(Wrap { trim: false })
|
|
.into(),
|
|
Line::from("").into(),
|
|
header,
|
|
]));
|
|
|
|
let items = options
|
|
.iter()
|
|
.map(|opt| SelectionItem {
|
|
name: opt.label.clone(),
|
|
display_shortcut: approval_keymap.hint_for_bindings(&opt.shortcuts),
|
|
dismiss_on_select: false,
|
|
..Default::default()
|
|
})
|
|
.collect();
|
|
|
|
let params = SelectionViewParams {
|
|
footer_hint: Some(approval_footer_hint(request, approval_keymap, list_keymap)),
|
|
items,
|
|
header,
|
|
header_view_all_hint: approval_keymap
|
|
.primary_hint("open_fullscreen", &approval_keymap.open_fullscreen),
|
|
..SelectionViewParams::picker()
|
|
};
|
|
|
|
(options, params)
|
|
}
|
|
|
|
fn apply_selection(&mut self, actual_idx: usize) {
|
|
if self.current_complete {
|
|
return;
|
|
}
|
|
let Some(option) = self.options.get(actual_idx) else {
|
|
return;
|
|
};
|
|
if let Some(request) = self.current_request.as_ref() {
|
|
match (request, &option.decision) {
|
|
(ApprovalRequest::Exec(request), ApprovalDecision::Command(decision)) => {
|
|
self.handle_exec_decision(&request.id, &request.command, decision.clone());
|
|
}
|
|
(
|
|
ApprovalRequest::Permissions(request),
|
|
ApprovalDecision::Permissions(decision),
|
|
) => {
|
|
self.handle_permissions_decision(
|
|
&request.call_id,
|
|
&request.permissions,
|
|
*decision,
|
|
);
|
|
}
|
|
(ApprovalRequest::ApplyPatch(request), ApprovalDecision::FileChange(decision)) => {
|
|
self.handle_patch_decision(&request.id, decision.clone());
|
|
}
|
|
(
|
|
ApprovalRequest::McpElicitation(request),
|
|
ApprovalDecision::McpElicitation(decision),
|
|
) => {
|
|
self.handle_elicitation_decision(
|
|
&request.server_name,
|
|
&request.request_id,
|
|
*decision,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
self.current_complete = true;
|
|
self.advance_queue();
|
|
}
|
|
|
|
fn handle_exec_decision(
|
|
&self,
|
|
id: &str,
|
|
command: &[String],
|
|
decision: CommandExecutionApprovalDecision,
|
|
) {
|
|
let Some(request) = self.current_request.as_ref() else {
|
|
return;
|
|
};
|
|
if request.thread_label().is_none() {
|
|
let network_approval_context = match request {
|
|
ApprovalRequest::Exec(request) => request.network_approval_context.as_ref(),
|
|
ApprovalRequest::Permissions(_)
|
|
| ApprovalRequest::ApplyPatch(_)
|
|
| ApprovalRequest::McpElicitation(_) => None,
|
|
};
|
|
let subject = if let Some(network_approval_context) = network_approval_context {
|
|
history_cell::ApprovalDecisionSubject::NetworkAccess {
|
|
target: network_approval_target(network_approval_context, command),
|
|
}
|
|
} else if let Some(target) = network_approval_command_target(command) {
|
|
history_cell::ApprovalDecisionSubject::NetworkAccess {
|
|
target: target.to_string(),
|
|
}
|
|
} else {
|
|
history_cell::ApprovalDecisionSubject::Command(command.to_vec())
|
|
};
|
|
let cell = history_cell::new_approval_decision_cell(
|
|
subject,
|
|
command_decision_to_review_decision(&decision),
|
|
history_cell::ApprovalDecisionActor::User,
|
|
);
|
|
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
|
}
|
|
let thread_id = request.thread_id();
|
|
self.app_event_tx
|
|
.exec_approval(thread_id, id.to_string(), decision);
|
|
}
|
|
|
|
fn handle_permissions_decision(
|
|
&self,
|
|
call_id: &str,
|
|
permissions: &RequestPermissionProfile,
|
|
decision: PermissionsDecision,
|
|
) {
|
|
let Some(request) = self.current_request.as_ref() else {
|
|
return;
|
|
};
|
|
let granted_permissions = match decision {
|
|
PermissionsDecision::GrantForTurn
|
|
| PermissionsDecision::GrantForTurnWithStrictAutoReview
|
|
| PermissionsDecision::GrantForSession => permissions.clone(),
|
|
PermissionsDecision::Deny => Default::default(),
|
|
};
|
|
let scope = if matches!(decision, PermissionsDecision::GrantForSession) {
|
|
PermissionGrantScope::Session
|
|
} else {
|
|
PermissionGrantScope::Turn
|
|
};
|
|
let strict_auto_review = matches!(
|
|
decision,
|
|
PermissionsDecision::GrantForTurnWithStrictAutoReview
|
|
);
|
|
if request.thread_label().is_none() {
|
|
let message = if granted_permissions.is_empty() {
|
|
"You did not grant additional permissions"
|
|
} else if strict_auto_review {
|
|
"You granted additional permissions with strict auto review"
|
|
} else if matches!(scope, PermissionGrantScope::Session) {
|
|
"You granted additional permissions for this session"
|
|
} else {
|
|
"You granted additional permissions"
|
|
};
|
|
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
crate::history_cell::PlainHistoryCell::new(vec![message.into()]),
|
|
)));
|
|
}
|
|
let thread_id = request.thread_id();
|
|
self.app_event_tx.request_permissions_response(
|
|
thread_id,
|
|
call_id.to_string(),
|
|
codex_protocol::request_permissions::RequestPermissionsResponse {
|
|
permissions: granted_permissions,
|
|
scope,
|
|
strict_auto_review,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn handle_patch_decision(&self, id: &str, decision: FileChangeApprovalDecision) {
|
|
let Some(thread_id) = self
|
|
.current_request
|
|
.as_ref()
|
|
.map(ApprovalRequest::thread_id)
|
|
else {
|
|
return;
|
|
};
|
|
self.app_event_tx
|
|
.patch_approval(thread_id, id.to_string(), decision);
|
|
}
|
|
|
|
fn handle_elicitation_decision(
|
|
&self,
|
|
server_name: &str,
|
|
request_id: &RequestId,
|
|
decision: McpServerElicitationAction,
|
|
) {
|
|
let Some(thread_id) = self
|
|
.current_request
|
|
.as_ref()
|
|
.map(ApprovalRequest::thread_id)
|
|
else {
|
|
return;
|
|
};
|
|
self.app_event_tx.resolve_elicitation(
|
|
thread_id,
|
|
server_name.to_string(),
|
|
request_id.clone(),
|
|
decision,
|
|
/*content*/ None,
|
|
/*meta*/ None,
|
|
);
|
|
}
|
|
|
|
fn advance_queue(&mut self) {
|
|
if let Some(next) = self.queue.pop() {
|
|
self.set_current(next);
|
|
} else {
|
|
self.done = true;
|
|
}
|
|
}
|
|
|
|
fn cancel_current_request(&mut self) {
|
|
if self.done {
|
|
return;
|
|
}
|
|
if !self.current_complete
|
|
&& let Some(request) = self.current_request.as_ref()
|
|
{
|
|
match request {
|
|
ApprovalRequest::Exec(request) => {
|
|
self.handle_exec_decision(
|
|
&request.id,
|
|
&request.command,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
);
|
|
}
|
|
ApprovalRequest::Permissions(request) => {
|
|
self.handle_permissions_decision(
|
|
&request.call_id,
|
|
&request.permissions,
|
|
PermissionsDecision::Deny,
|
|
);
|
|
}
|
|
ApprovalRequest::ApplyPatch(request) => {
|
|
self.handle_patch_decision(&request.id, FileChangeApprovalDecision::Cancel);
|
|
}
|
|
ApprovalRequest::McpElicitation(request) => {
|
|
self.handle_elicitation_decision(
|
|
&request.server_name,
|
|
&request.request_id,
|
|
McpServerElicitationAction::Cancel,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
self.queue.clear();
|
|
self.done = true;
|
|
}
|
|
|
|
/// Apply approval-specific shortcuts before delegating to list navigation.
|
|
///
|
|
/// `open_fullscreen` is handled here because it is orthogonal to list item
|
|
/// selection and should work regardless of current highlighted row.
|
|
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
|
if key_event.kind == KeyEventKind::Press
|
|
&& self.approval_keymap.open_fullscreen.is_pressed(*key_event)
|
|
&& let Some(request) = self.current_request.as_ref()
|
|
{
|
|
self.app_event_tx
|
|
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
|
return true;
|
|
}
|
|
|
|
if key_event.kind == KeyEventKind::Press
|
|
&& self.approval_keymap.open_thread.is_pressed(*key_event)
|
|
&& let Some(request) = self.current_request.as_ref()
|
|
&& request.thread_label().is_some()
|
|
{
|
|
self.app_event_tx
|
|
.send(AppEvent::SelectAgentThread(request.thread_id()));
|
|
return true;
|
|
}
|
|
|
|
if self.list_keymap.cancel.is_pressed(*key_event) {
|
|
self.cancel_current_request();
|
|
return true;
|
|
}
|
|
|
|
if let Some(idx) = self
|
|
.options
|
|
.iter()
|
|
.position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event)))
|
|
{
|
|
self.apply_selection(idx);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BottomPaneView for ApprovalOverlay {
|
|
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
|
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval)
|
|
.with(crate::keymap::KeymapContext::List)
|
|
}
|
|
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
if self.try_handle_shortcut(&key_event) {
|
|
return;
|
|
}
|
|
self.list.handle_key_event(key_event);
|
|
if let Some(idx) = self.list.take_last_selected_index() {
|
|
self.apply_selection(idx);
|
|
}
|
|
}
|
|
|
|
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
self.cancel_current_request();
|
|
CancellationEvent::Handled
|
|
}
|
|
|
|
fn is_complete(&self) -> bool {
|
|
self.done
|
|
}
|
|
|
|
fn current_patch_approval(&self) -> Option<&ApplyPatchApprovalRequest> {
|
|
let ApprovalRequest::ApplyPatch(patch) = self.current_request.as_ref()? else {
|
|
return None;
|
|
};
|
|
Some(patch)
|
|
}
|
|
|
|
fn try_consume_approval_request(
|
|
&mut self,
|
|
request: ApprovalRequest,
|
|
) -> Option<ApprovalRequest> {
|
|
self.enqueue_request(request);
|
|
None
|
|
}
|
|
|
|
fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
|
self.dismiss_resolved_request(request)
|
|
}
|
|
|
|
fn terminal_title_requires_action(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn program_status_blocked_kind(
|
|
&self,
|
|
) -> Option<crate::terminal_program_status::ProgramStatusKind> {
|
|
self.current_request
|
|
.as_ref()
|
|
.map(ApprovalRequest::program_status_blocked_kind)
|
|
}
|
|
}
|
|
|
|
impl Renderable for ApprovalOverlay {
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.list.desired_height(width)
|
|
}
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
self.list.render(area, buf);
|
|
}
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
self.list.cursor_pos(area)
|
|
}
|
|
}
|
|
|
|
fn approval_footer_hint(
|
|
request: &ApprovalRequest,
|
|
approval_keymap: &ApprovalKeymap,
|
|
list_keymap: &ListKeymap,
|
|
) -> Line<'static> {
|
|
let mut spans = accept_cancel_hint_line(
|
|
list_keymap.primary_hint(ListAction::Accept),
|
|
"to confirm",
|
|
list_keymap.primary_hint(ListAction::Cancel),
|
|
"to cancel",
|
|
)
|
|
.spans;
|
|
if request.thread_label().is_some()
|
|
&& let Some(open_thread) =
|
|
approval_keymap.primary_hint("open_thread", &approval_keymap.open_thread)
|
|
{
|
|
if !spans.is_empty() {
|
|
spans.push(" or ".into());
|
|
} else {
|
|
spans.push("Press ".into());
|
|
}
|
|
spans.extend([open_thread.into(), " to open thread".into()]);
|
|
}
|
|
Line::from(spans)
|
|
}
|
|
|
|
fn network_approval_target(
|
|
network_approval_context: &NetworkApprovalContext,
|
|
command: &[String],
|
|
) -> String {
|
|
if let Some(target) = network_approval_command_target(command) {
|
|
return target.to_string();
|
|
}
|
|
|
|
let scheme = match network_approval_context.protocol {
|
|
NetworkApprovalProtocol::Http => "http",
|
|
NetworkApprovalProtocol::Https => "https",
|
|
NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp",
|
|
NetworkApprovalProtocol::Socks5Udp => "socks5-udp",
|
|
};
|
|
format!("{scheme}://{}", network_approval_context.host)
|
|
}
|
|
|
|
fn network_approval_command_target(command: &[String]) -> Option<&str> {
|
|
match command {
|
|
[program, target] if program == "network-access" && !target.is_empty() => {
|
|
Some(target.as_str())
|
|
}
|
|
[command] => command
|
|
.strip_prefix("network-access ")
|
|
.filter(|target| !target.is_empty()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
|
|
match request {
|
|
ApprovalRequest::Exec(request) => {
|
|
let mut header: Vec<Line<'static>> = Vec::new();
|
|
if let Some(thread_label) = &request.thread_label {
|
|
header.push(Line::from(vec![
|
|
"Thread: ".into(),
|
|
thread_label.clone().bold(),
|
|
]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(environment_id) = &request.environment_id {
|
|
header.push(Line::from(vec![
|
|
"Environment: ".into(),
|
|
environment_id.clone().bold(),
|
|
]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(reason) = &request.reason {
|
|
header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(additional_permissions) = &request.additional_permissions
|
|
&& let Some(rule_line) = format_additional_permissions_rule(additional_permissions)
|
|
{
|
|
header.push(Line::from(vec![
|
|
"Permission rule: ".into(),
|
|
rule_line.fg(crate::style::accent_color()),
|
|
]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if request.kind == CommandExecutionApprovalKind::WriteStdin {
|
|
let input = request.command.last().map_or("", String::as_str);
|
|
header.push(vec!["Input: ".into(), format!("{input:?}").into()].into());
|
|
} else {
|
|
let full_cmd = strip_bash_lc_and_escape(&request.command);
|
|
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
|
if let Some(first) = full_cmd_lines.first_mut() {
|
|
first.spans.insert(0, Span::from("$ "));
|
|
}
|
|
if request.network_approval_context.is_none() {
|
|
header.extend(full_cmd_lines);
|
|
}
|
|
}
|
|
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header))
|
|
}
|
|
ApprovalRequest::Permissions(request) => {
|
|
let mut header: Vec<Line<'static>> = Vec::new();
|
|
if let Some(thread_label) = &request.thread_label {
|
|
header.push(Line::from(vec![
|
|
"Thread: ".into(),
|
|
thread_label.clone().bold(),
|
|
]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(environment_id) = &request.environment_id {
|
|
header.push(Line::from(vec![
|
|
"Environment: ".into(),
|
|
environment_id.clone().bold(),
|
|
]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(reason) = &request.reason {
|
|
header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()]));
|
|
header.push(Line::from(""));
|
|
}
|
|
if let Some(rule_line) = format_requested_permissions_rule(&request.permissions) {
|
|
header.push(Line::from(vec![
|
|
"Permission rule: ".into(),
|
|
rule_line.fg(crate::style::accent_color()),
|
|
]));
|
|
}
|
|
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(header))
|
|
}
|
|
ApprovalRequest::ApplyPatch(request) => super::apply_patch_header::build_header(request),
|
|
ApprovalRequest::McpElicitation(request) => {
|
|
let mut lines = Vec::new();
|
|
if let Some(thread_label) = &request.thread_label {
|
|
lines.push(Line::from(vec![
|
|
"Thread: ".into(),
|
|
thread_label.clone().bold(),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
}
|
|
lines.extend([
|
|
Line::from(vec!["Server: ".into(), request.server_name.clone().bold()]),
|
|
Line::from(""),
|
|
Line::from(request.message.clone()),
|
|
]);
|
|
Box::new(crate::terminal_hyperlinks::HyperlinkText::new(lines))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum ApprovalDecision {
|
|
Command(CommandExecutionApprovalDecision),
|
|
FileChange(FileChangeApprovalDecision),
|
|
Permissions(PermissionsDecision),
|
|
McpElicitation(McpServerElicitationAction),
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PermissionsDecision {
|
|
GrantForTurn,
|
|
GrantForTurnWithStrictAutoReview,
|
|
GrantForSession,
|
|
Deny,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ApprovalOption {
|
|
label: String,
|
|
decision: ApprovalDecision,
|
|
shortcuts: Vec<KeyBinding>,
|
|
}
|
|
|
|
fn command_decision_to_review_decision(
|
|
decision: &CommandExecutionApprovalDecision,
|
|
) -> ReviewDecision {
|
|
match decision {
|
|
CommandExecutionApprovalDecision::Accept => ReviewDecision::Approved,
|
|
CommandExecutionApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
|
|
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
|
execpolicy_amendment,
|
|
} => ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment: execpolicy_amendment.clone().into_core(),
|
|
},
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment,
|
|
} => ReviewDecision::NetworkPolicyAmendment {
|
|
network_policy_amendment: network_policy_amendment.clone().into_core(),
|
|
},
|
|
CommandExecutionApprovalDecision::Decline => ReviewDecision::Denied,
|
|
CommandExecutionApprovalDecision::Cancel => ReviewDecision::Abort,
|
|
}
|
|
}
|
|
|
|
fn exec_options(
|
|
available_decisions: &[CommandExecutionApprovalDecision],
|
|
network_approval_context: Option<&NetworkApprovalContext>,
|
|
additional_permissions: Option<&AdditionalPermissionProfile>,
|
|
keymap: &ApprovalKeymap,
|
|
) -> Vec<ApprovalOption> {
|
|
available_decisions
|
|
.iter()
|
|
.filter_map(|decision| match decision {
|
|
CommandExecutionApprovalDecision::Accept => Some(ApprovalOption {
|
|
label: if network_approval_context.is_some() {
|
|
"Yes, just this once".to_string()
|
|
} else {
|
|
"Yes, proceed".to_string()
|
|
},
|
|
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Accept),
|
|
shortcuts: keymap.approve.clone(),
|
|
}),
|
|
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
|
execpolicy_amendment,
|
|
} => {
|
|
let rendered_prefix = strip_bash_lc_and_escape(&execpolicy_amendment.command);
|
|
if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') {
|
|
return None;
|
|
}
|
|
|
|
Some(ApprovalOption {
|
|
label: format!(
|
|
"Yes, and don't ask again for commands that start with `{rendered_prefix}`"
|
|
),
|
|
decision: ApprovalDecision::Command(
|
|
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
|
execpolicy_amendment: execpolicy_amendment.clone(),
|
|
},
|
|
),
|
|
shortcuts: keymap.approve_for_prefix.clone(),
|
|
})
|
|
}
|
|
CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption {
|
|
label: if network_approval_context.is_some() {
|
|
"Yes, and allow this host for this conversation".to_string()
|
|
} else if additional_permissions.is_some() {
|
|
"Yes, and allow these permissions for this session".to_string()
|
|
} else {
|
|
"Yes, and don't ask again for this command in this session".to_string()
|
|
},
|
|
decision: ApprovalDecision::Command(
|
|
CommandExecutionApprovalDecision::AcceptForSession,
|
|
),
|
|
shortcuts: keymap.approve_for_session.clone(),
|
|
}),
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment,
|
|
} => {
|
|
let (label, shortcuts) = match network_policy_amendment.action {
|
|
NetworkPolicyRuleAction::Allow => (
|
|
"Yes, and allow this host in the future".to_string(),
|
|
keymap.approve_for_prefix.clone(),
|
|
),
|
|
NetworkPolicyRuleAction::Deny => (
|
|
"No, and block this host in the future".to_string(),
|
|
keymap.deny.clone(),
|
|
),
|
|
};
|
|
Some(ApprovalOption {
|
|
label,
|
|
decision: ApprovalDecision::Command(
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: network_policy_amendment.clone(),
|
|
},
|
|
),
|
|
shortcuts,
|
|
})
|
|
}
|
|
CommandExecutionApprovalDecision::Decline => Some(ApprovalOption {
|
|
label: "No, continue without running it".to_string(),
|
|
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Decline),
|
|
shortcuts: keymap.deny.clone(),
|
|
}),
|
|
CommandExecutionApprovalDecision::Cancel => Some(ApprovalOption {
|
|
label: "No, and tell Codex what to do differently".to_string(),
|
|
decision: ApprovalDecision::Command(CommandExecutionApprovalDecision::Cancel),
|
|
shortcuts: keymap.decline.clone(),
|
|
}),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn format_additional_permissions_rule(
|
|
additional_permissions: &AdditionalPermissionProfile,
|
|
) -> Option<String> {
|
|
let mut parts = Vec::new();
|
|
if additional_permissions
|
|
.network
|
|
.as_ref()
|
|
.and_then(|network| network.enabled)
|
|
.unwrap_or(false)
|
|
{
|
|
parts.push("network".to_string());
|
|
}
|
|
if let Some(file_system) = additional_permissions.file_system.as_ref() {
|
|
let reads = format_file_system_entry_paths(
|
|
file_system
|
|
.entries
|
|
.iter()
|
|
.flatten()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::Read),
|
|
);
|
|
if !reads.is_empty() {
|
|
parts.push(format!("read {reads}"));
|
|
}
|
|
let writes = format_file_system_entry_paths(
|
|
file_system
|
|
.entries
|
|
.iter()
|
|
.flatten()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::Write),
|
|
);
|
|
if !writes.is_empty() {
|
|
parts.push(format!("write {writes}"));
|
|
}
|
|
let denied_reads = format_file_system_entry_paths(
|
|
file_system
|
|
.entries
|
|
.iter()
|
|
.flatten()
|
|
.filter(|entry| entry.access == FileSystemAccessMode::Deny),
|
|
);
|
|
if !denied_reads.is_empty() {
|
|
parts.push(format!("deny read {denied_reads}"));
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(parts.join("; "))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn format_requested_permissions_rule(
|
|
permissions: &RequestPermissionProfile,
|
|
) -> Option<String> {
|
|
let permissions =
|
|
crate::app_server_approval_conversions::granted_permission_profile_from_request(
|
|
permissions.clone(),
|
|
);
|
|
format_additional_permissions_rule(&AdditionalPermissionProfile {
|
|
network: permissions.network,
|
|
file_system: permissions.file_system,
|
|
})
|
|
}
|
|
|
|
fn format_file_system_entry_paths<'a>(
|
|
entries: impl Iterator<Item = &'a FileSystemSandboxEntry>,
|
|
) -> String {
|
|
entries
|
|
.map(|entry| match &entry.path {
|
|
FileSystemPath::Path { path } => format!("`{path}`"),
|
|
FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"),
|
|
FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn special_path_label(value: &FileSystemSpecialPath) -> String {
|
|
match value {
|
|
FileSystemSpecialPath::Root => ":root".to_string(),
|
|
FileSystemSpecialPath::Minimal => ":minimal".to_string(),
|
|
FileSystemSpecialPath::ProjectRoots { subpath } => path_label(":workspace_roots", subpath),
|
|
FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(),
|
|
FileSystemSpecialPath::SlashTmp => "/tmp".to_string(),
|
|
FileSystemSpecialPath::Unknown { path, subpath } => path_label(path, subpath),
|
|
}
|
|
}
|
|
|
|
fn path_label(base: &str, subpath: &Option<LegacyAppPathString>) -> String {
|
|
match subpath {
|
|
Some(subpath) => format!("{base}/{subpath}"),
|
|
None => base.to_string(),
|
|
}
|
|
}
|
|
|
|
fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
|
vec![
|
|
ApprovalOption {
|
|
label: "Yes, proceed".to_string(),
|
|
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept),
|
|
shortcuts: keymap.approve.clone(),
|
|
},
|
|
ApprovalOption {
|
|
label: "Yes, and don't ask again for these files".to_string(),
|
|
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession),
|
|
shortcuts: keymap.approve_for_session.clone(),
|
|
},
|
|
ApprovalOption {
|
|
label: "No, and tell Codex what to do differently".to_string(),
|
|
decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel),
|
|
shortcuts: keymap.decline.clone(),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn permissions_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
|
let deny_shortcuts = keymap
|
|
.deny
|
|
.iter()
|
|
.copied()
|
|
.filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE))
|
|
.collect();
|
|
|
|
vec![
|
|
ApprovalOption {
|
|
label: "Yes, grant these permissions for this turn".to_string(),
|
|
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn),
|
|
shortcuts: keymap.approve.clone(),
|
|
},
|
|
ApprovalOption {
|
|
label: "Yes, grant for this turn with strict auto review".to_string(),
|
|
decision: ApprovalDecision::Permissions(
|
|
PermissionsDecision::GrantForTurnWithStrictAutoReview,
|
|
),
|
|
shortcuts: vec![key_hint::plain(KeyCode::Char('r'))],
|
|
},
|
|
ApprovalOption {
|
|
label: "Yes, grant these permissions for this session".to_string(),
|
|
decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession),
|
|
shortcuts: keymap.approve_for_session.clone(),
|
|
},
|
|
ApprovalOption {
|
|
label: "No, continue without permissions".to_string(),
|
|
decision: ApprovalDecision::Permissions(PermissionsDecision::Deny),
|
|
shortcuts: deny_shortcuts,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Build MCP elicitation options with stable cancellation semantics.
|
|
///
|
|
/// `Esc` is always treated as cancel for elicitation prompts, even if users
|
|
/// customize `decline`/`cancel` bindings. We keep this as a hard contract so
|
|
/// dismissal remains a safe abort path and never silently maps to "continue
|
|
/// without requested info." Any decline/cancel overlap is removed from the
|
|
/// decline option in elicitation mode to preserve this invariant.
|
|
fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
|
|
let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)];
|
|
for shortcut in &keymap.cancel {
|
|
if !cancel_shortcuts.contains(shortcut) {
|
|
cancel_shortcuts.push(*shortcut);
|
|
}
|
|
}
|
|
|
|
let decline_shortcuts: Vec<KeyBinding> = keymap
|
|
.decline
|
|
.iter()
|
|
.copied()
|
|
.filter(|shortcut| !cancel_shortcuts.contains(shortcut))
|
|
.collect();
|
|
|
|
vec![
|
|
ApprovalOption {
|
|
label: "Yes, provide the requested info".to_string(),
|
|
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Accept),
|
|
shortcuts: keymap.approve.clone(),
|
|
},
|
|
ApprovalOption {
|
|
label: "No, but continue without it".to_string(),
|
|
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Decline),
|
|
shortcuts: decline_shortcuts,
|
|
},
|
|
ApprovalOption {
|
|
label: "Cancel this request".to_string(),
|
|
decision: ApprovalDecision::McpElicitation(McpServerElicitationAction::Cancel),
|
|
shortcuts: cancel_shortcuts,
|
|
},
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_event::AppEvent;
|
|
use crate::keymap::RuntimeKeymap;
|
|
use codex_app_server_protocol::AdditionalFileSystemPermissions;
|
|
use codex_app_server_protocol::AdditionalNetworkPermissions;
|
|
use codex_app_server_protocol::ExecPolicyAmendment;
|
|
use codex_app_server_protocol::NetworkApprovalProtocol;
|
|
use codex_app_server_protocol::NetworkPolicyAmendment;
|
|
use codex_config::types::KeybindingSpec;
|
|
use codex_config::types::KeybindingsSpec;
|
|
use codex_config::types::TuiKeymap;
|
|
use codex_protocol::models::FileSystemPermissions;
|
|
use codex_protocol::models::NetworkPermissions;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::test_support::PathExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use crossterm::event::KeyModifiers;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
|
|
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
|
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
|
}
|
|
|
|
fn render_overlay_lines(view: &ApprovalOverlay, width: u16) -> String {
|
|
let height = view.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
view.render(Rect::new(0, 0, width, height), &mut buf);
|
|
(0..buf.area.height)
|
|
.map(|row| {
|
|
(0..buf.area.width)
|
|
.map(|col| buf[(col, row)].symbol().to_string())
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_string()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn render_history_cell_lines(
|
|
cell: &dyn crate::history_cell::HistoryCell,
|
|
width: u16,
|
|
) -> Vec<String> {
|
|
cell.display_lines(width)
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn normalize_snapshot_paths(rendered: String) -> String {
|
|
[
|
|
(absolute_path("/tmp/readme.txt"), "/tmp/readme.txt"),
|
|
(absolute_path("/tmp/out.txt"), "/tmp/out.txt"),
|
|
]
|
|
.into_iter()
|
|
.fold(rendered, |rendered, (path, normalized)| {
|
|
rendered.replace(&path.display().to_string(), normalized)
|
|
})
|
|
}
|
|
|
|
fn make_overlay(
|
|
request: ApprovalRequest,
|
|
app_event_tx: AppEventSender,
|
|
features: Features,
|
|
) -> ApprovalOverlay {
|
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
make_overlay_with_keymap(
|
|
request,
|
|
app_event_tx,
|
|
features,
|
|
keymap.approval,
|
|
keymap.list,
|
|
)
|
|
}
|
|
|
|
fn make_overlay_with_keymap(
|
|
request: ApprovalRequest,
|
|
app_event_tx: AppEventSender,
|
|
features: Features,
|
|
approval_keymap: ApprovalKeymap,
|
|
list_keymap: ListKeymap,
|
|
) -> ApprovalOverlay {
|
|
ApprovalOverlay::new(
|
|
request,
|
|
app_event_tx,
|
|
features,
|
|
approval_keymap,
|
|
list_keymap,
|
|
)
|
|
}
|
|
|
|
fn make_exec_request() -> ApprovalRequest {
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
reason: Some("reason".to_string()),
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
})
|
|
}
|
|
|
|
fn make_permissions_request() -> ApprovalRequest {
|
|
ApprovalRequest::Permissions(PermissionsApprovalRequest {
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
call_id: "test".to_string(),
|
|
environment_id: None,
|
|
reason: Some("need workspace access".to_string()),
|
|
permissions: RequestPermissionProfile {
|
|
network: Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![absolute_path("/tmp/readme.txt")]),
|
|
Some(vec![absolute_path("/tmp/out.txt")]),
|
|
)),
|
|
},
|
|
})
|
|
}
|
|
|
|
fn make_elicitation_request() -> ApprovalRequest {
|
|
ApprovalRequest::McpElicitation(McpElicitationApprovalRequest {
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
server_name: "test-server".to_string(),
|
|
request_id: RequestId::String("request-1".to_string()),
|
|
message: "Need more information".to_string(),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn approval_shortcuts_display_chords_in_configured_order() {
|
|
let chord = crate::key_hint::ShortcutHint::Chord {
|
|
prefix: key_hint::ctrl(KeyCode::Char('x')),
|
|
completion: key_hint::plain(KeyCode::Char('y')),
|
|
};
|
|
let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y')));
|
|
let binding = |spec: &str| KeybindingSpec(spec.to_string());
|
|
for (specs, expected_shortcut) in [
|
|
(KeybindingsSpec::One(binding("ctrl-x y")), chord),
|
|
(
|
|
KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]),
|
|
single,
|
|
),
|
|
(
|
|
KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]),
|
|
chord,
|
|
),
|
|
] {
|
|
let mut config = TuiKeymap::default();
|
|
config.approval.approve = Some(specs);
|
|
let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap");
|
|
let request = make_exec_request();
|
|
let (_, params) = ApprovalOverlay::build_options(
|
|
&request,
|
|
build_header(&request),
|
|
&Features::with_defaults(),
|
|
&keymap.approval,
|
|
&keymap.list,
|
|
);
|
|
let approval = params
|
|
.items
|
|
.iter()
|
|
.find(|item| item.name == "Yes, proceed")
|
|
.expect("approval selection");
|
|
|
|
assert_eq!(approval.display_shortcut, Some(expected_shortcut));
|
|
if matches!(
|
|
expected_shortcut,
|
|
crate::key_hint::ShortcutHint::Chord { .. }
|
|
) {
|
|
let dispatch_token = keymap.approval.approve.last().expect("chord token");
|
|
assert_eq!(
|
|
keymap
|
|
.approval
|
|
.hint_for_bindings(std::slice::from_ref(dispatch_token)),
|
|
Some(expected_shortcut)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_c_aborts_and_clears_queue() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
|
view.enqueue_request(make_exec_request());
|
|
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
|
|
assert!(view.queue.is_empty());
|
|
assert!(view.is_complete());
|
|
}
|
|
|
|
#[test]
|
|
fn configured_list_cancel_aborts_exec_approval() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))];
|
|
let mut view = make_overlay_with_keymap(
|
|
make_exec_request(),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
|
|
|
assert!(view.is_complete());
|
|
let mut decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { decision: d, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
decision = Some(d);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Cancel));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_list_cancel_cancels_mcp_elicitation() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.list.cancel = vec![key_hint::plain(KeyCode::Char('q'))];
|
|
let mut view = make_overlay_with_keymap(
|
|
make_elicitation_request(),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
|
|
|
assert!(view.is_complete());
|
|
let mut decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ResolveElicitation { decision: d, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
decision = Some(d);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
|
|
}
|
|
|
|
#[test]
|
|
fn shortcut_triggers_selection() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
|
assert!(!view.is_complete());
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
// We expect at least one thread-scoped approval op message in the queue.
|
|
let mut saw_op = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if matches!(ev, AppEvent::SubmitThreadOp { .. }) {
|
|
saw_op = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_op, "expected approval decision to emit an op");
|
|
}
|
|
|
|
#[test]
|
|
fn deny_shortcut_submits_denied_exec_decision() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Decline,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
|
|
|
let mut saw_denied = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { decision, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert_eq!(decision, CommandExecutionApprovalDecision::Decline);
|
|
saw_denied = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_denied, "expected deny shortcut to emit denied decision");
|
|
}
|
|
|
|
#[test]
|
|
fn approval_headers_preserve_wrapped_url_destinations() {
|
|
let url = "https://github.com/openai/codex/pull/12345?diff=split";
|
|
for mut request in [
|
|
make_exec_request(),
|
|
make_permissions_request(),
|
|
make_elicitation_request(),
|
|
ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "patch".into(),
|
|
reason: None,
|
|
cwd: absolute_path("/tmp"),
|
|
changes: HashMap::new(),
|
|
}),
|
|
] {
|
|
match &mut request {
|
|
ApprovalRequest::Exec(request) => request.reason = Some(url.into()),
|
|
ApprovalRequest::Permissions(request) => request.reason = Some(url.into()),
|
|
ApprovalRequest::McpElicitation(request) => request.message = url.into(),
|
|
ApprovalRequest::ApplyPatch(request) => request.reason = Some(url.into()),
|
|
}
|
|
let header = build_header(&request);
|
|
let area = Rect::new(0, 0, 32, header.desired_height(/*width*/ 32));
|
|
let mut buf = Buffer::empty(area);
|
|
header.render(area, &mut buf);
|
|
let linked = buf
|
|
.content
|
|
.iter()
|
|
.filter(|cell| cell.symbol().contains("\x1b]8;;"))
|
|
.map(|cell| {
|
|
assert!(cell.symbol().starts_with(&format!("\x1b]8;;{url}\x07")));
|
|
crate::terminal_hyperlinks::strip_osc8(cell.symbol())
|
|
})
|
|
.collect::<String>();
|
|
assert_eq!(linked, url);
|
|
if matches!(request, ApprovalRequest::Exec(_)) {
|
|
let visible = buf
|
|
.content
|
|
.chunks(32)
|
|
.map(|row| {
|
|
row.iter()
|
|
.map(|cell| crate::terminal_hyperlinks::strip_osc8(cell.symbol()))
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_owned()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
insta::assert_snapshot!("approval_header_wrapped_url", visible);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn network_deny_shortcut_submits_policy_deny_decision() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let amendment = NetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: NetworkPolicyRuleAction::Deny,
|
|
};
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["curl".to_string(), "https://example.com".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: amendment.clone(),
|
|
},
|
|
],
|
|
network_approval_context: Some(NetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
}),
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
|
|
|
let mut saw_deny = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { decision, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert_eq!(
|
|
decision,
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: amendment
|
|
}
|
|
);
|
|
saw_deny = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_deny,
|
|
"expected deny shortcut to emit network policy deny decision"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolved_request_dismisses_overlay_without_emitting_abort() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let request = make_exec_request();
|
|
let thread_id = request.thread_id();
|
|
let mut view = make_overlay(request, tx, Features::with_defaults());
|
|
|
|
assert!(
|
|
!view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
|
|
thread_id: ThreadId::new().to_string(),
|
|
id: "test".to_string(),
|
|
})
|
|
);
|
|
assert!(!view.is_complete());
|
|
assert!(
|
|
view.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval {
|
|
thread_id: thread_id.to_string(),
|
|
id: "test".to_string(),
|
|
})
|
|
);
|
|
assert!(
|
|
view.is_complete(),
|
|
"resolved request should close the overlay"
|
|
);
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"dismissing a stale request should not emit an approval op"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn o_opens_source_thread_for_cross_thread_approval() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let thread_id = ThreadId::new();
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id,
|
|
thread_label: Some("Robie [explorer]".to_string()),
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
|
|
|
|
let event = rx.try_recv().expect("expected select-agent-thread event");
|
|
assert_eq!(
|
|
matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id),
|
|
true
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_open_thread_shortcut_opens_source_thread() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let thread_id = ThreadId::new();
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))];
|
|
let mut view = make_overlay_with_keymap(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id,
|
|
thread_label: Some("Robie [explorer]".to_string()),
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(
|
|
KeyCode::Char('o'),
|
|
/*modifiers*/ KeyModifiers::NONE,
|
|
));
|
|
assert!(rx.try_recv().is_err());
|
|
|
|
view.handle_key_event(KeyEvent::new(
|
|
KeyCode::Char('x'),
|
|
/*modifiers*/ KeyModifiers::NONE,
|
|
));
|
|
let event = rx.try_recv().expect("expected select-agent-thread event");
|
|
assert!(matches!(event, AppEvent::SelectAgentThread(id) if id == thread_id));
|
|
}
|
|
|
|
#[test]
|
|
fn cross_thread_footer_hint_mentions_o_shortcut() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: Some("Robie [explorer]".to_string()),
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
|
|
assert_snapshot!(
|
|
"approval_overlay_cross_thread_prompt",
|
|
render_overlay_lines(&view, /*width*/ 80)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_prefix_option_emits_execpolicy_amendment() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["echo".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
|
execpolicy_amendment: ExecPolicyAmendment {
|
|
command: vec!["echo".to_string()],
|
|
},
|
|
},
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
|
let mut saw_op = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { decision, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert_eq!(
|
|
decision,
|
|
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
|
|
execpolicy_amendment: ExecPolicyAmendment {
|
|
command: vec!["echo".to_string()],
|
|
}
|
|
}
|
|
);
|
|
saw_op = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_op,
|
|
"expected approval decision to emit an op with command prefix"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_deny_forever_shortcut_is_not_bound() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
environment_id: None,
|
|
command: vec!["curl".to_string(), "https://example.com".to_string()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::AcceptForSession,
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: NetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
},
|
|
},
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: Some(NetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
}),
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
|
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"unexpected approval event emitted for hidden network deny shortcut"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn header_includes_command_snippet() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let command = vec!["echo".into(), "hello".into(), "world".into()];
|
|
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".into(),
|
|
environment_id: None,
|
|
command,
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
});
|
|
|
|
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)));
|
|
view.render(
|
|
Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)),
|
|
&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("echo hello world")),
|
|
"expected header to include command snippet, got {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[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 keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
let options = exec_options(
|
|
&[
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::AcceptForSession,
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: NetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
},
|
|
},
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
Some(&network_context),
|
|
/*additional_permissions*/ None,
|
|
&keymap.approval,
|
|
);
|
|
|
|
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 conversation".to_string(),
|
|
"Yes, and allow this host in the future".to_string(),
|
|
"No, and tell Codex what to do differently".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generic_exec_options_can_offer_allow_for_session() {
|
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
let options = exec_options(
|
|
&[
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::AcceptForSession,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
/*network_approval_context*/ None,
|
|
/*additional_permissions*/ None,
|
|
&keymap.approval,
|
|
);
|
|
|
|
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
|
assert_eq!(
|
|
labels,
|
|
vec![
|
|
"Yes, proceed".to_string(),
|
|
"Yes, and don't ask again for this command in this session".to_string(),
|
|
"No, and tell Codex what to do differently".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
|
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
let additional_permissions = AdditionalPermissionProfile {
|
|
network: None,
|
|
file_system: Some(
|
|
FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![absolute_path("/tmp/readme.txt")]),
|
|
Some(vec![absolute_path("/tmp/out.txt")]),
|
|
)
|
|
.into(),
|
|
),
|
|
};
|
|
let options = exec_options(
|
|
&[
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
/*network_approval_context*/ None,
|
|
Some(&additional_permissions),
|
|
&keymap.approval,
|
|
);
|
|
|
|
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
|
|
assert_eq!(
|
|
labels,
|
|
vec![
|
|
"Yes, proceed".to_string(),
|
|
"No, and tell Codex what to do differently".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_options_use_expected_labels() {
|
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
let labels: Vec<String> = permissions_options(&keymap.approval)
|
|
.into_iter()
|
|
.map(|option| option.label)
|
|
.collect();
|
|
assert_eq!(
|
|
labels,
|
|
vec![
|
|
"Yes, grant these permissions for this turn".to_string(),
|
|
"Yes, grant for this turn with strict auto review".to_string(),
|
|
"Yes, grant these permissions for this session".to_string(),
|
|
"No, continue without permissions".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_permissions_rule_shows_non_path_file_system_entries() {
|
|
let additional_permissions = AdditionalPermissionProfile {
|
|
network: None,
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: None,
|
|
write: None,
|
|
entries: Some(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
},
|
|
]),
|
|
glob_scan_max_depth: None,
|
|
}),
|
|
};
|
|
|
|
assert_eq!(
|
|
format_additional_permissions_rule(&additional_permissions),
|
|
Some("write `:root`; deny read glob `**/*.env`".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_permissions_rule_uses_workspace_roots_label() {
|
|
let additional_permissions = AdditionalPermissionProfile {
|
|
network: None,
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: None,
|
|
write: None,
|
|
entries: Some(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots {
|
|
subpath: Some(LegacyAppPathString::from_path(std::path::Path::new(
|
|
".git",
|
|
))),
|
|
},
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}]),
|
|
glob_scan_max_depth: None,
|
|
}),
|
|
};
|
|
|
|
assert_eq!(
|
|
format_additional_permissions_rule(&additional_permissions),
|
|
Some("read `:workspace_roots/.git`".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_session_shortcut_submits_session_scope() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
|
|
|
let mut saw_op = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::RequestPermissionsResponse { response, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert_eq!(response.scope, PermissionGrantScope::Session);
|
|
saw_op = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_op,
|
|
"expected permission approval decision to emit a session-scoped response"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_deny_shortcut_uses_deny_keymap() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.approval.deny = vec![key_hint::plain(KeyCode::Char('x'))];
|
|
keymap.approval.decline = Vec::new();
|
|
let mut view = make_overlay_with_keymap(
|
|
make_permissions_request(),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
|
|
|
let mut saw_op = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::RequestPermissionsResponse { response, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert!(response.permissions.is_empty());
|
|
assert_eq!(response.scope, PermissionGrantScope::Turn);
|
|
assert!(!response.strict_auto_review);
|
|
saw_op = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_op,
|
|
"expected permission deny shortcut to emit an empty permission response"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_strict_auto_review_shortcut_submits_turn_scope_with_strict_review() {
|
|
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
|
|
|
|
let mut saw_op = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::RequestPermissionsResponse { response, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
assert_eq!(response.scope, PermissionGrantScope::Turn);
|
|
assert!(response.strict_auto_review);
|
|
saw_op = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_op,
|
|
"expected permission approval decision to emit a strict auto review response"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_permissions_prompt_shows_permission_rule_line() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".into(),
|
|
environment_id: None,
|
|
command: vec!["cat".into(), "/tmp/readme.txt".into()],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: Some(AdditionalPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(
|
|
FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![absolute_path("/tmp/readme.txt")]),
|
|
Some(vec![absolute_path("/tmp/out.txt")]),
|
|
)
|
|
.into(),
|
|
),
|
|
}),
|
|
});
|
|
|
|
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
|
|
view.render(
|
|
Rect::new(0, 0, 100, view.desired_height(/*width*/ 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("Permission rule:")),
|
|
"expected permission-rule line, got {rendered:?}"
|
|
);
|
|
assert!(
|
|
rendered.iter().any(|line| line.contains("network;")),
|
|
"expected network permission text, got {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_permissions_prompt_snapshot() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".into(),
|
|
environment_id: None,
|
|
command: vec!["cat".into(), "/tmp/readme.txt".into()],
|
|
reason: Some("need filesystem access".into()),
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: Some(AdditionalPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(
|
|
FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![absolute_path("/tmp/readme.txt")]),
|
|
Some(vec![absolute_path("/tmp/out.txt")]),
|
|
)
|
|
.into(),
|
|
),
|
|
}),
|
|
});
|
|
|
|
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
|
assert_snapshot!(
|
|
"approval_overlay_additional_permissions_prompt",
|
|
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_prompt_snapshot() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let view = make_overlay(make_permissions_request(), tx, Features::with_defaults());
|
|
assert_snapshot!(
|
|
"approval_overlay_permissions_prompt",
|
|
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_patch_prompt_with_thread_label_omits_command_line() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("bug1.txt"),
|
|
FileChange::Add {
|
|
content: "one\ntwo\nthree\n".to_string(),
|
|
},
|
|
);
|
|
changes.insert(
|
|
PathBuf::from("old.txt"),
|
|
FileChange::Update {
|
|
unified_diff: String::new(),
|
|
move_path: Some(PathBuf::from("new.txt")),
|
|
},
|
|
);
|
|
#[cfg(windows)]
|
|
let (foreign_path, foreign_move_path) = ("/remote/old.txt", "/remote/new.txt");
|
|
#[cfg(not(windows))]
|
|
let (foreign_path, foreign_move_path) = (r"C:\workspace\old.txt", r"C:\workspace\new.txt");
|
|
changes.insert(
|
|
PathBuf::from(foreign_path),
|
|
FileChange::Update {
|
|
unified_diff: String::new(),
|
|
move_path: Some(PathBuf::from(foreign_move_path)),
|
|
},
|
|
);
|
|
let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
|
|
thread_id: ThreadId::new(),
|
|
thread_label: Some("Banach [worker]".to_string()),
|
|
id: "test".to_string(),
|
|
reason: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
changes,
|
|
});
|
|
let keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
let view = ApprovalOverlay::new(
|
|
request,
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
let rendered = render_overlay_lines(&view, /*width*/ 120);
|
|
assert!(rendered.contains("Thread: Banach [worker]"));
|
|
assert!(rendered.contains("Description: Apply proposed file edits"));
|
|
for path in ["/tmp/bug1.txt", "/tmp/new.txt", "/tmp/old.txt"] {
|
|
assert!(rendered.contains(&format!("Destination: {}", test_path_buf(path).display())));
|
|
}
|
|
for path in [foreign_path, foreign_move_path] {
|
|
assert!(rendered.contains(&format!("Destination: {path}")));
|
|
}
|
|
assert!(rendered.contains("o to open thread"));
|
|
assert!(!rendered.contains("$ apply_patch"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_patch_prompt_without_changes_shows_unavailable_destination() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".to_string(),
|
|
reason: None,
|
|
cwd: absolute_path("/tmp"),
|
|
changes: HashMap::new(),
|
|
});
|
|
let view = make_overlay(request, tx, Features::with_defaults());
|
|
assert_snapshot!(
|
|
"approval_overlay_patch_destination_unavailable",
|
|
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_exec_prompt_title_includes_host() {
|
|
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx);
|
|
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".into(),
|
|
environment_id: None,
|
|
command: vec!["curl".into(), "https://example.com".into()],
|
|
reason: Some("network request blocked".into()),
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::AcceptForSession,
|
|
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: NetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: NetworkPolicyRuleAction::Allow,
|
|
},
|
|
},
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: Some(NetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
}),
|
|
additional_permissions: None,
|
|
});
|
|
|
|
let view = make_overlay(exec_request, tx, Features::with_defaults());
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
|
|
view.render(
|
|
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
|
|
&mut buf,
|
|
);
|
|
assert_snapshot!("network_exec_prompt", format!("{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 network access to \"example.com\"?")
|
|
}),
|
|
"expected network title to include host, got {rendered:?}"
|
|
);
|
|
assert!(
|
|
!rendered.iter().any(|line| line.contains("$ curl")),
|
|
"network prompt should not show command line, got {rendered:?}"
|
|
);
|
|
assert!(
|
|
!rendered.iter().any(|line| line.contains("don't ask again")),
|
|
"network prompt should not show execpolicy option, got {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_shift_a_opens_fullscreen() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
|
|
|
view.handle_key_event(KeyEvent::new(
|
|
KeyCode::Char('a'),
|
|
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
|
));
|
|
|
|
let mut saw_fullscreen = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) {
|
|
saw_fullscreen = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen");
|
|
}
|
|
|
|
#[test]
|
|
fn exec_history_cell_wraps_with_two_space_indent() {
|
|
let command = vec![
|
|
"/bin/zsh".into(),
|
|
"-lc".into(),
|
|
"git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(),
|
|
];
|
|
let cell = history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(command),
|
|
ReviewDecision::Approved,
|
|
history_cell::ApprovalDecisionActor::User,
|
|
);
|
|
let lines = cell.display_lines(/*width*/ 28);
|
|
let rendered: Vec<String> = lines
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect();
|
|
let expected = vec![
|
|
"✔ You approved codex to run".to_string(),
|
|
" git add tui/src/render/".to_string(),
|
|
" mod.rs tui/src/render/".to_string(),
|
|
" renderable.rs this time".to_string(),
|
|
];
|
|
assert_eq!(rendered, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_history_cell_does_not_render_blank_action_for_empty_command() {
|
|
let approved = history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(Vec::new()),
|
|
ReviewDecision::Approved,
|
|
history_cell::ApprovalDecisionActor::User,
|
|
);
|
|
assert_eq!(
|
|
render_history_cell_lines(approved.as_ref(), /*width*/ 80),
|
|
vec!["✔ You approved this request this time".to_string()]
|
|
);
|
|
|
|
let approved_for_session = history_cell::new_approval_decision_cell(
|
|
history_cell::ApprovalDecisionSubject::Command(Vec::new()),
|
|
ReviewDecision::ApprovedForSession,
|
|
history_cell::ApprovalDecisionActor::User,
|
|
);
|
|
assert_eq!(
|
|
render_history_cell_lines(approved_for_session.as_ref(), /*width*/ 80),
|
|
vec!["✔ You approved this request every time this session".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_access_command_history_uses_target_without_structured_context() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut view = make_overlay(
|
|
ApprovalRequest::Exec(ExecApprovalRequest {
|
|
kind: Default::default(),
|
|
thread_id: ThreadId::new(),
|
|
thread_label: None,
|
|
id: "test".into(),
|
|
environment_id: None,
|
|
command: vec![
|
|
"network-access".to_string(),
|
|
"https://example.com:8443".to_string(),
|
|
],
|
|
reason: None,
|
|
available_decisions: vec![
|
|
CommandExecutionApprovalDecision::Accept,
|
|
CommandExecutionApprovalDecision::Cancel,
|
|
],
|
|
network_approval_context: None,
|
|
additional_permissions: None,
|
|
}),
|
|
tx,
|
|
Features::with_defaults(),
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
let mut decision = None;
|
|
while let Ok(event) = rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
|
decision = Some(cell);
|
|
break;
|
|
}
|
|
}
|
|
let decision = decision.expect("expected decision cell in history");
|
|
assert_eq!(
|
|
render_history_cell_lines(decision.as_ref(), /*width*/ 80),
|
|
vec![
|
|
"✔ You approved codex network access to https://example.com:8443 this time"
|
|
.to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn esc_cancels_mcp_elicitation() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults());
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
|
|
let mut decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ResolveElicitation { decision: d, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
decision = Some(d);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(decision, Some(McpServerElicitationAction::Cancel));
|
|
}
|
|
|
|
#[test]
|
|
fn esc_still_cancels_elicitation_with_custom_overlap() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.approval.decline = vec![
|
|
key_hint::plain(KeyCode::Esc),
|
|
key_hint::plain(KeyCode::Char('n')),
|
|
];
|
|
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
|
|
|
|
let mut view = make_overlay_with_keymap(
|
|
make_elicitation_request(),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
let mut esc_decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ResolveElicitation { decision, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
esc_decision = Some(decision);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(esc_decision, Some(McpServerElicitationAction::Cancel));
|
|
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
|
keymap.approval.decline = vec![
|
|
key_hint::plain(KeyCode::Esc),
|
|
key_hint::plain(KeyCode::Char('n')),
|
|
];
|
|
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
|
|
|
|
let mut view = make_overlay_with_keymap(
|
|
make_elicitation_request(),
|
|
tx,
|
|
Features::with_defaults(),
|
|
keymap.approval,
|
|
keymap.list,
|
|
);
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
let mut n_decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ResolveElicitation { decision, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
n_decision = Some(decision);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(n_decision, Some(McpServerElicitationAction::Decline));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_sets_last_selected_index_without_dismissing() {
|
|
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
|
let tx = AppEventSender::new(tx_raw);
|
|
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
|
|
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(
|
|
view.is_complete(),
|
|
"exec approval should complete without queued requests"
|
|
);
|
|
|
|
let mut decision = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { decision: d, .. },
|
|
..
|
|
} = ev
|
|
{
|
|
decision = Some(d);
|
|
break;
|
|
}
|
|
}
|
|
assert_eq!(decision, Some(CommandExecutionApprovalDecision::Accept));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "approval_overlay/clipping_tests.rs"]
|
|
mod clipping_tests;
|