Require approval for input to escalated terminals (#40978)

## What changed

- Add the `write_stdin_approval` feature flag, disabled by default, to require a fresh approval before sending non-empty input to an escalated unified-exec terminal.
- Route these reviews through hooks, Guardian, app-server, and the TUI as `writeStdin` approvals while keeping the parent command item unchanged.
- Preserve the terminal environment, launch directory, and escalation state across turns, and revalidate the process identity after approval before writing input.

## Testing

- Cover approval routing, policy decisions, process reuse, terminal identity preservation, app-server events, analytics, and TUI rendering.

GitOrigin-RevId: 4ba29eaae6208e934737ba078c96e589c7c2164d
This commit is contained in:
Dylan Hurd authored and copyberry committed 2026-08-26 21:39:05 +00:00
1 parent 21ff2e802c
commit a57b398351
41 files changed
+861 -169

No files matched your search

@@ -2808,58 +2808,89 @@ async fn plugin_measurement_batch_emits_directly_and_filters_invalid_rows() {
#[tokio::test] #[tokio::test]
async fn command_execution_approval_response_publishes_user_review_event() { async fn command_execution_approval_response_publishes_user_review_event() {
let mut reducer = AnalyticsReducer::default(); for (kind, approval_id, subject, trigger) in [
let mut events = Vec::new(); (None, None, "command_execution", "initial"),
(
None,
Some("execve-approval"),
"command_execution",
"execve_intercept",
),
(
Some("writeStdin"),
Some("stdin-approval"),
"write_stdin",
"initial",
),
] {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await; ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer let mut request = serde_json::to_value(sample_command_approval_request(
.ingest( /*request_id*/ 41,
AnalyticsFact::ServerRequest { approval_id,
connection_id: 7, ))
request: Box::new(sample_command_approval_request( .expect("serialize approval request");
/*request_id*/ 41, /*approval_id*/ None, // Missing kind models requests from older app-servers.
)), if let Some(kind) = kind {
}, request["params"]["kind"] = json!(kind);
&mut events, } else {
) request["params"].as_object_mut().unwrap().remove("kind");
.await; }
assert!(events.is_empty()); reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(
serde_json::from_value(request).expect("deserialize approval request"),
),
},
&mut events,
)
.await;
assert!(events.is_empty());
reducer reducer
.ingest( .ingest(
AnalyticsFact::ServerResponse { AnalyticsFact::ServerResponse {
completed_at_ms: 1_042, completed_at_ms: 1_042,
response: Box::new(sample_command_approval_response( response: Box::new(sample_command_approval_response(
/*request_id*/ 41, /*request_id*/ 41,
CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Accept,
)), )),
}, },
&mut events, &mut events,
) )
.await; .await;
let payload = serde_json::to_value(&events).expect("serialize events"); let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_review_event"); assert_eq!(payload[0]["event_type"], "codex_review_event");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1");
assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); assert_eq!(payload[0]["event_params"]["item_id"], "item-1");
assert_eq!(payload[0]["event_params"]["review_id"], "user:41"); assert_eq!(payload[0]["event_params"]["review_id"], "user:41");
assert_eq!(payload[0]["event_params"]["thread_source"], "user"); assert_eq!(payload[0]["event_params"]["thread_source"], "user");
assert_eq!( assert_eq!(payload[0]["event_params"]["subject_kind"], subject);
payload[0]["event_params"]["subject_kind"], assert_eq!(payload[0]["event_params"]["subject_name"], subject);
"command_execution" assert_eq!(payload[0]["event_params"]["reviewer"], "user");
); assert_eq!(payload[0]["event_params"]["trigger"], trigger);
assert_eq!( assert_eq!(payload[0]["event_params"]["status"], "approved");
payload[0]["event_params"]["subject_name"], assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000);
"command_execution" assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042);
); assert_eq!(payload[0]["event_params"]["duration_ms"], 42);
assert_eq!(payload[0]["event_params"]["reviewer"], "user");
assert_eq!(payload[0]["event_params"]["trigger"], "initial"); // Stdin reviews must not count toward the parent command's approval summary.
assert_eq!(payload[0]["event_params"]["status"], "approved"); events.clear();
assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "item-1")
assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042); .await;
assert_eq!(payload[0]["event_params"]["duration_ms"], 42); let item = serde_json::to_value(&events[0]).expect("serialize tool item event");
assert_eq!(
item["event_params"]["review_count"],
u64::from(kind.is_none())
);
}
} }
async fn ingest_code_mode_facts( async fn ingest_code_mode_facts(
+14 -3
View File
@@ -126,6 +126,7 @@ use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionApprovalKind;
use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::DynamicToolCallOutputContentItem;
@@ -1498,6 +1499,10 @@ impl AnalyticsReducer {
fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) { fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) {
match request { match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => { ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let is_stdin_review = match params.kind {
CommandExecutionApprovalKind::WriteStdin => true,
CommandExecutionApprovalKind::Command => false,
};
let is_network_access_review = params.network_approval_context.is_some(); let is_network_access_review = params.network_approval_context.is_some();
let requested_network_access = is_network_access_review let requested_network_access = is_network_access_review
|| params || params
@@ -1511,7 +1516,9 @@ impl AnalyticsReducer {
.and_then(|network| network.enabled) .and_then(|network| network.enabled)
.unwrap_or(false); .unwrap_or(false);
let requested_additional_permissions = params.additional_permissions.is_some(); let requested_additional_permissions = params.additional_permissions.is_some();
let trigger = if params.approval_id.is_some() { let trigger = if is_stdin_review {
ReviewTrigger::Initial
} else if params.approval_id.is_some() {
ReviewTrigger::ExecveIntercept ReviewTrigger::ExecveIntercept
} else if requested_network_access { } else if requested_network_access {
ReviewTrigger::NetworkPolicyDenial ReviewTrigger::NetworkPolicyDenial
@@ -1530,12 +1537,16 @@ impl AnalyticsReducer {
turn_id: params.turn_id, turn_id: params.turn_id,
item_id: Some(params.item_id), item_id: Some(params.item_id),
review_id: user_review_id(&request_id), review_id: user_review_id(&request_id),
subject_kind: if is_network_access_review { subject_kind: if is_stdin_review {
ReviewSubjectKind::WriteStdin
} else if is_network_access_review {
ReviewSubjectKind::NetworkAccess ReviewSubjectKind::NetworkAccess
} else { } else {
ReviewSubjectKind::CommandExecution ReviewSubjectKind::CommandExecution
}, },
subject_name: if is_network_access_review { subject_name: if is_stdin_review {
"write_stdin".to_string()
} else if is_network_access_review {
"network_access".to_string() "network_access".to_string()
} else { } else {
"command_execution".to_string() "command_execution".to_string()
+2
View File
@@ -1822,6 +1822,8 @@ Order of messages:
`kind` distinguishes command approvals from writes to an existing terminal. Requests from older servers without `kind` retain `command` semantics; `approvalId` alone does not distinguish stdin writes from execve interception. `kind` distinguishes command approvals from writes to an existing terminal. Requests from older servers without `kind` retain `command` semantics; `approvalId` alone does not distinguish stdin writes from execve interception.
When stdin approvals are enabled, a `write_stdin` approval sets `kind: "writeStdin"`, references the original terminal command's `itemId`, and has its own `approvalId`. The request belongs to the current turn, which may differ from the turn that opened the terminal. With `approvalsReviewer: "auto_review"`, the `item/autoApprovalReview/*` notifications likewise target the original command item and carry an action of type `writeStdin` with `approvalId`, `processId`, `stdin`, and `cwd`. For stdin approvals, `cwd` is the terminal’s launch directory, not its current working directory. Approving or denying a stdin write does not start, complete, or change the status of the parent command-execution item.
### File change approvals ### File change approvals
Order of messages: Order of messages:
@@ -117,6 +117,7 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::shlex_join; use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
@@ -630,26 +631,46 @@ pub(crate) async fn apply_bespoke_event_handling(
parsed_cmd, parsed_cmd,
.. ..
} = ev; } = ev;
let command_actions = parsed_cmd let cwd_uri = match PathUri::try_from(cwd.clone()) {
.iter() Ok(cwd) => cwd,
.cloned() Err(err) => {
.map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) error!(%err, "invalid command approval cwd");
.collect::<Vec<_>>(); if let Err(err) = conversation
.submit(Op::ExecApproval {
id: approval_id.unwrap_or(call_id),
turn_id: Some(turn_id),
decision: ReviewDecision::denied("invalid command approval cwd"),
})
.await
{
error!(%err, "failed to reject invalid command approval");
}
return;
}
};
let command_presentation =
CommandExecutionPresentation::from_raw(&command, &parsed_cmd, &cwd_uri);
// Approval requests retain the exact command; only history is redacted.
let command_actions = match cwd_uri.to_abs_path() {
Ok(native_cwd) => parsed_cmd
.iter()
.cloned()
.map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &native_cwd))
.collect(),
Err(_) => vec![V2ParsedCommand::Unknown {
command: shlex_join(&command),
}],
};
let presentation = if let Some(network_approval_context) = let presentation = if let Some(network_approval_context) =
network_approval_context.map(V2NetworkApprovalContext::from) network_approval_context.map(V2NetworkApprovalContext::from)
{ {
CommandExecutionApprovalPresentation::Network(network_approval_context) CommandExecutionApprovalPresentation::Network(network_approval_context)
} else { } else {
let command_presentation = CommandExecutionPresentation::from_raw(
&command,
&parsed_cmd,
&cwd.clone().into(),
);
let completion_item = CommandExecutionCompletionItem { let completion_item = CommandExecutionCompletionItem {
plugin_id, plugin_id,
script_path, script_path,
command: command_presentation.command, command: command_presentation.command,
cwd: cwd.clone().into(), cwd: cwd.clone(),
command_actions: command_presentation.command_actions, command_actions: command_presentation.command_actions,
}; };
CommandExecutionApprovalPresentation::Command(completion_item) CommandExecutionApprovalPresentation::Command(completion_item)
+6
View File
@@ -993,6 +993,9 @@
}, },
"workspace_owner_usage_nudge": { "workspace_owner_usage_nudge": {
"type": "boolean" "type": "boolean"
},
"write_stdin_approval": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -6143,6 +6146,9 @@
}, },
"workspace_owner_usage_nudge": { "workspace_owner_usage_nudge": {
"type": "boolean" "type": "boolean"
},
"write_stdin_approval": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -26,16 +26,10 @@ pub(crate) enum GuardianApprovalRequest {
justification: Option<String>, justification: Option<String>,
tty: bool, tty: bool,
}, },
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Constructed by the follow-up stdin approval routing change"
)
)]
WriteStdin { WriteStdin {
id: String, id: String,
approval_id: String, approval_id: String,
environment_id: String,
process_id: i32, process_id: i32,
input: String, input: String,
cwd: PathUri, cwd: PathUri,
@@ -129,6 +123,7 @@ struct CommandApprovalAction<'a> {
#[derive(Serialize)] #[derive(Serialize)]
struct WriteStdinApprovalAction<'a> { struct WriteStdinApprovalAction<'a> {
tool: &'static str, tool: &'static str,
environment_id: &'a str,
session_id: i32, session_id: i32,
chars: &'a str, chars: &'a str,
cwd: LegacyAppPathString, cwd: LegacyAppPathString,
@@ -300,6 +295,7 @@ pub(crate) fn guardian_approval_request_to_json(
Some(*tty), Some(*tty),
), ),
GuardianApprovalRequest::WriteStdin { GuardianApprovalRequest::WriteStdin {
environment_id,
process_id, process_id,
input, input,
cwd, cwd,
@@ -307,6 +303,7 @@ pub(crate) fn guardian_approval_request_to_json(
.. ..
} => serialize_guardian_action(WriteStdinApprovalAction { } => serialize_guardian_action(WriteStdinApprovalAction {
tool: "write_stdin", tool: "write_stdin",
environment_id,
session_id: *process_id, session_id: *process_id,
chars: input, chars: input,
cwd: cwd.clone().into(), cwd: cwd.clone().into(),
+5 -3
View File
@@ -313,10 +313,12 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
push_text("Retry reason:\n".to_string()); push_text("Retry reason:\n".to_string());
push_text(format!("{reason}\n\n")); push_text(format!("{reason}\n\n"));
} }
push_text( let action_scope = if matches!(&request, GuardianApprovalRequest::WriteStdin { .. }) {
"Assess input to the existing terminal, not a fresh command. The `cwd` field is its launch directory; the terminal's current directory and state may have changed. Use the retained transcript and read-only checks when that state matters.\n"
} else {
"Assess the exact planned action below. Use read-only tool checks when local state matters.\n" "Assess the exact planned action below. Use read-only tool checks when local state matters.\n"
.to_string(), };
); push_text(action_scope.to_string());
push_text("Planned action JSON:\n".to_string()); push_text("Planned action JSON:\n".to_string());
} }
} }
+2
View File
@@ -1371,6 +1371,7 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result<
let action = GuardianApprovalRequest::WriteStdin { let action = GuardianApprovalRequest::WriteStdin {
id: "terminal-open".to_string(), id: "terminal-open".to_string(),
approval_id: "terminal-write".to_string(), approval_id: "terminal-write".to_string(),
environment_id: "windows-executor".to_string(),
process_id: 1000, process_id: 1000,
input: input.to_string(), input: input.to_string(),
cwd: cwd.clone(), cwd: cwd.clone(),
@@ -1381,6 +1382,7 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd() -> serde_json::Result<
guardian_approval_request_to_json(&action)?, guardian_approval_request_to_json(&action)?,
serde_json::json!({ serde_json::json!({
"tool": "write_stdin", "tool": "write_stdin",
"environment_id": "windows-executor",
"session_id": 1000, "session_id": 1000,
"chars": input, "chars": input,
"cwd": r"C:\workspace", "cwd": r"C:\workspace",
@@ -3,6 +3,7 @@ use std::sync::Arc;
use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::PluginCommandAttribution;
use codex_plugin::PluginId; use codex_plugin::PluginId;
use codex_protocol::approvals::ExecApprovalKind;
use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile;
@@ -50,11 +51,12 @@ async fn command_approval_holds_an_elicitation_until_response() {
session session
.request_command_approval( .request_command_approval(
turn_context.as_ref(), turn_context.as_ref(),
ExecApprovalKind::Command,
"call-1".to_string(), "call-1".to_string(),
/*approval_id*/ None, /*approval_id*/ None,
/*environment_id*/ None, /*environment_id*/ None,
vec!["echo".to_string()], vec!["echo".to_string()],
cwd, cwd.into(),
/*reason*/ None, /*reason*/ None,
/*network_approval_context*/ None, /*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None, /*proposed_execpolicy_amendment*/ None,
+10 -6
View File
@@ -2475,11 +2475,12 @@ impl Session {
pub async fn request_command_approval( pub async fn request_command_approval(
&self, &self,
turn_context: &TurnContext, turn_context: &TurnContext,
kind: ExecApprovalKind,
call_id: String, call_id: String,
approval_id: Option<String>, approval_id: Option<String>,
environment_id: Option<String>, environment_id: Option<String>,
command: Vec<String>, command: Vec<String>,
cwd: AbsolutePathBuf, cwd: PathUri,
reason: Option<String>, reason: Option<String>,
network_approval_context: Option<NetworkApprovalContext>, network_approval_context: Option<NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>, proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
@@ -2489,7 +2490,7 @@ impl Session {
) -> ReviewDecision { ) -> ReviewDecision {
let _elicitation = self.services.elicitations.register(); let _elicitation = self.services.elicitations.register();
// command-level approvals use `call_id`. // command-level approvals use `call_id`.
// `approval_id` is only present for subcommand callbacks (execve intercept) // `approval_id` identifies subcommand callbacks and stdin writes.
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
// Add the tx_approve callback to the map before sending the request. // Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel(); let (tx_approve, rx_approve) = oneshot::channel();
@@ -2528,14 +2529,17 @@ impl Session {
additional_permissions.as_ref(), additional_permissions.as_ref(),
) )
}); });
let plugin_attribution = plugin_attribution_override let plugin_attribution = plugin_attribution_override.or_else(|| {
.or_else(|| turn_context.plugin_attribution_for_command(&command, &cwd)); cwd.to_abs_path()
.ok()
.and_then(|cwd| turn_context.plugin_attribution_for_command(&command, &cwd))
});
let (plugin_id, script_path) = plugin_attribution let (plugin_id, script_path) = plugin_attribution
.as_ref() .as_ref()
.map(PluginCommandAttribution::serialized_fields) .map(PluginCommandAttribution::serialized_fields)
.unzip(); .unzip();
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
kind: ExecApprovalKind::Command, kind,
call_id, call_id,
plugin_id, plugin_id,
script_path, script_path,
@@ -2544,7 +2548,7 @@ impl Session {
environment_id, environment_id,
started_at_ms: now_unix_timestamp_ms(), started_at_ms: now_unix_timestamp_ms(),
command, command,
cwd, cwd: cwd.into(),
reason, reason,
network_approval_context, network_approval_context,
proposed_execpolicy_amendment, proposed_execpolicy_amendment,
+1 -1
View File
@@ -8280,7 +8280,7 @@ async fn shutdown_and_wait_shuts_down_tracked_ephemeral_guardian_review() {
.expect("ephemeral guardian review should receive a shutdown op"); .expect("ephemeral guardian review should receive a shutdown op");
} }
async fn make_session_and_context_with_auth_and_config_and_rx<F>( pub(crate) async fn make_session_and_context_with_auth_and_config_and_rx<F>(
auth: CodexAuth, auth: CodexAuth,
dynamic_tools: Vec<DynamicToolSpec>, dynamic_tools: Vec<DynamicToolSpec>,
configure_config: F, configure_config: F,
+111 -4
View File
@@ -1,6 +1,7 @@
//! Central approval policy-stage execution and reviewer routing. //! Central approval policy-stage execution and reviewer routing.
use crate::command_canonicalization::canonicalize_command_for_approval; use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec_policy::prompt_is_rejected_by_policy;
use crate::guardian::GuardianNetworkAccessTrigger; use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::GuardianReviewContext; use crate::guardian::GuardianReviewContext;
use crate::guardian::GuardianReviewOptions; use crate::guardian::GuardianReviewOptions;
@@ -25,6 +26,7 @@ use codex_analytics::GuardianApprovalRequestSource;
use codex_config::types::AppToolApproval; use codex_config::types::AppToolApproval;
use codex_hooks::PermissionRequestDecision; use codex_hooks::PermissionRequestDecision;
use codex_otel::ToolDecisionSource; use codex_otel::ToolDecisionSource;
use codex_protocol::approvals::ExecApprovalKind;
use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::ExecPolicyAmendment;
#[cfg(unix)] #[cfg(unix)]
use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::GuardianCommandSource;
@@ -75,6 +77,15 @@ pub(crate) enum ApprovalAction {
tty: bool, tty: bool,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>, proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
}, },
WriteStdin {
id: String,
approval_id: String,
environment_id: String,
process_id: i32,
input: String,
cwd: PathUri,
tty: bool,
},
#[cfg(unix)] #[cfg(unix)]
Execve { Execve {
id: String, id: String,
@@ -152,6 +163,26 @@ impl ApprovalAction {
justification, justification,
.. ..
} => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()), } => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()),
Self::WriteStdin {
id,
approval_id,
environment_id,
process_id,
input,
cwd,
tty,
} => PermissionRequestPayload {
tool_name: HookToolName::new("write_stdin"),
tool_input: serde_json::json!({
"session_id": process_id,
"chars": input,
"parent_call_id": id,
"approval_id": approval_id,
"environment_id": environment_id,
"cwd": cwd,
"tty": tty,
}),
},
#[cfg(unix)] #[cfg(unix)]
Self::Execve { command, .. } => PermissionRequestPayload::bash( Self::Execve { command, .. } => PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(command), codex_shell_command::parse_command::shlex_join(command),
@@ -216,7 +247,8 @@ impl ApprovalAction {
Self::Execve { .. } => Vec::new(), Self::Execve { .. } => Vec::new(),
Self::McpToolCall { .. } Self::McpToolCall { .. }
| Self::NetworkAccess { .. } | Self::NetworkAccess { .. }
| Self::RequestPermissions { .. } => Vec::new(), | Self::RequestPermissions { .. }
| Self::WriteStdin { .. } => Vec::new(),
Self::ApplyPatch { Self::ApplyPatch {
environment_id, environment_id,
files, files,
@@ -255,6 +287,23 @@ impl ApprovalAction {
justification, justification,
tty, tty,
}, },
Self::WriteStdin {
id,
approval_id,
environment_id,
process_id,
input,
cwd,
tty,
} => crate::guardian::GuardianApprovalRequest::WriteStdin {
id,
approval_id,
environment_id,
process_id,
input,
cwd,
tty,
},
#[cfg(unix)] #[cfg(unix)]
Self::Execve { Self::Execve {
id, id,
@@ -436,6 +485,16 @@ impl Session {
action: ApprovalAction, action: ApprovalAction,
ctx: ApprovalContext, ctx: ApprovalContext,
) -> Result<ReviewDecision, ToolError> { ) -> Result<ReviewDecision, ToolError> {
// Stdin is a fresh sandbox approval, not an exec-policy cache hit. Strict
// review can route Never to Guardian, but granular restrictions still apply.
let policy = ctx.review_context.turn().approval_policy();
if matches!(&action, ApprovalAction::WriteStdin { .. })
&& !(ctx.strict_auto_review && matches!(policy, AskForApproval::Never))
&& let Some(reason) =
prompt_is_rejected_by_policy(policy, /*prompt_is_rule*/ false)
{
return Err(ToolError::Rejected(reason.to_string()));
}
let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. }); let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. });
let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. });
let permission_request_run_id = match &action { let permission_request_run_id = match &action {
@@ -532,6 +591,20 @@ impl Session {
action: ApprovalAction, action: ApprovalAction,
ctx: &ApprovalContext, ctx: &ApprovalContext,
) -> ReviewDecision { ) -> ReviewDecision {
// Guardian inherits only the current turn's ready environments. A retained
// terminal handle may outlive its selection, but must not be reviewed in
// a different environment that happens to have the same launch directory.
if let ApprovalAction::WriteStdin { environment_id, .. } = &action
&& !ctx
.review_context
.environments()
.turn_environments()
.any(|environment| environment.selection.environment_id == *environment_id)
{
return ReviewDecision::denied(
"automatic approval review cannot access the terminal's environment; select it before retrying",
);
}
let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. }); let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. });
let review_id = new_guardian_review_id(); let review_id = new_guardian_review_id();
let action = match action.into_guardian_request() { let action = match action.into_guardian_request() {
@@ -650,11 +723,12 @@ impl Session {
with_cached_approval(&self.services, tool_name, cache_keys, || async { with_cached_approval(&self.services, tool_name, cache_keys, || async {
self.request_command_approval( self.request_command_approval(
ctx.review_context.turn(), ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.call_id.clone(), ctx.call_id.clone(),
/*approval_id*/ None, /*approval_id*/ None,
Some(environment_id.clone()), Some(environment_id.clone()),
command.clone(), command.clone(),
cwd, cwd.into(),
reason, reason,
ctx.network_approval_context.clone(), ctx.network_approval_context.clone(),
proposed_execpolicy_amendment.clone(), proposed_execpolicy_amendment.clone(),
@@ -666,6 +740,37 @@ impl Session {
}) })
.await .await
} }
ApprovalAction::WriteStdin {
id,
approval_id,
environment_id,
process_id,
input,
cwd,
..
} => {
self.request_command_approval(
ctx.review_context.turn(),
ExecApprovalKind::WriteStdin,
id.clone(),
Some(approval_id.clone()),
Some(environment_id.clone()),
vec![
"write_stdin".to_string(),
"--session-id".to_string(),
process_id.to_string(),
input.clone(),
],
cwd.clone(),
ctx.approval_reason.clone(),
/*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None,
/*additional_permissions*/ None,
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
/*plugin_attribution_override*/ None,
)
.await
}
#[cfg(unix)] #[cfg(unix)]
ApprovalAction::Execve { ApprovalAction::Execve {
approval_id, approval_id,
@@ -677,11 +782,12 @@ impl Session {
} => { } => {
self.request_command_approval( self.request_command_approval(
ctx.review_context.turn(), ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.call_id.clone(), ctx.call_id.clone(),
Some(approval_id.clone()), Some(approval_id.clone()),
Some(environment_id.clone()), Some(environment_id.clone()),
command.clone(), command.clone(),
cwd.clone(), cwd.clone().into(),
/*reason*/ None, /*reason*/ None,
/*network_approval_context*/ None, /*network_approval_context*/ None,
/*proposed_execpolicy_amendment*/ None, /*proposed_execpolicy_amendment*/ None,
@@ -748,11 +854,12 @@ impl Session {
} => { } => {
self.request_command_approval( self.request_command_approval(
ctx.review_context.turn(), ctx.review_context.turn(),
ExecApprovalKind::Command,
ctx.call_id.clone(), ctx.call_id.clone(),
/*approval_id*/ None, /*approval_id*/ None,
Some(environment_id.clone()), Some(environment_id.clone()),
command.clone(), command.clone(),
cwd.clone(), cwd.clone().into(),
ctx.approval_reason.clone(), ctx.approval_reason.clone(),
ctx.network_approval_context.clone(), ctx.network_approval_context.clone(),
/*proposed_execpolicy_amendment*/ None, /*proposed_execpolicy_amendment*/ None,
@@ -95,7 +95,6 @@ fn guardian_cwd_rejects_foreign_remote_path() {
assert!(guardian_cwd(codex_exec_server::REMOTE_ENVIRONMENT_ID, cwd).is_err()); assert!(guardian_cwd(codex_exec_server::REMOTE_ENVIRONMENT_ID, cwd).is_err());
} }
#[tokio::test] #[tokio::test]
async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() { async fn explicit_mcp_reviewer_override_takes_precedence_over_action_context() {
let (session, turn, events) = make_session_and_context_with_rx().await; let (session, turn, events) = make_session_and_context_with_rx().await;
@@ -7,6 +7,9 @@ use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExecutor;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::WriteStdinInteractionEvent; use crate::unified_exec::WriteStdinInteractionEvent;
use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::WriteStdinRequest;
use codex_tools::ToolName; use codex_tools::ToolName;
@@ -56,6 +59,9 @@ impl WriteStdinHandler {
let ToolInvocation { let ToolInvocation {
session, session,
turn, turn,
step_context,
cancellation_token,
call_id,
payload, payload,
.. ..
} = invocation; } = invocation;
@@ -70,23 +76,37 @@ impl WriteStdinHandler {
}; };
let args: WriteStdinArgs = parse_arguments(&arguments)?; let args: WriteStdinArgs = parse_arguments(&arguments)?;
let context =
UnifiedExecContext::new(session.clone(), step_context, cancellation_token, call_id);
let response = session let response = session
.services .services
.unified_exec_manager .unified_exec_manager
.write_stdin(WriteStdinRequest { .write_stdin(
process_id: args.session_id, &context,
input: &args.chars, WriteStdinRequest {
yield_time_ms: args.yield_time_ms, process_id: args.session_id,
max_output_tokens: args.max_output_tokens, input: &args.chars,
truncation_policy: turn.model_info().truncation_policy.into(), yield_time_ms: args.yield_time_ms,
interaction_event: Some(WriteStdinInteractionEvent { max_output_tokens: args.max_output_tokens,
session: &session, truncation_policy: turn.model_info().truncation_policy.into(),
turn: &turn, interaction_event: Some(WriteStdinInteractionEvent {
}), session: &session,
}) turn: &turn,
}),
},
)
.await .await
.map_err(|err| { .map_err(|err| {
FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) let message = match err {
UnifiedExecError::StdinApproval(ToolError::Rejected(reason)) => {
format!("write_stdin rejected: {reason}")
}
UnifiedExecError::StdinApproval(ToolError::Codex(err)) => {
format!("write_stdin approval failed: {err}")
}
err => format!("write_stdin failed: {err}"),
};
FunctionCallError::RespondToModel(message)
})?; })?;
Ok(boxed_tool_output(response)) Ok(boxed_tool_output(response))
@@ -106,6 +106,7 @@ pub struct UnifiedExecRuntime<'a> {
pub(crate) struct UnifiedExecAttempt { pub(crate) struct UnifiedExecAttempt {
pub(crate) process: UnifiedExecProcess, pub(crate) process: UnifiedExecProcess,
pub(crate) metrics_sidecar: Option<PluginMetricsSidecar>, pub(crate) metrics_sidecar: Option<PluginMetricsSidecar>,
pub(crate) escalated: bool,
} }
fn unified_exec_options( fn unified_exec_options(
@@ -475,6 +476,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
return Ok(UnifiedExecAttempt { return Ok(UnifiedExecAttempt {
process, process,
metrics_sidecar, metrics_sidecar,
escalated: attempt.is_escalated(),
}); });
} }
None => { None => {
@@ -519,6 +521,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
Ok(UnifiedExecAttempt { Ok(UnifiedExecAttempt {
process, process,
metrics_sidecar, metrics_sidecar,
escalated: attempt.is_escalated(),
}) })
} }
} }
+11
View File
@@ -418,6 +418,17 @@ pub(crate) fn executor_windows_sandbox_level(
} }
impl<'a> SandboxAttempt<'a> { impl<'a> SandboxAttempt<'a> {
/// Whether this attempt bypasses sandboxing required by its ambient policy.
/// Use the requested policy, not the controller's wrapper, for remote executors.
pub(crate) fn is_escalated(&self) -> bool {
!self.sandbox_requested
&& self.manager.should_sandbox(
self.permissions,
SandboxablePreference::Auto,
self.enforce_managed_network,
)
}
pub(crate) fn network_proxy<'b>( pub(crate) fn network_proxy<'b>(
&'b self, &'b self,
fallback: Option<&'b NetworkProxy>, fallback: Option<&'b NetworkProxy>,
@@ -352,6 +352,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
let request = attempt let request = attempt
.env_for_exec_server(command(), options()) .env_for_exec_server(command(), options())
.expect("prepare remote exec request"); .expect("prepare remote exec request");
assert!(!attempt.is_escalated());
assert_eq!( assert_eq!(
request.command, request.command,
@@ -390,8 +391,15 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
let request = attempt let request = attempt
.env_for_exec_server(command(), options()) .env_for_exec_server(command(), options())
.expect("prepare unsandboxed remote exec request"); .expect("prepare unsandboxed remote exec request");
assert!(attempt.is_escalated());
assert_eq!(request.exec_server_sandbox, None); assert_eq!(request.exec_server_sandbox, None);
assert!(!request.exec_server_enforce_managed_network); assert!(!request.exec_server_enforce_managed_network);
assert_eq!(request.exec_server_managed_network, Some(managed_network)); assert_eq!(request.exec_server_managed_network, Some(managed_network));
let full_access = codex_protocol::models::PermissionProfile::Disabled;
attempt.permissions = &full_access;
attempt.exec_server_permissions = &full_access;
attempt.enforce_managed_network = false;
assert!(!attempt.is_escalated(), "full access is not an escalation");
} }
+2
View File
@@ -12,6 +12,8 @@ pub(crate) enum UnifiedExecError {
// The model is trained on `session_id`, but internally we track a `process_id`. // The model is trained on `session_id`, but internally we track a `process_id`.
#[error("Unknown process id {process_id}")] #[error("Unknown process id {process_id}")]
UnknownProcessId { process_id: i32 }, UnknownProcessId { process_id: i32 },
#[error("stdin approval failed: {0:?}")]
StdinApproval(crate::tools::sandboxing::ToolError),
#[error("failed to write to stdin")] #[error("failed to write to stdin")]
WriteToStdin, WriteToStdin,
#[error( #[error(
+4
View File
@@ -184,6 +184,10 @@ struct ProcessEntry {
initial_exec_command_active: Arc<std::sync::atomic::AtomicBool>, initial_exec_command_active: Arc<std::sync::atomic::AtomicBool>,
hook_command: String, hook_command: String,
tty: bool, tty: bool,
environment_id: String,
// The successful launch bypassed sandboxing required by its ambient policy.
// Preserve this across turns so subsequent stdin writes can require approval.
escalated: bool,
network_approval: Option<DeferredNetworkApproval>, network_approval: Option<DeferredNetworkApproval>,
session: Weak<Session>, session: Weak<Session>,
last_used: tokio::time::Instant, last_used: tokio::time::Instant,
+158 -12
View File
@@ -141,6 +141,8 @@ async fn exec_command_with_tty(
initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
hook_command: cmd.to_string(), hook_command: cmd.to_string(),
tty, tty,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
escalated: false,
network_approval: None, network_approval: None,
session: Arc::downgrade(session), session: Arc::downgrade(session),
last_used: started_at, last_used: started_at,
@@ -304,6 +306,7 @@ async fn blocking_terminate_unified_process(
async fn write_stdin( async fn write_stdin(
session: &Arc<Session>, session: &Arc<Session>,
turn: &Arc<TurnContext>,
process_id: i32, process_id: i32,
input: &str, input: &str,
yield_time_ms: u64, yield_time_ms: u64,
@@ -311,14 +314,22 @@ async fn write_stdin(
session session
.services .services
.unified_exec_manager .unified_exec_manager
.write_stdin(WriteStdinRequest { .write_stdin(
process_id, &UnifiedExecContext::new(
input, Arc::clone(session),
yield_time_ms, crate::session::step_context::StepContext::for_test(Arc::clone(turn)),
max_output_tokens: None, tokio_util::sync::CancellationToken::new(),
truncation_policy: TruncationPolicy::Tokens(10_000), "write".to_string(),
interaction_event: None, ),
}) WriteStdinRequest {
process_id,
input,
yield_time_ms,
max_output_tokens: None,
truncation_policy: TruncationPolicy::Tokens(10_000),
interaction_event: None,
},
)
.await .await
} }
@@ -347,6 +358,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> {
write_stdin( write_stdin(
&session, &session,
&turn,
process_id, process_id,
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n", "export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
/*yield_time_ms*/ 2_500, /*yield_time_ms*/ 2_500,
@@ -355,6 +367,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> {
let out_2 = write_stdin( let out_2 = write_stdin(
&session, &session,
&turn,
process_id, process_id,
"echo $CODEX_INTERACTIVE_SHELL_VAR\n", "echo $CODEX_INTERACTIVE_SHELL_VAR\n",
/*yield_time_ms*/ 2_500, /*yield_time_ms*/ 2_500,
@@ -388,6 +401,7 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> {
write_stdin( write_stdin(
&session, &session,
&turn,
session_a, session_a,
"export CODEX_INTERACTIVE_SHELL_VAR=codex\n", "export CODEX_INTERACTIVE_SHELL_VAR=codex\n",
/*yield_time_ms*/ 2_500, /*yield_time_ms*/ 2_500,
@@ -416,6 +430,7 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> {
let out_3 = write_stdin( let out_3 = write_stdin(
&session, &session,
&turn,
shell_a.process_id.expect("expected process id"), shell_a.process_id.expect("expected process id"),
"echo $CODEX_INTERACTIVE_SHELL_VAR\n", "echo $CODEX_INTERACTIVE_SHELL_VAR\n",
/*yield_time_ms*/ 2_500, /*yield_time_ms*/ 2_500,
@@ -447,6 +462,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> {
write_stdin( write_stdin(
&session, &session,
&turn,
process_id, process_id,
format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(), format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(),
/*yield_time_ms*/ 2_500, /*yield_time_ms*/ 2_500,
@@ -455,6 +471,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> {
let out_2 = write_stdin( let out_2 = write_stdin(
&session, &session,
&turn,
process_id, process_id,
"sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n", "sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n",
/*yield_time_ms*/ 10, /*yield_time_ms*/ 10,
@@ -469,7 +486,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> {
tokio::time::sleep(Duration::from_secs(7)).await; tokio::time::sleep(Duration::from_secs(7)).await;
let out_3 = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100).await?; let out_3 = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100).await?;
assert!( assert!(
out_3 out_3
@@ -533,11 +550,14 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<(
.await?; .await?;
let process_id = open_shell.process_id.expect("expected process id"); let process_id = open_shell.process_id.expect("expected process id");
write_stdin(&session, process_id, "exit\n", /*yield_time_ms*/ 2_500).await?; write_stdin(
&session, &turn, process_id, "exit\n", /*yield_time_ms*/ 2_500,
)
.await?;
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
let err = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100) let err = write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 100)
.await .await
.expect_err("expected unknown process error"); .expect_err("expected unknown process error");
@@ -588,6 +608,8 @@ async fn terminating_initial_exec_command_rechecks_initial_response_state() -> a
initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
hook_command: "sleep 60".to_string(), hook_command: "sleep 60".to_string(),
tty: true, tty: true,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
escalated: false,
network_approval: None, network_approval: None,
session: Arc::downgrade(&session), session: Arc::downgrade(&session),
last_used: Instant::now(), last_used: Instant::now(),
@@ -662,6 +684,8 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu
initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)), initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
hook_command: "sleep 60".to_string(), hook_command: "sleep 60".to_string(),
tty: true, tty: true,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
escalated: false,
network_approval: None, network_approval: None,
session: Arc::downgrade(&session), session: Arc::downgrade(&session),
last_used, last_used,
@@ -670,8 +694,12 @@ async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Resu
let poll_task = tokio::spawn({ let poll_task = tokio::spawn({
let session = Arc::clone(&session); let session = Arc::clone(&session);
let turn = Arc::clone(&turn);
async move { async move {
write_stdin(&session, process_id, "", /*yield_time_ms*/ 60_000).await write_stdin(
&session, &turn, process_id, "", /*yield_time_ms*/ 60_000,
)
.await
} }
}); });
tokio::time::timeout(Duration::from_secs(2), async { tokio::time::timeout(Duration::from_secs(2), async {
@@ -835,3 +863,121 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()
); );
Ok(()) Ok(())
} }
#[tokio::test]
async fn stdin_approval_preserves_the_reviewed_terminal() -> anyhow::Result<()> {
use crate::session::tests::make_session_and_context_with_auth_and_config_and_rx;
use crate::state::ActiveTurn;
use crate::tools::sandboxing::ToolError;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
skip_if_sandbox!(Ok(()));
let (session, mut turn, events) = make_session_and_context_with_auth_and_config_and_rx(
codex_login::CodexAuth::from_api_key("Test API Key"),
Vec::new(),
|config| {
config.features.enable(Feature::WriteStdinApproval).unwrap();
config.permissions.approval_policy =
crate::config::Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
},
)
.await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let manager = &session.services.unified_exec_manager;
let command = "while IFS= read -r line; do printf 'received:%s\\n' \"$line\"; done";
let opened = exec_command(
&session, &turn, command, /*yield_time_ms*/ 250, /*workdir*/ None,
)
.await?;
let process_id = opened.process_id.expect("running terminal");
let cwd = PathUri::parse("file:///C:/workspace")?;
let original = {
let mut store = manager.process_store.lock().await;
let entry = store.processes.get_mut(&process_id).unwrap();
entry.escalated = true;
entry.environment_id = "unselected-executor".to_string();
entry.cwd = cwd.clone();
Arc::clone(&entry.process)
};
// Empty polling must complete without an approval response.
tokio::time::timeout(
Duration::from_secs(/*secs*/ 5),
write_stdin(&session, &turn, process_id, "", /*yield_time_ms*/ 250),
)
.await??;
let input = "rejected\n";
let denied = write_stdin(
&session, &turn, process_id, input, /*yield_time_ms*/ 250,
)
.await;
assert!(
matches!(denied, Err(UnifiedExecError::StdinApproval(ToolError::Rejected(reason)))
if reason.contains("select it before retrying"))
);
Arc::make_mut(&mut Arc::get_mut(&mut turn).unwrap().config).approvals_reviewer =
ApprovalsReviewer::User;
for (input, decision) in [
("rejected\n", ReviewDecision::denied("test denial")),
("accepted\n", ReviewDecision::Approved),
("replace\n", ReviewDecision::Approved),
] {
let review = async {
loop {
let EventMsg::ExecApprovalRequest(approval) = events.recv().await?.msg else {
continue;
};
assert_eq!(
(
approval.call_id.as_str(),
approval.approval_id.as_deref(),
approval.cwd
),
("call", Some("write"), cwd.clone().into())
);
assert!(original.interaction_lock().try_lock_owned().is_err());
if input == "replace\n" {
let replacement = super::process_tests::remote_process(
WriteStatus::Accepted,
/*terminate_error*/ None,
SandboxType::None,
)
.await;
let mut store = manager.process_store.lock().await;
store.processes.get_mut(&process_id).unwrap().process = Arc::new(replacement);
}
session.notify_approval("write", decision).await;
return anyhow::Ok(());
}
};
let (result, reviewed) = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
tokio::join!(
write_stdin(
&session, &turn, process_id, input, /*yield_time_ms*/ 250
),
review
)
})
.await?;
reviewed?;
match input {
"rejected\n" => assert!(matches!(result, Err(UnifiedExecError::StdinApproval(_)))),
"accepted\n" => {
let output = result?.truncated_output(DEFAULT_MAX_OUTPUT_TOKENS);
assert!(output.contains("received:accepted"), "{output}");
assert!(!output.contains("received:rejected"), "{output}");
}
_ => assert!(
matches!(result, Err(UnifiedExecError::UnknownProcessId { process_id: id }) if id == process_id)
),
}
assert!(original.interaction_lock().try_lock_owned().is_ok());
}
original.terminate();
assert!(session.terminate_background_terminal(process_id).await);
Ok(())
}
@@ -20,10 +20,12 @@ use crate::exec_env::inject_apply_patch_env;
use crate::exec_env::inject_permission_profile_env; use crate::exec_env::inject_permission_profile_env;
use crate::exec_env::inject_session_id_env; use crate::exec_env::inject_session_id_env;
use crate::exec_policy::ExecApprovalRequest; use crate::exec_policy::ExecApprovalRequest;
use crate::guardian::GuardianReviewContext;
use crate::plugins::metrics::finish_and_track_measurements; use crate::plugins::metrics::finish_and_track_measurements;
use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest; use crate::sandboxing::ExecRequest;
use crate::sandboxing::ExecServerEnvConfig; use crate::sandboxing::ExecServerEnvConfig;
use crate::tools::ApprovalContext;
use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ExecCommandToolOutput;
use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventCtx;
@@ -35,6 +37,7 @@ use crate::tools::runtimes::is_managed_proxy_env_var;
use crate::tools::runtimes::unified_exec::UnifiedExecAttempt; use crate::tools::runtimes::unified_exec::UnifiedExecAttempt;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolError;
@@ -66,6 +69,7 @@ use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::PluginCommandAttribution;
use codex_core_plugins::PluginMetricsSidecar; use codex_core_plugins::PluginMetricsSidecar;
use codex_core_plugins::strip_output_env; use codex_core_plugins::strip_output_env;
use codex_features::Feature;
use codex_network_proxy::NetworkPolicyDecider; use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::config_types::ShellEnvironmentPolicy;
@@ -485,6 +489,7 @@ impl UnifiedExecProcessManager {
let UnifiedExecAttempt { let UnifiedExecAttempt {
process, process,
metrics_sidecar, metrics_sidecar,
escalated,
} = attempt; } = attempt;
let process = Arc::new(process); let process = Arc::new(process);
let network_denial_monitor = deferred_network_approval.as_ref().map(|deferred| { let network_denial_monitor = deferred_network_approval.as_ref().map(|deferred| {
@@ -543,6 +548,8 @@ impl UnifiedExecProcessManager {
&request.command, &request.command,
request.hook_command.clone(), request.hook_command.clone(),
cwd.clone(), cwd.clone(),
request.turn_environment.selection.environment_id.clone(),
escalated,
plugin_attribution.clone(), plugin_attribution.clone(),
start, start,
request.process_id, request.process_id,
@@ -757,6 +764,7 @@ impl UnifiedExecProcessManager {
pub(crate) async fn write_stdin( pub(crate) async fn write_stdin(
&self, &self,
context: &UnifiedExecContext,
request: WriteStdinRequest<'_>, request: WriteStdinRequest<'_>,
) -> Result<ExecCommandToolOutput, UnifiedExecError> { ) -> Result<ExecCommandToolOutput, UnifiedExecError> {
let process_id = request.process_id; let process_id = request.process_id;
@@ -764,16 +772,55 @@ impl UnifiedExecProcessManager {
// Different terminal sessions can be polled concurrently, but reads and // Different terminal sessions can be polled concurrently, but reads and
// writes against one terminal must not overlap because they share a // writes against one terminal must not overlap because they share a
// draining output buffer and process lifecycle. // draining output buffer and process lifecycle.
let locked_process = { let (locked_process, approval) = {
let store = self.process_store.lock().await; let store = self.process_store.lock().await;
let entry = store let entry = store
.processes .processes
.get(&process_id) .get(&process_id)
.ok_or(UnifiedExecError::UnknownProcessId { process_id })?; .ok_or(UnifiedExecError::UnknownProcessId { process_id })?;
Arc::clone(&entry.process) let approval = (!request.input.is_empty()
&& (entry.tty || request.input != INTERRUPT)
&& entry.escalated
&& context
.session
.features()
.enabled(Feature::WriteStdinApproval))
.then(|| ApprovalAction::WriteStdin {
id: entry.call_id.clone(),
approval_id: context.call_id.clone(),
environment_id: entry.environment_id.clone(),
process_id,
input: request.input.to_string(),
cwd: entry.cwd.clone(),
tty: entry.tty,
});
(Arc::clone(&entry.process), approval)
}; };
let _interaction_guard = locked_process.interaction_lock().lock_owned().await; let _interaction_guard = locked_process.interaction_lock().lock_owned().await;
if let Some(approval) = approval {
let strict_auto_review = context
.session
.active_turn_context_and_strict_auto_review()
.await
.is_some_and(|(_, _, strict)| strict);
let approval_context = ApprovalContext {
review_context: GuardianReviewContext::from(&context.step_context),
cancellation_token: Some(context.cancellation_token.clone()),
call_id: context.call_id.clone(),
tool_name: ToolName::plain("write_stdin"),
strict_auto_review,
approval_reason: Some("Send input to an existing escalated terminal. The cwd is its launch directory; the terminal's current directory and state may have changed.".to_string()),
retry_reason: None,
network_approval_context: None,
};
context
.session
.request_approval(approval, approval_context)
.await
.map_err(UnifiedExecError::StdinApproval)?;
}
// Revalidate the identity after approval: a removed process ID can be reused.
let PreparedProcessHandles { let PreparedProcessHandles {
process, process,
output, output,
@@ -1003,6 +1050,8 @@ impl UnifiedExecProcessManager {
command: &[String], command: &[String],
hook_command: String, hook_command: String,
cwd: PathUri, cwd: PathUri,
environment_id: String,
escalated: bool,
plugin_attribution: Option<PluginCommandAttribution>, plugin_attribution: Option<PluginCommandAttribution>,
started_at: Instant, started_at: Instant,
process_id: i32, process_id: i32,
@@ -1024,6 +1073,8 @@ impl UnifiedExecProcessManager {
initial_exec_command_active, initial_exec_command_active,
hook_command, hook_command,
tty, tty,
environment_id,
escalated,
network_approval, network_approval,
session: Arc::downgrade(&context.session), session: Arc::downgrade(&context.session),
last_used: started_at, last_used: started_at,
@@ -589,6 +589,8 @@ async fn pruning_does_not_evict_live_process_while_exited_process_is_finalizing(
initial_exec_command_active: Arc::new(AtomicBool::new(false)), initial_exec_command_active: Arc::new(AtomicBool::new(false)),
hook_command: format!("command-{process_id}"), hook_command: format!("command-{process_id}"),
tty: false, tty: false,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
escalated: false,
network_approval: None, network_approval: None,
session: std::sync::Weak::new(), session: std::sync::Weak::new(),
last_used: if is_exited { last_used: if is_exited {
@@ -887,7 +887,7 @@ async fn user_network_approval_once_session_and_denial_semantics() -> Result<()>
assert_eq!(approval.approval_id.as_deref(), None); assert_eq!(approval.approval_id.as_deref(), None);
let first_approval_call_id = approval.call_id.clone(); let first_approval_call_id = approval.call_id.clone();
assert!(!approval.turn_id.is_empty()); assert!(!approval.turn_id.is_empty());
assert_eq!(approval.cwd, test.config.cwd); assert_eq!(approval.cwd, test.config.cwd.clone().into());
assert_eq!( assert_eq!(
approval.reason.as_deref(), approval.reason.as_deref(),
Some("codex-network-test.invalid is not in the allowed_domains") Some("codex-network-test.invalid is not in the allowed_domains")
@@ -1387,7 +1387,7 @@ async fn unattributed_network_request_uses_active_turn_environment_fallback() ->
let proxy_request = tokio::spawn(raw_http_proxy_request(proxy_addr, NETWORK_TEST_HOST)); let proxy_request = tokio::spawn(raw_http_proxy_request(proxy_addr, NETWORK_TEST_HOST));
let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?;
assert_eq!(approval.command, ["network-access", NETWORK_TEST_TARGET]); assert_eq!(approval.command, ["network-access", NETWORK_TEST_TARGET]);
assert_eq!(approval.cwd, test.config.cwd); assert_eq!(approval.cwd, test.config.cwd.clone().into());
test.codex test.codex
.submit(Op::ExecApproval { .submit(Op::ExecApproval {
id: approval.effective_approval_id(), id: approval.effective_approval_id(),
+110 -15
View File
@@ -1,6 +1,10 @@
use codex_core::EnvironmentConfig; use codex_core::EnvironmentConfig;
use codex_core::TurnInputRequest; use codex_core::TurnInputRequest;
use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_features::Feature;
use codex_protocol::approvals::ExecApprovalKind;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use core_test_support::test_codex::local_selections; use core_test_support::test_codex::local_selections;
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsStr; use std::ffi::OsStr;
@@ -1347,8 +1351,12 @@ async fn wait_for_unified_exec_end(
(end_event, turn_completed) (end_event, turn_completed)
} }
#[test_case::test_case(false; "without_stdin_approval")]
#[test_case::test_case(true; "with_stdin_approval")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { async fn unified_exec_emits_terminal_interaction_for_write_stdin(
stdin_approval: bool,
) -> Result<()> {
// TODO(anp): Remove after unified-exec interactive fixtures support Windows/ConPTY. // TODO(anp): Remove after unified-exec interactive fixtures support Windows/ConPTY.
skip_if_target_windows!(Ok(()), "uses POSIX interactive-process and EOF semantics"); skip_if_target_windows!(Ok(()), "uses POSIX interactive-process and EOF semantics");
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
@@ -1356,15 +1364,25 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
let server = start_mock_server().await; let server = start_mock_server().await;
let mut builder = test_codex(); let mut builder = test_codex().with_config(move |config| {
if stdin_approval {
config
.features
.enable(Feature::WriteStdinApproval)
.expect("enable stdin approvals");
}
});
let test = builder.build_with_auto_env(&server).await?; let test = builder.build_with_auto_env(&server).await?;
let open_call_id = "uexec-open"; let open_call_id = "uexec-open";
let open_args = json!({ let mut open_args = json!({
"cmd": "/bin/bash -i", "cmd": "/bin/bash -i",
"yield_time_ms": 200, "yield_time_ms": 200,
"tty": true, "tty": true,
}); });
if stdin_approval {
open_args["sandbox_permissions"] = json!("require_escalated");
}
let stdin_call_id = "uexec-stdin-delta"; let stdin_call_id = "uexec-stdin-delta";
let stdin_args = json!({ let stdin_args = json!({
@@ -1400,13 +1418,43 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
]; ];
mount_sse_sequence(&server, responses).await; mount_sse_sequence(&server, responses).await;
submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?; if stdin_approval {
// Start without waiting for completion so the loop below can answer approvals.
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "stdin delta".to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_policy: Some(SandboxPolicy::ReadOnly {
network_access: false,
}),
..Default::default()
}),
)
.await?;
} else {
submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?;
}
let mut terminal_interaction = None; let mut terminal_interaction = None;
let mut approvals = Vec::new();
loop { loop {
let msg = wait_for_event(&test.codex, |_| true).await; let msg = wait_for_event(&test.codex, |_| true).await;
match msg { match msg {
EventMsg::ExecApprovalRequest(approval) => {
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: Some(approval.turn_id),
decision: ReviewDecision::Approved,
})
.await?;
approvals.push((approval.kind, approval.call_id, approval.approval_id));
}
EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => {
terminal_interaction = Some(ev); terminal_interaction = Some(ev);
} }
@@ -1415,6 +1463,21 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
} }
} }
assert_eq!(
approvals,
if stdin_approval {
vec![
(ExecApprovalKind::Command, open_call_id.to_string(), None),
(
ExecApprovalKind::WriteStdin,
open_call_id.to_string(),
Some(stdin_call_id.to_string()),
),
]
} else {
Vec::new()
}
);
let delta = terminal_interaction.expect("expected TerminalInteraction event"); let delta = terminal_interaction.expect("expected TerminalInteraction event");
assert_eq!(delta.process_id, "1000"); assert_eq!(delta.process_id, "1000");
let expected_stdin = stdin_args let expected_stdin = stdin_args
@@ -2370,7 +2433,12 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session(
let server = start_mock_server().await; let server = start_mock_server().await;
let mut builder = test_codex(); let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::WriteStdinApproval)
.expect("enable stdin approvals");
});
let test = builder.build_with_auto_env(&server).await?; let test = builder.build_with_auto_env(&server).await?;
let start_call_id = format!("uexec-non-tty-interrupt-{test_name}-start"); let start_call_id = format!("uexec-non-tty-interrupt-{test_name}-start");
@@ -2380,6 +2448,7 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session(
"cmd": command, "cmd": command,
"yield_time_ms": 250, "yield_time_ms": 250,
"tty": false, "tty": false,
"sandbox_permissions": "require_escalated",
}); });
let interrupt_args = serde_json::json!({ let interrupt_args = serde_json::json!({
"chars": "\u{3}", "chars": "\u{3}",
@@ -2413,17 +2482,43 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session(
]; ];
let request_log = mount_sse_sequence(&server, responses).await; let request_log = mount_sse_sequence(&server, responses).await;
submit_unified_exec_turn( test.codex
&test, .start_or_steer_turn(
"interrupt non-tty unified exec", TurnInputRequest::user_input(vec![UserInput::Text {
PermissionProfile::Disabled, text: "interrupt non-tty unified exec".to_string(),
) text_elements: Vec::new(),
.await?; }])
.with_thread_settings(ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_policy: Some(SandboxPolicy::ReadOnly {
network_access: false,
}),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| { let mut approval_count = 0;
matches!(event, EventMsg::TurnComplete(_)) loop {
}) match wait_for_event(&test.codex, |_| true).await {
.await; EventMsg::ExecApprovalRequest(approval) => {
approval_count += 1;
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: Some(approval.turn_id),
decision: ReviewDecision::Approved,
})
.await?;
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
assert_eq!(
approval_count, 1,
"only process launch should need approval"
);
let requests = request_log.requests(); let requests = request_log.requests();
assert!(!requests.is_empty(), "expected at least one POST request"); assert!(!requests.is_empty(), "expected at least one POST request");
@@ -5,6 +5,7 @@ use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::types::ApprovalsReviewer; use codex_config::types::ApprovalsReviewer;
use codex_core::TurnInputRequest; use codex_core::TurnInputRequest;
use codex_core::sandboxing::SandboxPermissions; use codex_core::sandboxing::SandboxPermissions;
use codex_features::Feature;
use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings; use codex_protocol::config_types::Settings;
@@ -40,9 +41,9 @@ use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::turn_permission_fields; use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event; use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout; use core_test_support::wait_for_event_with_timeout;
use core_test_support::zsh_fork::build_zsh_fork_test;
use core_test_support::zsh_fork::restrictive_workspace_write_profile; use core_test_support::zsh_fork::restrictive_workspace_write_profile;
use core_test_support::zsh_fork::zsh_fork_runtime; use core_test_support::zsh_fork::zsh_fork_runtime;
use core_test_support::zsh_fork::zsh_fork_test_builder;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use regex_lite::Regex; use regex_lite::Regex;
use serde_json::Value; use serde_json::Value;
@@ -517,9 +518,14 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t
ev_completed("resp-cross-turn-write"), ev_completed("resp-cross-turn-write"),
]), ]),
sse(vec![ sse(vec![
ev_response_created("resp-cross-turn-guardian"), ev_response_created("resp-cross-turn-write-guardian"),
ev_assistant_message("msg-cross-turn-guardian", r#"{"outcome":"allow"}"#), ev_assistant_message("msg-cross-turn-write-guardian", r#"{"outcome":"allow"}"#),
ev_completed("resp-cross-turn-guardian"), ev_completed("resp-cross-turn-write-guardian"),
]),
sse(vec![
ev_response_created("resp-cross-turn-execve-guardian"),
ev_assistant_message("msg-cross-turn-execve-guardian", r#"{"outcome":"allow"}"#),
ev_completed("resp-cross-turn-execve-guardian"),
]), ]),
sse(vec![ sse(vec![
ev_response_created("resp-cross-turn-second-done"), ev_response_created("resp-cross-turn-second-done"),
@@ -555,6 +561,7 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t
.await?; .await?;
let mut current_turn_id = None; let mut current_turn_id = None;
let mut stdin_assessment = None;
let mut intercepted_assessment = None; let mut intercepted_assessment = None;
loop { loop {
let event = tokio::time::timeout(Duration::from_secs(30), test.codex.next_event()) let event = tokio::time::timeout(Duration::from_secs(30), test.codex.next_event())
@@ -562,6 +569,16 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t
.context("timed out waiting for current-turn intercepted execve Guardian review")??; .context("timed out waiting for current-turn intercepted execve Guardian review")??;
match event.msg { match event.msg {
EventMsg::TurnStarted(started) => current_turn_id = Some(started.turn_id), EventMsg::TurnStarted(started) => current_turn_id = Some(started.turn_id),
EventMsg::GuardianAssessment(assessment)
if assessment.status == GuardianAssessmentStatus::Approved
&& matches!(
&assessment.action,
GuardianAssessmentAction::WriteStdin { approval_id, .. }
if approval_id == write_call_id
) =>
{
stdin_assessment = Some(assessment);
}
EventMsg::GuardianAssessment(assessment) EventMsg::GuardianAssessment(assessment)
if assessment.status == GuardianAssessmentStatus::Approved if assessment.status == GuardianAssessmentStatus::Approved
&& matches!(assessment.action, GuardianAssessmentAction::Execve { .. }) => && matches!(assessment.action, GuardianAssessmentAction::Execve { .. }) =>
@@ -578,6 +595,15 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t
let current_turn_id = current_turn_id.context("expected the second turn to start")?; let current_turn_id = current_turn_id.context("expected the second turn to start")?;
assert_ne!(current_turn_id, first_completion.turn_id); assert_ne!(current_turn_id, first_completion.turn_id);
let stdin_assessment =
stdin_assessment.context("expected an approved Guardian assessment for stdin")?;
assert_eq!(
(
stdin_assessment.turn_id.as_str(),
stdin_assessment.target_item_id.as_deref(),
),
(current_turn_id.as_str(), Some(open_call_id)),
);
let assessment = intercepted_assessment let assessment = intercepted_assessment
.context("expected an approved Guardian assessment for the persistent terminal")?; .context("expected an approved Guardian assessment for the persistent terminal")?;
assert_eq!(assessment.turn_id, current_turn_id); assert_eq!(assessment.turn_id, current_turn_id);
@@ -594,8 +620,13 @@ async fn unified_exec_zsh_fork_guardian_reviews_persistent_terminal_in_current_t
request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian")
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(guardian_requests.len(), 1); assert_eq!(guardian_requests.len(), 2);
assert!(guardian_requests[0].body_contains_text(&outside_path.to_string_lossy())); assert!(guardian_requests[0].body_contains_text(&outside_path.to_string_lossy()));
let environment_id = &test.executor_environment().selection().environment_id;
let environment = format!(r#""environment_id": "{environment_id}""#);
assert!(guardian_requests[0].body_contains_text(&environment));
assert!(guardian_requests[0].body_contains_text("The `cwd` field is its launch directory"));
assert!(guardian_requests[1].body_contains_text(&outside_path.to_string_lossy()));
Ok(()) Ok(())
} }
@@ -619,14 +650,17 @@ where
}; };
let server = start_mock_server().await; let server = start_mock_server().await;
let test = build_zsh_fork_test( let test = zsh_fork_test_builder(runtime, approval_policy)
&server, .with_pre_build_hook(pre_build_hook)
runtime, .with_config(move |config| {
approval_policy, config
permission_profile, .permissions
pre_build_hook, .set_permission_profile(permission_profile)
) .expect("set permission profile");
.await?; assert!(config.features.enable(Feature::WriteStdinApproval).is_ok());
})
.build(&server)
.await?;
Ok(Some((server, test))) Ok(Some((server, test)))
} }
+8
View File
@@ -137,6 +137,8 @@ pub enum Feature {
ApplyPatchPreserveLineEndings, ApplyPatchPreserveLineEndings,
/// Allow exec tools to request additional permissions while staying sandboxed. /// Allow exec tools to request additional permissions while staying sandboxed.
ExecPermissionApprovals, ExecPermissionApprovals,
/// Require approval before writing input to escalated unified-exec terminals.
WriteStdinApproval,
/// Expose the built-in request_permissions tool. /// Expose the built-in request_permissions tool.
RequestPermissionsTool, RequestPermissionsTool,
/// Allow the model to request web searches that fetch live content. /// Allow the model to request web searches that fetch live content.
@@ -1079,6 +1081,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment, stage: Stage::UnderDevelopment,
default_enabled: false, default_enabled: false,
}, },
FeatureSpec {
id: Feature::WriteStdinApproval,
key: "write_stdin_approval",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec { FeatureSpec {
id: Feature::CodexHooks, id: Feature::CodexHooks,
key: "hooks", key: "hooks",
+1 -1
View File
@@ -217,7 +217,7 @@ async fn run_codex_tool_session_inner(
} = ev; } = ev;
handle_exec_approval_request( handle_exec_approval_request(
command, command,
cwd.to_path_buf(), std::path::PathBuf::from(cwd.into_string()),
outgoing.clone(), outgoing.clone(),
thread.clone(), thread.clone(),
request_id.clone(), request_id.clone(),
+4 -2
View File
@@ -6,6 +6,7 @@ use crate::protocol::FileChange;
use crate::protocol::ReviewDecision; use crate::protocol::ReviewDecision;
use crate::request_permissions::RequestPermissionProfile; use crate::request_permissions::RequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
@@ -152,6 +153,7 @@ pub enum GuardianAssessmentAction {
approval_id: String, approval_id: String,
process_id: String, process_id: String,
stdin: String, stdin: String,
/// Launch directory of the existing terminal, not its current working directory.
cwd: PathUri, cwd: PathUri,
}, },
ApplyPatch { ApplyPatch {
@@ -279,8 +281,8 @@ pub struct ExecApprovalRequestEvent {
pub started_at_ms: i64, pub started_at_ms: i64,
/// The command to be executed. /// The command to be executed.
pub command: Vec<String>, pub command: Vec<String>,
/// The command's working directory. /// The command's working directory, or the launch directory for terminal input.
pub cwd: AbsolutePathBuf, pub cwd: LegacyAppPathString,
/// Optional human-readable reason for the approval (e.g. retry without sandbox). /// Optional human-readable reason for the approval (e.g. retry without sandbox).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>, pub reason: Option<String>,
+1
View File
@@ -220,6 +220,7 @@ impl App {
let proposed_network_policy_amendments = let proposed_network_policy_amendments =
params.proposed_network_policy_amendments.clone(); params.proposed_network_policy_amendments.clone();
let approval = ExecApprovalRequest { let approval = ExecApprovalRequest {
kind: params.kind,
thread_id, thread_id,
thread_label, thread_label,
id: params id: params
+3
View File
@@ -11,6 +11,7 @@ use std::path::PathBuf;
use crate::diff_model::FileChange; use crate::diff_model::FileChange;
use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionApprovalKind;
use codex_app_server_protocol::ExecPolicyAmendment; use codex_app_server_protocol::ExecPolicyAmendment;
use codex_app_server_protocol::NetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalContext;
use codex_app_server_protocol::NetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyAmendment;
@@ -21,6 +22,8 @@ use serde::Serialize;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ExecApprovalRequestEvent { pub(crate) struct ExecApprovalRequestEvent {
#[serde(default)]
pub(crate) kind: CommandExecutionApprovalKind,
pub(crate) call_id: String, pub(crate) call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) approval_id: Option<String>, pub(crate) approval_id: Option<String>,
@@ -40,6 +40,7 @@ use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionApprovalKind;
use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileSystemAccessMode; use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath; use codex_app_server_protocol::FileSystemPath;
@@ -79,6 +80,7 @@ pub(crate) enum ApprovalRequest {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ExecApprovalRequest { pub(crate) struct ExecApprovalRequest {
pub kind: CommandExecutionApprovalKind,
pub thread_id: ThreadId, pub thread_id: ThreadId,
pub thread_label: Option<String>, pub thread_label: Option<String>,
pub id: String, pub id: String,
@@ -250,23 +252,35 @@ impl ApprovalOverlay {
list_keymap: &ListKeymap, list_keymap: &ListKeymap,
) -> (Vec<ApprovalOption>, SelectionViewParams) { ) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match request { let (options, title) = match request {
ApprovalRequest::Exec(request) => ( ApprovalRequest::Exec(request) => {
exec_options( let title = if request.kind == CommandExecutionApprovalKind::WriteStdin {
&request.available_decisions, request.command.get(2).map_or_else(
request.network_approval_context.as_ref(), || "Would you like to send input to the existing terminal?".to_string(),
request.additional_permissions.as_ref(), |process_id| {
approval_keymap, format!("Would you like to send input to terminal {process_id}?")
), },
request.network_approval_context.as_ref().map_or_else( )
|| "Would you like to run the following command?".to_string(), } else {
|network_approval_context| { request.network_approval_context.as_ref().map_or_else(
format!( || "Would you like to run the following command?".to_string(),
"Do you want to approve network access to \"{}\"?", |network_approval_context| {
network_approval_context.host 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(_) => ( ApprovalRequest::Permissions(_) => (
permissions_options(approval_keymap), permissions_options(approval_keymap),
"Would you like to grant these permissions?".to_string(), "Would you like to grant these permissions?".to_string(),
@@ -704,13 +718,18 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
])); ]));
header.push(Line::from("")); header.push(Line::from(""));
} }
let full_cmd = strip_bash_lc_and_escape(&request.command); if request.kind == CommandExecutionApprovalKind::WriteStdin {
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); let input = request.command.last().map_or("", String::as_str);
if let Some(first) = full_cmd_lines.first_mut() { header.push(vec!["Input: ".into(), format!("{input:?}").into()].into());
first.spans.insert(0, Span::from("$ ")); } else {
} let full_cmd = strip_bash_lc_and_escape(&request.command);
if request.network_approval_context.is_none() { let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
header.extend(full_cmd_lines); 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(Paragraph::new(header).wrap(Wrap { trim: false })) Box::new(Paragraph::new(header).wrap(Wrap { trim: false }))
} }
@@ -1187,6 +1206,7 @@ mod tests {
fn make_exec_request() -> ApprovalRequest { fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".to_string(), id: "test".to_string(),
@@ -1380,6 +1400,7 @@ mod tests {
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".to_string(), id: "test".to_string(),
@@ -1424,6 +1445,7 @@ mod tests {
}; };
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".to_string(), id: "test".to_string(),
@@ -1509,6 +1531,7 @@ mod tests {
let thread_id = ThreadId::new(); let thread_id = ThreadId::new();
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id, thread_id,
thread_label: Some("Robie [explorer]".to_string()), thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(), id: "test".to_string(),
@@ -1544,6 +1567,7 @@ mod tests {
keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))]; keymap.approval.open_thread = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap( let mut view = make_overlay_with_keymap(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id, thread_id,
thread_label: Some("Robie [explorer]".to_string()), thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(), id: "test".to_string(),
@@ -1583,6 +1607,7 @@ mod tests {
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let view = make_overlay( let view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: Some("Robie [explorer]".to_string()), thread_label: Some("Robie [explorer]".to_string()),
id: "test".to_string(), id: "test".to_string(),
@@ -1612,6 +1637,7 @@ mod tests {
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".to_string(), id: "test".to_string(),
@@ -1665,6 +1691,7 @@ mod tests {
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".to_string(), id: "test".to_string(),
@@ -1705,6 +1732,7 @@ mod tests {
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let command = vec!["echo".into(), "hello".into(), "world".into()]; let command = vec!["echo".into(), "hello".into(), "world".into()];
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".into(), id: "test".into(),
@@ -2006,6 +2034,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".into(), id: "test".into(),
@@ -2063,6 +2092,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".into(), id: "test".into(),
@@ -2188,6 +2218,7 @@ mod tests {
let (tx, _rx) = unbounded_channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx); let tx = AppEventSender::new(tx);
let exec_request = ApprovalRequest::Exec(ExecApprovalRequest { let exec_request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".into(), id: "test".into(),
@@ -2325,6 +2356,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw); let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay( let mut view = make_overlay(
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: ThreadId::new(), thread_id: ThreadId::new(),
thread_label: None, thread_label: None,
id: "test".into(), id: "test".into(),
+1
View File
@@ -2011,6 +2011,7 @@ mod tests {
fn exec_request() -> ApprovalRequest { fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec(ExecApprovalRequest { ApprovalRequest::Exec(ExecApprovalRequest {
kind: Default::default(),
thread_id: codex_protocol::ThreadId::new(), thread_id: codex_protocol::ThreadId::new(),
thread_label: None, thread_label: None,
id: "1".to_string(), id: "1".to_string(),
+1
View File
@@ -869,6 +869,7 @@ fn exec_approval_request_from_params(
.and_then(|cwd| cwd.to_inferred_abs_path()) .and_then(|cwd| cwd.to_inferred_abs_path())
.unwrap_or_else(|| fallback_cwd.clone()); .unwrap_or_else(|| fallback_cwd.clone());
ExecApprovalRequestEvent { ExecApprovalRequestEvent {
kind: params.kind,
call_id: params.item_id, call_id: params.item_id,
command: params command: params
.command .command
@@ -176,6 +176,7 @@ mod tests {
fn exec_approval(call_id: &str, approval_id: Option<&str>) -> ExecApprovalRequestEvent { fn exec_approval(call_id: &str, approval_id: Option<&str>) -> ExecApprovalRequestEvent {
ExecApprovalRequestEvent { ExecApprovalRequestEvent {
kind: Default::default(),
call_id: call_id.to_string(), call_id: call_id.to_string(),
approval_id: approval_id.map(str::to_string), approval_id: approval_id.map(str::to_string),
turn_id: "turn".to_string(), turn_id: "turn".to_string(),
@@ -10,6 +10,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
// Trigger an exec approval request with a short, single-line command. // Trigger an exec approval request with a short, single-line command.
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-short".into(), call_id: "call-short".into(),
approval_id: Some("call-short".into()), approval_id: Some("call-short".into()),
turn_id: "turn-short".into(), turn_id: "turn-short".into(),
@@ -86,6 +87,42 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
); );
} }
#[tokio::test]
async fn app_server_write_stdin_approval_renders_terminal_input() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let event = exec_approval_request_from_params(
AppServerCommandExecutionRequestApprovalParams {
kind: codex_app_server_protocol::CommandExecutionApprovalKind::WriteStdin,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
environment_id: None,
reason: None,
network_approval_context: None,
command: Some(
shlex::try_join(["write_stdin", "--session-id", "42", "confirm\n"])
.expect("round-trippable write_stdin input"),
),
cwd: Some(test_path_buf("/tmp").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
&test_path_buf("/tmp").abs(),
);
handle_exec_approval_request(&mut chat, "approval-1", event);
let area = Rect::new(0, 0, 80, chat.desired_height(/*width*/ 80));
let mut buf = ratatui::buffer::Buffer::empty(area);
chat.render(area, &mut buf);
assert_snapshot!("write_stdin_approval_modal", format!("{buf:?}"));
}
#[test] #[test]
fn app_server_exec_approval_request_preserves_permissions_context() { fn app_server_exec_approval_request_preserves_permissions_context() {
let read_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/read-only"))) let read_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/read-only")))
@@ -338,6 +375,7 @@ async fn exec_approval_uses_approval_id_when_present() {
&mut chat, &mut chat,
"sub-short", "sub-short",
ExecApprovalRequestEvent { ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-parent".into(), call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()), approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(), turn_id: "turn-short".into(),
@@ -381,6 +419,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let ev_multi = ExecApprovalRequestEvent { let ev_multi = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-multi".into(), call_id: "call-multi".into(),
approval_id: Some("call-multi".into()), approval_id: Some("call-multi".into()),
turn_id: "turn-multi".into(), turn_id: "turn-multi".into(),
@@ -433,6 +472,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
let long = format!("echo {}", "a".repeat(200)); let long = format!("echo {}", "a".repeat(200));
let ev_long = ExecApprovalRequestEvent { let ev_long = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-long".into(), call_id: "call-long".into(),
approval_id: Some("call-long".into()), approval_id: Some("call-long".into()),
turn_id: "turn-long".into(), turn_id: "turn-long".into(),
@@ -430,6 +430,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
// Trigger an exec approval request with a short, single-line command // Trigger an exec approval request with a short, single-line command
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-short".into(), call_id: "call-short".into(),
approval_id: Some("call-short".into()), approval_id: Some("call-short".into()),
turn_id: "turn-short".into(), turn_id: "turn-short".into(),
@@ -522,6 +523,7 @@ async fn exec_approval_uses_approval_id_when_present() {
&mut chat, &mut chat,
"sub-short", "sub-short",
ExecApprovalRequestEvent { ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-parent".into(), call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()), approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(), turn_id: "turn-short".into(),
@@ -566,6 +568,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
// Multiline command: modal should show full command, history records decision only // Multiline command: modal should show full command, history records decision only
let ev_multi = ExecApprovalRequestEvent { let ev_multi = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-multi".into(), call_id: "call-multi".into(),
approval_id: Some("call-multi".into()), approval_id: Some("call-multi".into()),
turn_id: "turn-multi".into(), turn_id: "turn-multi".into(),
@@ -620,6 +623,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
// Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ...
let long = format!("echo {}", "a".repeat(200)); let long = format!("echo {}", "a".repeat(200));
let ev_long = ExecApprovalRequestEvent { let ev_long = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-long".into(), call_id: "call-long".into(),
approval_id: Some("call-long".into()), approval_id: Some("call-long".into()),
turn_id: "turn-long".into(), turn_id: "turn-long".into(),
@@ -1552,6 +1556,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
.set(AskForApproval::OnRequest.to_core())?; .set(AskForApproval::OnRequest.to_core())?;
// Inject an exec approval request to display the approval modal. // Inject an exec approval request to display the approval modal.
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-approve-cmd".into(), call_id: "call-approve-cmd".into(),
approval_id: Some("call-approve-cmd".into()), approval_id: Some("call-approve-cmd".into()),
turn_id: "turn-approve-cmd".into(), turn_id: "turn-approve-cmd".into(),
@@ -1610,6 +1615,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
.set(AskForApproval::OnRequest.to_core())?; .set(AskForApproval::OnRequest.to_core())?;
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-approve-cmd-noreason".into(), call_id: "call-approve-cmd-noreason".into(),
approval_id: Some("call-approve-cmd-noreason".into()), approval_id: Some("call-approve-cmd-noreason".into()),
turn_id: "turn-approve-cmd-noreason".into(), turn_id: "turn-approve-cmd-noreason".into(),
@@ -1656,6 +1662,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
let command = vec!["bash".into(), "-lc".into(), script]; let command = vec!["bash".into(), "-lc".into(), script];
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-approve-cmd-multiline-trunc".into(), call_id: "call-approve-cmd-multiline-trunc".into(),
approval_id: Some("call-approve-cmd-multiline-trunc".into()), approval_id: Some("call-approve-cmd-multiline-trunc".into()),
turn_id: "turn-approve-cmd-multiline-trunc".into(), turn_id: "turn-approve-cmd-multiline-trunc".into(),
@@ -0,0 +1,30 @@
---
source: tui/src/chatwidget/tests/approval_requests.rs
assertion_line: 123
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 10 },
content: [
" ",
" ",
" Would you like to send input to terminal 42? ",
" ",
" Input: "confirm\n" ",
" ",
"› 1. Yes, proceed (y) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
" Press enter to confirm or esc to cancel ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -2640,6 +2640,7 @@ async fn status_widget_and_approval_modal_snapshot() {
// Now show an approval modal (e.g. exec approval). // Now show an approval modal (e.g. exec approval).
let ev = ExecApprovalRequestEvent { let ev = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-approve-exec".into(), call_id: "call-approve-exec".into(),
approval_id: Some("call-approve-exec".into()), approval_id: Some("call-approve-exec".into()),
turn_id: "turn-approve-exec".into(), turn_id: "turn-approve-exec".into(),
@@ -81,6 +81,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
); );
let request = ExecApprovalRequestEvent { let request = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-action-required".into(), call_id: "call-action-required".into(),
approval_id: Some("call-action-required".into()), approval_id: Some("call-action-required".into()),
turn_id: "turn-action-required".into(), turn_id: "turn-action-required".into(),
@@ -146,6 +147,7 @@ async fn terminal_title_action_required_respects_spinner_setting() {
chat.refresh_terminal_title(); chat.refresh_terminal_title();
let request = ExecApprovalRequestEvent { let request = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-no-spinner".into(), call_id: "call-no-spinner".into(),
approval_id: Some("call-no-spinner".into()), approval_id: Some("call-no-spinner".into()),
turn_id: "turn-no-spinner".into(), turn_id: "turn-no-spinner".into(),
@@ -174,6 +176,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
chat.refresh_terminal_title(); chat.refresh_terminal_title();
let request = ExecApprovalRequestEvent { let request = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-blink".into(), call_id: "call-blink".into(),
approval_id: Some("call-blink".into()), approval_id: Some("call-blink".into()),
turn_id: "turn-blink".into(), turn_id: "turn-blink".into(),
@@ -212,6 +215,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d
assert!(!chat.should_animate_terminal_title_spinner()); assert!(!chat.should_animate_terminal_title_spinner());
let request = ExecApprovalRequestEvent { let request = ExecApprovalRequestEvent {
kind: Default::default(),
call_id: "call-no-animations".into(), call_id: "call-no-animations".into(),
approval_id: Some("call-no-animations".into()), approval_id: Some("call-no-animations".into()),
turn_id: "turn-no-animations".into(), turn_id: "turn-no-animations".into(),
@@ -289,6 +289,7 @@ impl ChatWidget {
let available_decisions = ev.effective_available_decisions(); let available_decisions = ev.effective_available_decisions();
let request = ApprovalRequest::Exec(ExecApprovalRequest { let request = ApprovalRequest::Exec(ExecApprovalRequest {
kind: ev.kind,
thread_id: self.thread_id.unwrap_or_default(), thread_id: self.thread_id.unwrap_or_default(),
thread_label: None, thread_label: None,
id: ev.effective_approval_id(), id: ev.effective_approval_id(),