diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 749f6de23b..92786d0407 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -161,6 +161,41 @@ "description": "Tool settings for a single app.", "type": "object" }, + "ApprovalHandlerOnError": { + "enum": [ + "fallback", + "deny" + ], + "type": "string" + }, + "ApprovalHandlerToml": { + "additionalProperties": false, + "properties": { + "command": { + "description": "Command argv used to resolve approval requests over stdin/stdout.", + "items": { + "type": "string" + }, + "type": "array" + }, + "on_error": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalHandlerOnError" + } + ], + "default": null, + "description": "Behavior when the handler exits non-zero, times out, or returns invalid JSON." + }, + "timeout_ms": { + "description": "Timeout for the handler process, in milliseconds.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" @@ -1599,6 +1634,15 @@ ], "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`." }, + "approval_handler": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalHandlerToml" + } + ], + "default": null, + "description": "Optional external command to synchronously resolve approvals over stdin/stdout." + }, "approval_policy": { "allOf": [ { diff --git a/codex-rs/core/src/approval_handler.rs b/codex-rs/core/src/approval_handler.rs new file mode 100644 index 0000000000..ca98da1f28 --- /dev/null +++ b/codex-rs/core/src/approval_handler.rs @@ -0,0 +1,456 @@ +use std::process::Stdio; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_hooks::command_from_argv; +use codex_protocol::ThreadId; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use serde::Serialize; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tracing::info; + +use crate::config::types::ApprovalHandlerConfig; + +#[derive(Debug, Serialize)] +struct ApprovalCommandRequest<'a, T> { + thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_label: Option<&'a str>, + #[serde(flatten)] + event: &'a T, +} + +#[derive(Debug)] +struct CommandOutput { + stdout: Vec, + stderr: Vec, +} + +pub(crate) async fn request_exec_approval( + config: &ApprovalHandlerConfig, + thread_id: ThreadId, + thread_label: Option<&str>, + event: &ExecApprovalRequestEvent, +) -> Result { + let request = ApprovalCommandRequest { + thread_id: thread_id.to_string(), + thread_label, + event, + }; + let op = invoke_approval_handler(config, &request).await?; + match op { + Op::ExecApproval { + id, + turn_id, + decision, + } => { + let expected_id = event.effective_approval_id(); + if id != expected_id { + return Err(anyhow!( + "approval handler returned exec approval for unexpected id `{id}`; expected `{expected_id}`" + )); + } + if let Some(turn_id) = turn_id + && turn_id != event.turn_id + { + return Err(anyhow!( + "approval handler returned exec approval for unexpected turn_id `{turn_id}`; expected `{}`", + event.turn_id + )); + } + Ok(decision) + } + other => Err(anyhow!( + "approval handler returned wrong op for exec approval request: {other:?}" + )), + } +} + +pub(crate) fn fallback_warning_message(dialog_kind: &str, err: &anyhow::Error) -> String { + format!( + "{EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX}: {dialog_kind} approval dialog failed; falling back to the built-in prompt. {err:#}" + ) +} + +pub(crate) fn deny_warning_message( + dialog_kind: &str, + deny_verb: &str, + err: &anyhow::Error, +) -> String { + format!( + "{EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX}: {dialog_kind} approval dialog failed; {deny_verb} the request. {err:#}" + ) +} + +pub(crate) async fn request_patch_approval( + config: &ApprovalHandlerConfig, + thread_id: ThreadId, + thread_label: Option<&str>, + event: &ApplyPatchApprovalRequestEvent, +) -> Result { + let request = ApprovalCommandRequest { + thread_id: thread_id.to_string(), + thread_label, + event, + }; + let op = invoke_approval_handler(config, &request).await?; + match op { + Op::PatchApproval { id, decision } => { + if id != event.call_id { + return Err(anyhow!( + "approval handler returned patch approval for unexpected id `{id}`; expected `{}`", + event.call_id + )); + } + Ok(decision) + } + other => Err(anyhow!( + "approval handler returned wrong op for patch approval request: {other:?}" + )), + } +} + +pub(crate) async fn request_elicitation_approval( + config: &ApprovalHandlerConfig, + thread_id: ThreadId, + thread_label: Option<&str>, + event: &ElicitationRequestEvent, +) -> Result { + let request = ApprovalCommandRequest { + thread_id: thread_id.to_string(), + thread_label, + event, + }; + let op = invoke_approval_handler(config, &request).await?; + match op { + Op::ResolveElicitation { + server_name, + request_id, + decision, + } => { + if server_name != event.server_name { + return Err(anyhow!( + "approval handler returned elicitation approval for unexpected server `{server_name}`; expected `{}`", + event.server_name + )); + } + if request_id != event.id { + return Err(anyhow!( + "approval handler returned elicitation approval for unexpected request_id `{request_id}`; expected `{}`", + event.id + )); + } + Ok(decision) + } + other => Err(anyhow!( + "approval handler returned wrong op for elicitation request: {other:?}" + )), + } +} + +async fn invoke_approval_handler( + config: &ApprovalHandlerConfig, + request: &T, +) -> Result { + let mut command = command_from_argv(&config.command) + .ok_or_else(|| anyhow!("approval_handler.command must not be empty"))?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let input = serde_json::to_vec(request).context("failed to serialize approval request")?; + let output = run_command(command, input, Duration::from_millis(config.timeout_ms)).await?; + if output.stdout.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "approval handler returned no stdout{}", + format_stderr_suffix(&stderr) + )); + } + + serde_json::from_slice::(&output.stdout).with_context(|| { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!( + "failed to parse approval handler stdout as Op; stdout=`{}`{}", + stdout.trim(), + format_stderr_suffix(&stderr) + ) + }) +} + +async fn run_command( + mut command: tokio::process::Command, + input: Vec, + timeout: Duration, +) -> Result { + let start = Instant::now(); + let mut child = command + .spawn() + .context("failed to spawn approval handler")?; + let child_id = child.id(); + info!( + "approval handler: spawned pid={child_id:?} input_bytes={} timeout_ms={}", + input.len(), + timeout.as_millis() + ); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("approval handler stdin was not piped"))?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("approval handler stdout was not piped"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("approval handler stderr was not piped"))?; + + stdin + .write_all(&input) + .await + .context("failed to write approval request to handler stdin")?; + info!( + "approval handler: stdin write complete pid={child_id:?} elapsed_ms={}", + start.elapsed().as_millis() + ); + stdin + .shutdown() + .await + .context("failed to close approval handler stdin")?; + info!( + "approval handler: stdin shutdown complete pid={child_id:?} elapsed_ms={}", + start.elapsed().as_millis() + ); + drop(stdin); + info!( + "approval handler: stdin dropped pid={child_id:?} elapsed_ms={}", + start.elapsed().as_millis() + ); + + let stdout_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).await.map(|_| bytes) + }); + let stderr_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).await.map(|_| bytes) + }); + + info!( + "approval handler: waiting for child pid={child_id:?} elapsed_ms={}", + start.elapsed().as_millis() + ); + let status = match tokio::time::timeout(timeout, child.wait()).await { + Ok(status) => { + let status = status.context("failed to wait for approval handler")?; + info!( + "approval handler: child exited pid={child_id:?} status={status} elapsed_ms={}", + start.elapsed().as_millis() + ); + status + } + Err(_) => { + info!( + "approval handler: timeout waiting for child pid={child_id:?} elapsed_ms={}", + start.elapsed().as_millis() + ); + let _ = child.kill().await; + let _ = child.wait().await; + return Err(anyhow!( + "approval handler timed out after {} ms", + timeout.as_millis() + )); + } + }; + + let stdout = stdout_task + .await + .context("approval handler stdout task join failed")? + .context("failed to read approval handler stdout")?; + let stderr = stderr_task + .await + .context("approval handler stderr task join failed")? + .context("failed to read approval handler stderr")?; + + if !status.success() { + let stderr_text = String::from_utf8_lossy(&stderr); + return Err(anyhow!( + "approval handler exited with status {status}{}", + format_stderr_suffix(&stderr_text) + )); + } + + Ok(CommandOutput { stdout, stderr }) +} + +fn format_stderr_suffix(stderr: &str) -> String { + let trimmed = stderr.trim(); + if trimmed.is_empty() { + String::new() + } else { + format!("; stderr=`{trimmed}`") + } +} + +#[cfg(test)] +mod tests { + use codex_protocol::approvals::NetworkPolicyAmendment; + use codex_protocol::mcp::RequestId; + use codex_protocol::protocol::ExecPolicyAmendment; + use codex_protocol::protocol::NetworkPolicyRuleAction; + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn exec_validation_accepts_matching_op() { + let event = ExecApprovalRequestEvent { + call_id: "call-1".to_string(), + approval_id: Some("approval-1".to_string()), + turn_id: "turn-1".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: std::env::temp_dir(), + reason: None, + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + parsed_cmd: Vec::new(), + }; + + let op = Op::ExecApproval { + id: "approval-1".to_string(), + turn_id: Some("turn-1".to_string()), + decision: ReviewDecision::Approved, + }; + + let decision = match op { + Op::ExecApproval { + id, + turn_id, + decision, + } => { + assert_eq!(id, event.effective_approval_id()); + assert_eq!(turn_id, Some(event.turn_id.clone())); + decision + } + _ => unreachable!(), + }; + + assert_eq!(decision, ReviewDecision::Approved); + } + + #[test] + fn stderr_suffix_omits_empty_values() { + assert_eq!(format_stderr_suffix(""), ""); + assert_eq!(format_stderr_suffix(" \n"), ""); + assert_eq!(format_stderr_suffix("oops\n"), "; stderr=`oops`"); + } + + #[test] + fn approval_command_request_omits_thread_label_when_absent() { + let event = ExecApprovalRequestEvent { + call_id: "call-123".to_string(), + approval_id: Some("approval-123".to_string()), + turn_id: "turn-123".to_string(), + command: vec!["echo".to_string(), "hello".to_string()], + cwd: "/tmp".into(), + reason: Some("because".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment { + command: vec!["echo".to_string()], + }), + proposed_network_policy_amendments: Some(vec![NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, + }]), + additional_permissions: None, + available_decisions: Some(vec![ReviewDecision::Approved]), + parsed_cmd: Vec::new(), + }; + let request = ApprovalCommandRequest { + thread_id: "thread-123".to_string(), + thread_label: None, + event: &event, + }; + + let value = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(value["thread_id"], json!("thread-123")); + assert!(value.get("thread_label").is_none()); + assert_eq!(value["call_id"], json!("call-123")); + assert_eq!(value["turn_id"], json!("turn-123")); + assert_eq!(value["command"], json!(["echo", "hello"])); + assert_eq!(value["reason"], json!("because")); + } + + #[test] + fn approval_command_request_includes_thread_label_when_present() { + let event = ElicitationRequestEvent { + server_name: "server".to_string(), + id: RequestId::Integer(7), + message: "need info".to_string(), + }; + let request = ApprovalCommandRequest { + thread_id: "thread-456".to_string(), + thread_label: Some("Scout [worker]"), + event: &event, + }; + + let value = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(value["thread_id"], json!("thread-456")); + assert_eq!(value["thread_label"], json!("Scout [worker]")); + assert_eq!(value["server_name"], json!("server")); + assert_eq!(value["id"], json!(7)); + assert_eq!(value["message"], json!("need info")); + } + + #[test] + fn elicitation_request_id_equality_matches_both_variants() { + assert_eq!( + RequestId::String("abc".to_string()), + RequestId::String("abc".to_string()) + ); + assert_eq!(RequestId::Integer(7), RequestId::Integer(7)); + } + + #[test] + fn fallback_warning_message_uses_red_warning_prefix() { + let err = anyhow!("approval handler timed out after 1000 ms"); + + let message = fallback_warning_message("exec", &err); + + assert_eq!( + message, + "External approval handler failed: exec approval dialog failed; falling back to the built-in prompt. approval handler timed out after 1000 ms" + ); + } + + #[test] + fn deny_warning_message_uses_requested_verb() { + let err = anyhow!("approval handler exited with status 1"); + + let message = deny_warning_message("elicitation", "declining", &err); + + assert_eq!( + message, + "External approval handler failed: elicitation approval dialog failed; declining the request. approval handler exited with status 1" + ); + } +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 87aac73185..09c901f3c1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -16,6 +16,7 @@ use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; use crate::analytics_client::InvocationType; use crate::analytics_client::build_track_events_context; +use crate::approval_handler; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; @@ -153,6 +154,7 @@ use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; +use crate::config::types::ApprovalHandlerOnError; use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; @@ -1495,6 +1497,9 @@ impl Session { // setup is straightforward enough and performs well. mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, + config.approval_handler.clone(), + conversation_id, + Some(session_configuration.session_source.default_thread_label()), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( @@ -1527,6 +1532,7 @@ impl Session { network_proxy, network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), + approval_handler: config.approval_handler.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, @@ -1615,6 +1621,9 @@ impl Session { config.mcp_oauth_credentials_store_mode, auth_statuses.clone(), &session_configuration.approval_policy, + sess.services.approval_handler.clone(), + sess.conversation_id, + Some(session_configuration.session_source.default_thread_label()), tx_event.clone(), sandbox_state, config.codex_home.clone(), @@ -2694,25 +2703,6 @@ impl Session { additional_permissions: Option, available_decisions: Option>, ) -> ReviewDecision { - // command-level approvals use `call_id`. - // `approval_id` is only present for subcommand callbacks (execve intercept) - 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. - let (tx_approve, rx_approve) = oneshot::channel(); - let prev_entry = { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.insert_pending_approval(effective_approval_id.clone(), tx_approve) - } - None => None, - } - }; - if prev_entry.is_some() { - warn!("Overwriting existing pending approval for call_id: {effective_approval_id}"); - } - let parsed_cmd = parse_command(&command); let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| { vec![ @@ -2734,7 +2724,7 @@ impl Session { additional_permissions.as_ref(), ) }); - let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + let request = ExecApprovalRequestEvent { call_id, approval_id, turn_id: turn_context.sub_id.clone(), @@ -2747,7 +2737,64 @@ impl Session { additional_permissions, available_decisions: Some(available_decisions), parsed_cmd, - }); + }; + + if let Some(config) = self.services.approval_handler.as_ref() { + let thread_label = turn_context.session_source.default_thread_label(); + match approval_handler::request_exec_approval( + config, + self.conversation_id, + Some(&thread_label), + &request, + ) + .await + { + Ok(decision) => return decision, + Err(err) => match config.on_error { + ApprovalHandlerOnError::Fallback => { + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: approval_handler::fallback_warning_message("exec", &err), + }), + ) + .await; + warn!("external exec approval handler failed; falling back: {err:#}"); + } + ApprovalHandlerOnError::Deny => { + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: approval_handler::deny_warning_message( + "exec", "denying", &err, + ), + }), + ) + .await; + warn!("external exec approval handler failed; denying request: {err:#}"); + return ReviewDecision::Denied; + } + }, + } + } + + let effective_approval_id = request.effective_approval_id(); + let (tx_approve, rx_approve) = oneshot::channel(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval(effective_approval_id.clone(), tx_approve) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending approval for call_id: {effective_approval_id}"); + } + + let event = EventMsg::ExecApprovalRequest(request); self.send_event(turn_context, event).await; rx_approve.await.unwrap_or_default() } @@ -2760,9 +2807,61 @@ impl Session { reason: Option, grant_root: Option, ) -> oneshot::Receiver { - // Add the tx_approve callback to the map before sending the request. + let request = ApplyPatchApprovalRequestEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + changes, + reason, + grant_root, + }; + + if let Some(config) = self.services.approval_handler.as_ref() { + let thread_label = turn_context.session_source.default_thread_label(); + match approval_handler::request_patch_approval( + config, + self.conversation_id, + Some(&thread_label), + &request, + ) + .await + { + Ok(decision) => { + let (tx_approve, rx_approve) = oneshot::channel(); + let _ = tx_approve.send(decision); + return rx_approve; + } + Err(err) => match config.on_error { + ApprovalHandlerOnError::Fallback => { + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: approval_handler::fallback_warning_message("patch", &err), + }), + ) + .await; + warn!("external patch approval handler failed; falling back: {err:#}"); + } + ApprovalHandlerOnError::Deny => { + self.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: approval_handler::deny_warning_message( + "patch", "denying", &err, + ), + }), + ) + .await; + warn!("external patch approval handler failed; denying request: {err:#}"); + let (tx_approve, rx_approve) = oneshot::channel(); + let _ = tx_approve.send(ReviewDecision::Denied); + return rx_approve; + } + }, + } + } + let (tx_approve, rx_approve) = oneshot::channel(); - let approval_id = call_id.clone(); + let approval_id = request.call_id.clone(); let prev_entry = { let mut active = self.active_turn.lock().await; match active.as_mut() { @@ -2777,13 +2876,7 @@ impl Session { warn!("Overwriting existing pending approval for call_id: {approval_id}"); } - let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id, - turn_id: turn_context.sub_id.clone(), - changes, - reason, - grant_root, - }); + let event = EventMsg::ApplyPatchApprovalRequest(request); self.send_event(turn_context, event).await; rx_approve } @@ -3608,6 +3701,9 @@ impl Session { store_mode, auth_statuses, &turn_context.config.permissions.approval_policy, + self.services.approval_handler.clone(), + self.conversation_id, + Some(turn_context.session_source.default_thread_label()), self.get_tx_event(), sandbox_state, config.codex_home.clone(), @@ -8428,6 +8524,9 @@ mod tests { mcp_connection_manager: Arc::new(RwLock::new( McpConnectionManager::new_mcp_connection_manager_for_tests( &config.permissions.approval_policy, + config.approval_handler.clone(), + conversation_id, + Some(session_configuration.session_source.default_thread_label()), ), )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), @@ -8461,6 +8560,7 @@ mod tests { network_proxy: None, network_approval: Arc::clone(&network_approval), state_db: None, + approval_handler: config.approval_handler.clone(), model_client: ModelClient::new( Some(auth_manager.clone()), conversation_id, @@ -8677,6 +8777,9 @@ mod tests { mcp_connection_manager: Arc::new(RwLock::new( McpConnectionManager::new_mcp_connection_manager_for_tests( &config.permissions.approval_policy, + config.approval_handler.clone(), + conversation_id, + Some(session_configuration.session_source.default_thread_label()), ), )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), @@ -8710,6 +8813,7 @@ mod tests { network_proxy: None, network_approval: Arc::clone(&network_approval), state_db: None, + approval_handler: config.approval_handler.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bb313f25f3..fd084f6a0a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,6 +1,8 @@ use crate::auth::AuthCredentialsStoreMode; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; +use crate::config::types::ApprovalHandlerConfig; +use crate::config::types::ApprovalHandlerToml; use crate::config::types::AppsConfigToml; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; @@ -269,6 +271,10 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, + /// Optional external approval handler command that synchronously resolves + /// exec, patch, and MCP elicitation approvals over stdin/stdout. + pub approval_handler: Option, + /// TUI notifications preference. When set, the TUI will send terminal notifications on /// approvals and turn completions when not focused. pub tui_notifications: Notifications, @@ -1052,6 +1058,10 @@ pub struct ConfigToml { #[serde(default)] pub notify: Option>, + /// Optional external command to synchronously resolve approvals over stdin/stdout. + #[serde(default)] + pub approval_handler: Option, + /// System instructions. pub instructions: Option, @@ -2110,6 +2120,12 @@ impl Config { } else { network.enabled().then_some(network) }; + let approval_handler = cfg + .approval_handler + .map(ApprovalHandlerToml::try_into_config) + .transpose() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))? + .flatten(); let config = Self { model, @@ -2133,6 +2149,7 @@ impl Config { enforce_residency: enforce_residency.value, did_user_set_custom_approval_policy_or_sandbox_mode, notify: cfg.notify, + approval_handler, user_instructions, base_instructions, personality, @@ -4914,6 +4931,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, user_instructions: None, notify: None, + approval_handler: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), @@ -5044,6 +5062,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, user_instructions: None, notify: None, + approval_handler: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), @@ -5172,6 +5191,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, user_instructions: None, notify: None, + approval_handler: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), @@ -5286,6 +5306,7 @@ model_verbosity = "high" did_user_set_custom_approval_policy_or_sandbox_mode: true, user_instructions: None, notify: None, + approval_handler: None, cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 6f8c349158..86b8045208 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -351,6 +351,59 @@ pub enum HistoryPersistence { None, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalHandlerOnError { + #[default] + Fallback, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ApprovalHandlerToml { + /// Command argv used to resolve approval requests over stdin/stdout. + pub command: Option>, + + /// Timeout for the handler process, in milliseconds. + pub timeout_ms: Option, + + /// Behavior when the handler exits non-zero, times out, or returns invalid JSON. + #[serde(default)] + pub on_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalHandlerConfig { + pub command: Vec, + pub timeout_ms: u64, + pub on_error: ApprovalHandlerOnError, +} + +pub const DEFAULT_APPROVAL_HANDLER_TIMEOUT_MS: u64 = 300_000; + +impl ApprovalHandlerToml { + pub fn try_into_config(self) -> anyhow::Result> { + let Some(command) = self.command else { + return Ok(None); + }; + let Some((program, _)) = command.split_first() else { + anyhow::bail!("approval_handler.command must not be empty"); + }; + if program.trim().is_empty() { + anyhow::bail!("approval_handler.command[0] must not be empty"); + } + + Ok(Some(ApprovalHandlerConfig { + command, + timeout_ms: self + .timeout_ms + .unwrap_or(DEFAULT_APPROVAL_HANDLER_TIMEOUT_MS), + on_error: self.on_error.unwrap_or_default(), + })) + } +} + // ===== Analytics configuration ===== /// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults. diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 3ad48fa008..fb4301fe0d 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -11,6 +11,7 @@ use async_channel::unbounded; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; +use codex_protocol::ThreadId; use codex_protocol::protocol::SandboxPolicy; use rmcp::model::ToolAnnotations; use serde::Deserialize; @@ -158,6 +159,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.mcp_oauth_credentials_store_mode, auth_status_entries, &config.permissions.approval_policy, + config.approval_handler.clone(), + ThreadId::default(), + None, tx_event, sandbox_state, config.codex_home.clone(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 828bbe214a..59a4d7a391 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -8,6 +8,7 @@ mod analytics_client; pub mod api_bridge; mod apply_patch; +mod approval_handler; mod apps; pub mod auth; mod client; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 91a7f74db9..9bb124c5c3 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use std::time::Duration; use async_channel::unbounded; +use codex_protocol::ThreadId; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; @@ -247,6 +248,9 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent config.mcp_oauth_credentials_store_mode, auth_status_entries.clone(), &config.permissions.approval_policy, + config.approval_handler.clone(), + ThreadId::default(), + None, tx_event, sandbox_state, config.codex_home.clone(), diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 2d22351d1f..0dc49a912d 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -18,6 +18,7 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use crate::approval_handler; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::McpAuthStatusEntry; use anyhow::Context; @@ -27,6 +28,7 @@ use async_channel::Sender; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_config::Constrained; +use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::RequestId as ProtocolRequestId; @@ -38,6 +40,7 @@ use codex_protocol::protocol::McpStartupFailure; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::WarningEvent; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; @@ -76,6 +79,8 @@ use tracing::warn; use url::Url; use crate::codex::INITIAL_SUBMIT_ID; +use crate::config::types::ApprovalHandlerConfig; +use crate::config::types::ApprovalHandlerOnError; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::connectors::is_connector_id_allowed; @@ -250,13 +255,24 @@ fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool { struct ElicitationRequestManager { requests: Arc>, approval_policy: Arc>, + approval_handler: Arc>>, + thread_id: ThreadId, + thread_label: Option, } impl ElicitationRequestManager { - fn new(approval_policy: AskForApproval) -> Self { + fn new( + approval_policy: AskForApproval, + approval_handler: Option, + thread_id: ThreadId, + thread_label: Option, + ) -> Self { Self { requests: Arc::new(Mutex::new(HashMap::new())), approval_policy: Arc::new(StdMutex::new(approval_policy)), + approval_handler: Arc::new(StdMutex::new(approval_handler)), + thread_id, + thread_label, } } @@ -278,11 +294,17 @@ impl ElicitationRequestManager { fn make_sender(&self, server_name: String, tx_event: Sender) -> SendElicitation { let elicitation_requests = self.requests.clone(); let approval_policy = self.approval_policy.clone(); + let approval_handler = self.approval_handler.clone(); + let thread_id = self.thread_id; + let thread_label = self.thread_label.clone(); Box::new(move |id, elicitation| { let elicitation_requests = elicitation_requests.clone(); let tx_event = tx_event.clone(); let server_name = server_name.clone(); let approval_policy = approval_policy.clone(); + let approval_handler = approval_handler.clone(); + let thread_id = thread_id; + let thread_label = thread_label.clone(); async move { if approval_policy .lock() @@ -294,6 +316,97 @@ impl ElicitationRequestManager { }); } + let request_event = ElicitationRequestEvent { + server_name: server_name.clone(), + id: match id.clone() { + rmcp::model::NumberOrString::String(value) => { + ProtocolRequestId::String(value.to_string()) + } + rmcp::model::NumberOrString::Number(value) => { + ProtocolRequestId::Integer(value) + } + }, + message: match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + message, + .. + } + | CreateElicitationRequestParams::UrlElicitationParams { + message, + .. + } => message, + }, + }; + + let handler_config = approval_handler.lock().ok().and_then(|config| config.clone()); + if let Some(config) = handler_config.as_ref() { + match approval_handler::request_elicitation_approval( + config, + thread_id, + thread_label.as_deref(), + &request_event, + ) + .await + { + Ok(action) => { + let action = match action { + codex_protocol::protocol::ElicitationAction::Accept => { + ElicitationAction::Accept + } + codex_protocol::protocol::ElicitationAction::Decline => { + ElicitationAction::Decline + } + codex_protocol::protocol::ElicitationAction::Cancel => { + ElicitationAction::Cancel + } + }; + return Ok(ElicitationResponse { + action, + content: None, + }); + } + Err(err) => match config.on_error { + ApprovalHandlerOnError::Fallback => { + let _ = tx_event + .send(Event { + id: thread_id.to_string(), + msg: EventMsg::Warning(WarningEvent { + message: approval_handler::fallback_warning_message( + "elicitation", + &err, + ), + }), + }) + .await; + warn!( + "external elicitation approval handler failed; falling back: {err:#}" + ); + } + ApprovalHandlerOnError::Deny => { + let _ = tx_event + .send(Event { + id: thread_id.to_string(), + msg: EventMsg::Warning(WarningEvent { + message: approval_handler::deny_warning_message( + "elicitation", + "declining", + &err, + ), + }), + }) + .await; + warn!( + "external elicitation approval handler failed; declining request: {err:#}" + ); + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + }); + } + }, + } + } + let (tx, rx) = oneshot::channel(); { let mut lock = elicitation_requests.lock().await; @@ -302,27 +415,7 @@ impl ElicitationRequestManager { let _ = tx_event .send(Event { id: "mcp_elicitation_request".to_string(), - msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { - server_name, - id: match id.clone() { - rmcp::model::NumberOrString::String(value) => { - ProtocolRequestId::String(value.to_string()) - } - rmcp::model::NumberOrString::Number(value) => { - ProtocolRequestId::Integer(value) - } - }, - message: match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - message, - .. - } - | CreateElicitationRequestParams::UrlElicitationParams { - message, - .. - } => message, - }, - }), + msg: EventMsg::ElicitationRequest(request_event), }) .await; rx.await @@ -513,19 +606,32 @@ pub(crate) struct McpConnectionManager { } impl McpConnectionManager { - pub(crate) fn new_uninitialized(approval_policy: &Constrained) -> Self { + pub(crate) fn new_uninitialized( + approval_policy: &Constrained, + approval_handler: Option, + thread_id: ThreadId, + thread_label: Option, + ) -> Self { Self { clients: HashMap::new(), server_origins: HashMap::new(), - elicitation_requests: ElicitationRequestManager::new(approval_policy.value()), + elicitation_requests: ElicitationRequestManager::new( + approval_policy.value(), + approval_handler, + thread_id, + thread_label, + ), } } #[cfg(test)] pub(crate) fn new_mcp_connection_manager_for_tests( approval_policy: &Constrained, + approval_handler: Option, + thread_id: ThreadId, + thread_label: Option, ) -> Self { - Self::new_uninitialized(approval_policy) + Self::new_uninitialized(approval_policy, approval_handler, thread_id, thread_label) } pub(crate) fn has_servers(&self) -> bool { @@ -548,6 +654,9 @@ impl McpConnectionManager { store_mode: OAuthCredentialsStoreMode, auth_entries: HashMap, approval_policy: &Constrained, + approval_handler: Option, + thread_id: ThreadId, + thread_label: Option, tx_event: Sender, initial_sandbox_state: SandboxState, codex_home: PathBuf, @@ -557,7 +666,12 @@ impl McpConnectionManager { let mut clients = HashMap::new(); let mut server_origins = HashMap::new(); let mut join_set = JoinSet::new(); - let elicitation_requests = ElicitationRequestManager::new(approval_policy.value()); + let elicitation_requests = ElicitationRequestManager::new( + approval_policy.value(), + approval_handler, + thread_id, + thread_label, + ); let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { if let Some(origin) = transport_origin(&cfg.transport) { @@ -1984,7 +2098,12 @@ mod tests { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + None, + ThreadId::default(), + None, + ); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -2009,7 +2128,12 @@ mod tests { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + None, + ThreadId::default(), + None, + ); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -2031,7 +2155,12 @@ mod tests { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + None, + ThreadId::default(), + None, + ); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -2061,7 +2190,12 @@ mod tests { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + None, + ThreadId::default(), + None, + ); let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index e2792638e8..f21a6f7fa3 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -7,6 +7,7 @@ use crate::agent::AgentControl; use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; +use crate::config::types::ApprovalHandlerConfig; use crate::exec_policy::ExecPolicyManager; use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; @@ -57,6 +58,7 @@ pub(crate) struct SessionServices { pub(crate) network_proxy: Option, pub(crate) network_approval: Arc, pub(crate) state_db: Option, + pub(crate) approval_handler: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 3f82139fe3..d19863dae8 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2,6 +2,8 @@ use anyhow::Result; use codex_core::config::Constrained; +use codex_core::config::types::ApprovalHandlerConfig; +use codex_core::config::types::ApprovalHandlerOnError; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; @@ -1982,6 +1984,88 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_exec_approval_handler_approves_without_emitting_prompt() -> Result<()> { + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let home = Arc::new(TempDir::new()?); + + let mut builder = test_codex().with_home(Arc::clone(&home)).with_config({ + move |config| { + config.features.enable(Feature::UnifiedExec); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.approval_handler = Some(ApprovalHandlerConfig { + command: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cat >/dev/null\nprintf '{\"type\":\"exec_approval\",\"id\":\"external-handler-approval\",\"turn_id\":\"external-handler-approval\",\"decision\":\"approved\"}'\n".to_string(), + ], + timeout_ms: 5_000, + on_error: ApprovalHandlerOnError::Fallback, + }); + } + }); + let server = start_mock_server().await; + let test = builder.build(&server).await?; + + let call_id = "external-handler-approval"; + let (event, expected_command) = ActionKind::RunUnifiedExecCommand { + command: "echo handled-by-external-approval", + justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), + } + .prepare( + &test, + &server, + call_id, + SandboxPermissions::RequireEscalated, + ) + .await?; + let expected_command = expected_command.expect("prepared shell command"); + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-external-approval-1"), + event, + ev_completed("resp-external-approval-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-external-approval-1", "done"), + ev_completed("resp-external-approval-2"), + ]), + ) + .await; + + submit_turn( + &test, + "external approval handler", + approval_policy, + sandbox_policy, + ) + .await?; + wait_for_completion_without_approval(&test).await; + + let output = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!( + output.exit_code.unwrap_or(0), + 0, + "unexpected shell output: {}", + output.stdout + ); + assert!( + output.stdout.contains("handled-by-external-approval"), + "unexpected stdout: {}", + output.stdout + ); + assert_eq!(expected_command, "echo handled-by-external-approval"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 55f19255b0..361d676905 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1500,6 +1500,8 @@ pub struct WarningEvent { pub message: String, } +pub const EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX: &str = "External approval handler failed"; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -2043,6 +2045,27 @@ impl fmt::Display for SessionSource { } } +pub fn format_thread_label( + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + impl SessionSource { pub fn get_nickname(&self) -> Option { match self { @@ -2067,6 +2090,14 @@ impl SessionSource { _ => None, } } + + pub fn default_thread_label(&self) -> String { + format_thread_label( + self.get_nickname().as_deref(), + self.get_agent_role().as_deref(), + !matches!(self, SessionSource::SubAgent(_)), + ) + } } impl fmt::Display for SubAgentSource { @@ -3169,6 +3200,31 @@ mod tests { ); } + #[test] + fn format_thread_label_prefers_primary_thread_name() { + assert_eq!( + format_thread_label(Some("Robie"), Some("explorer"), true), + "Main [default]".to_string() + ); + } + + #[test] + fn format_thread_label_formats_agent_metadata_variants() { + assert_eq!( + format_thread_label(Some("Robie"), Some("explorer"), false), + "Robie [explorer]".to_string() + ); + assert_eq!( + format_thread_label(Some("Robie"), None, false), + "Robie".to_string() + ); + assert_eq!( + format_thread_label(None, Some("explorer"), false), + "[explorer]".to_string() + ); + assert_eq!(format_thread_label(None, None, false), "Agent".to_string()); + } + #[test] fn workspace_write_restricted_read_access_includes_effective_writable_roots() { let cwd = if cfg!(windows) { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 76e92ca0e3..e29854e785 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -52,6 +52,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; @@ -1643,7 +1644,11 @@ fn decode_mcp_image(block: &serde_json::Value) -> Option { #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { - PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ") + if message.starts_with(EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX) { + PrefixedWrappedHistoryCell::new(message.red(), "⚠ ".red(), " ") + } else { + PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ") + } } #[derive(Debug)] @@ -2420,6 +2425,7 @@ mod tests { use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::Tool; + use codex_protocol::protocol::EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX; use codex_protocol::protocol::ExecCommandSource; use rmcp::model::Content; @@ -2713,6 +2719,32 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn external_approval_handler_warning_snapshot() { + let cell = new_warning_event(format!( + "{EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX}: exec approval dialog failed; falling back to the built-in prompt. approval handler timed out after 1000 ms" + )); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn external_approval_handler_warning_uses_red_style() { + let cell = new_warning_event(format!( + "{EXTERNAL_APPROVAL_HANDLER_WARNING_PREFIX}: exec approval dialog failed; falling back to the built-in prompt. approval handler timed out after 1000 ms" + )); + let rendered = cell.display_lines(120); + assert_eq!(rendered.len(), 2); + assert_eq!(rendered[0].spans[0].style.fg, Some(Color::Red)); + assert_eq!(rendered[0].spans[1].style.fg, Some(Color::Red)); + let continuation = rendered[1] + .spans + .iter() + .find(|span| !span.content.trim().is_empty()) + .expect("wrapped continuation span"); + assert_eq!(continuation.style.fg, Some(Color::Red)); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 7161e8880d..0d36bd8856 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; +pub(crate) use codex_protocol::protocol::format_thread_label as format_agent_picker_item_name; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -45,27 +46,6 @@ pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec, - agent_role: Option<&str>, - is_primary: bool, -) -> String { - if is_primary { - return "Main [default]".to_string(); - } - - let agent_nickname = agent_nickname - .map(str::trim) - .filter(|nickname| !nickname.is_empty()); - let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); - match (agent_nickname, agent_role) { - (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), - (Some(agent_nickname), None) => agent_nickname.to_string(), - (None, Some(agent_role)) => format!("[{agent_role}]"), - (None, None) => "Agent".to_string(), - } -} - pub(crate) fn sort_agent_picker_threads(agent_threads: &mut [(ThreadId, AgentPickerThreadEntry)]) { agent_threads.sort_by(|(left_id, left), (right_id, right)| { left.is_closed diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__external_approval_handler_warning_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__external_approval_handler_warning_snapshot.snap new file mode 100644 index 0000000000..dba333d612 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__external_approval_handler_warning_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 2728 +expression: rendered +--- +⚠ External approval handler failed: exec approval dialog failed; falling back to the built-in prompt. approval handler + timed out after 1000 ms diff --git a/docs/config.md b/docs/config.md index fc9d62b8e8..bc70bb80ab 100644 --- a/docs/config.md +++ b/docs/config.md @@ -26,6 +26,28 @@ Codex can run a notification hook when the agent finishes a turn. See the config When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`. +## Approval handler + +Codex can delegate exec, patch, and MCP elicitation approvals to an external +command via `[approval_handler]` in `config.toml`. + +Example: + +```toml +[approval_handler] +command = ["python3", "/path/to/codex/scripts/macos_approval_dialog.py"] +timeout_ms = 300000 +on_error = "fallback" +``` + +Codex writes each approval request as JSON to the handler's stdin and expects a +single approval `Op` JSON object on stdout. The request payload includes +`thread_id` and, when available, `thread_label`. `thread_label` is optional and +should be treated as best-effort metadata. + +On macOS, this repository includes a native approval helper at +[`scripts/macos_approval_dialog.py`](../scripts/macos_approval_dialog.py). + ## JSON Schema The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`. diff --git a/scripts/macos_approval_dialog.py b/scripts/macos_approval_dialog.py new file mode 100644 index 0000000000..8e57a24206 --- /dev/null +++ b/scripts/macos_approval_dialog.py @@ -0,0 +1,1269 @@ +#!/usr/bin/env python3 +"""Show a macOS approval dialog via osascript/JXA with JSON input and output. + +The helper wraps the working NSAlert + accessory-view pattern we prototyped: +- frontmost activation +- optional thread / reason / permission-rule labels +- optional monospaced code box +- custom buttons with single-key equivalents +- JSON result on stdout + +Input is read from stdin by default, or from --input. Example: + +{ + "kind": "exec", + "thread": "Robie [explorer]", + "reason": "run the targeted test suite before finalizing changes.", + "permission_rule": "write `/tmp`, `/Users/ebrevdo/code/codex`", + "code": "python -m pytest tests/test_example.py\\n--maxfail=1\\n-q" +} +""" + +from __future__ import annotations + +import argparse +import json +import os +import select +import shlex +import subprocess +import sys +import tempfile +import textwrap +from datetime import datetime +from pathlib import Path +from typing import Any + + +DEFAULT_WIDTH = 620 +DEFAULT_CODE_HEIGHT = 180 +BUTTON_WRAP_WIDTH = 72 +TITLE_WRAP_WIDTH = 52 +BODY_WRAP_WIDTH = 72 +BUTTON_VERTICAL_GAP = 4 +DEBUG_ENV = "CODEX_APPROVAL_DIALOG_DEBUG" +DEBUG_DIR_ENV = "CODEX_APPROVAL_DIALOG_DEBUG_DIR" +SIMPLE_DIALOG_ENV = "CODEX_APPROVAL_DIALOG_SIMPLE" +OSASCRIPT_TIMEOUT_ENV = "CODEX_APPROVAL_DIALOG_OSASCRIPT_TIMEOUT_MS" +REQUESTER_PID_ENV = "CODEX_APPROVAL_REQUESTER_PID" +STDIN_TIMEOUT_ENV = "CODEX_APPROVAL_DIALOG_STDIN_TIMEOUT_MS" +DEFAULT_STDIN_TIMEOUT_MS = 100 + + +def _render_command(command: list[str]) -> str: + try: + return shlex.join(command) + except Exception: + return " ".join(command) + + +def _format_permission_rule(additional_permissions: dict[str, Any] | None) -> str | None: + if not additional_permissions: + return None + parts: list[str] = [] + file_system = additional_permissions.get("file_system") + if isinstance(file_system, dict): + read = file_system.get("read") + if isinstance(read, list) and read: + parts.append("read " + ", ".join(f"`{item}`" for item in read)) + write = file_system.get("write") + if isinstance(write, list) and write: + parts.append("write " + ", ".join(f"`{item}`" for item in write)) + return "; ".join(parts) if parts else None + + +def _decision_id(decision: Any) -> str: + if isinstance(decision, str): + return decision + if isinstance(decision, dict) and len(decision) == 1: + return next(iter(decision)) + raise ValueError(f"unsupported review decision shape: {decision!r}") + + +def _execpolicy_command_prefix(amendment: Any) -> list[str]: + if isinstance(amendment, list) and all(isinstance(item, str) for item in amendment): + return amendment + if isinstance(amendment, dict): + command = amendment.get("command") + if isinstance(command, list) and all(isinstance(item, str) for item in command): + return command + raise ValueError(f"unsupported execpolicy amendment shape: {amendment!r}") + + +def _option_for_exec_decision( + decision: Any, + *, + network_approval_context: dict[str, Any] | None, + additional_permissions: dict[str, Any] | None, +) -> dict[str, Any]: + decision_id = _decision_id(decision) + option: dict[str, Any] + if decision_id == "approved": + option = { + "id": "approved", + "label": "Yes, just this once" if network_approval_context else "Yes, proceed", + "key": "y", + "default": True, + } + elif decision_id == "approved_execpolicy_amendment": + amendment = decision["approved_execpolicy_amendment"] + prefix = _render_command( + _execpolicy_command_prefix(amendment["proposed_execpolicy_amendment"]) + ) + option = { + "id": "approved_execpolicy_amendment", + "label": f"Yes, and don't ask again for commands that start with `{prefix}`", + "key": "p", + } + elif decision_id == "approved_for_session": + if network_approval_context: + label = "Yes, and allow this host for this conversation" + elif additional_permissions: + label = "Yes, and allow these permissions for this session" + else: + label = "Yes, and don't ask again for this command in this session" + option = {"id": "approved_for_session", "label": label, "key": "a"} + elif decision_id == "network_policy_amendment": + amendment = decision["network_policy_amendment"] + action = amendment["action"] + if action == "allow": + option = { + "id": "network_policy_amendment_allow", + "label": "Yes, and allow this host in the future", + "key": "p", + } + elif action == "deny": + option = { + "id": "network_policy_amendment_deny", + "label": "No, and block this host in the future", + "key": "d", + } + else: + raise ValueError(f"unsupported network policy action: {action!r}") + elif decision_id == "denied": + option = { + "id": "denied", + "label": "No, continue without running it", + "key": "d", + } + elif decision_id == "abort": + option = { + "id": "abort", + "label": "No, and tell Codex what to do differently", + "key": "n", + "cancel": True, + } + else: + raise ValueError(f"unsupported exec decision: {decision!r}") + + option["decision"] = decision + return option + + +def _default_exec_decisions(raw: dict[str, Any]) -> list[Any]: + if raw.get("available_decisions") is not None: + return raw["available_decisions"] + network_approval_context = raw.get("network_approval_context") + if network_approval_context is not None: + decisions: list[Any] = ["approved", "approved_for_session"] + amendments = raw.get("proposed_network_policy_amendments") or [] + allow_amendment = next( + (item for item in amendments if isinstance(item, dict) and item.get("action") == "allow"), + None, + ) + if allow_amendment is not None: + decisions.append({"network_policy_amendment": allow_amendment}) + decisions.append("abort") + return decisions + if raw.get("additional_permissions") is not None: + return ["approved", "abort"] + decisions = ["approved"] + amendment = raw.get("proposed_execpolicy_amendment") + if amendment is not None: + decisions.append({"approved_execpolicy_amendment": {"proposed_execpolicy_amendment": amendment}}) + decisions.append("abort") + return decisions + + +def _summarize_patch_changes(changes: dict[str, Any]) -> str: + lines = [] + for path, change in changes.items(): + if not isinstance(change, dict): + lines.append(f"? {path}") + continue + change_type = change.get("type", "?") + symbol = { + "add": "A", + "delete": "D", + "update": "M", + }.get(change_type, "?") + lines.append(f"{symbol} {path}") + return "\n".join(lines) + + +def _normalize_exec_request(raw: dict[str, Any]) -> dict[str, Any]: + network_approval_context = raw.get("network_approval_context") + additional_permissions = raw.get("additional_permissions") + payload: dict[str, Any] = { + "kind": "network" if network_approval_context else "exec", + "window_title": "Approval Request", + "title": _default_title( + { + "kind": "network" if network_approval_context else "exec", + "host": (network_approval_context or {}).get("host"), + } + ), + "message": "Codex needs your approval before continuing.", + "reason": raw.get("reason"), + "thread_id": raw.get("thread_id"), + "thread_label": raw.get("thread_label"), + "thread": raw.get("thread_label"), + "is_current_thread": raw.get("is_current_thread"), + "current_thread_id": raw.get("current_thread_id"), + "permission_rule": _format_permission_rule(additional_permissions), + "host": (network_approval_context or {}).get("host"), + "code": None if network_approval_context else _render_command(raw["command"]), + "show_shortcuts_hint": False, + "code_selectable": False, + "width": DEFAULT_WIDTH, + "code_height": DEFAULT_CODE_HEIGHT, + "call_id": raw.get("call_id"), + "approval_id": raw.get("approval_id"), + "turn_id": raw.get("turn_id"), + "cwd": raw.get("cwd"), + "requester_pid": raw.get("requester_pid"), + "command": raw.get("command"), + "protocol_output_type": "exec_approval", + } + decisions = _default_exec_decisions(raw) + payload["options"] = [ + _option_for_exec_decision( + decision, + network_approval_context=network_approval_context, + additional_permissions=additional_permissions, + ) + for decision in decisions + ] + return payload + + +def _normalize_patch_request(raw: dict[str, Any]) -> dict[str, Any]: + code = _summarize_patch_changes(raw.get("changes", {})) + options = [ + { + "id": "approved", + "label": "Yes, proceed", + "key": "y", + "default": True, + "decision": "approved", + }, + { + "id": "approved_for_session", + "label": "Yes, and don't ask again for these files", + "key": "a", + "decision": "approved_for_session", + }, + { + "id": "abort", + "label": "No, and tell Codex what to do differently", + "key": "n", + "cancel": True, + "decision": "abort", + }, + ] + return { + "kind": "patch", + "window_title": "Approval Request", + "title": _default_title({"kind": "patch"}), + "message": "Codex needs your approval before continuing.", + "reason": raw.get("reason"), + "thread_id": raw.get("thread_id"), + "thread_label": raw.get("thread_label"), + "thread": raw.get("thread_label"), + "is_current_thread": raw.get("is_current_thread"), + "current_thread_id": raw.get("current_thread_id"), + "permission_rule": f"grant write access under `{raw['grant_root']}`" if raw.get("grant_root") else None, + "code": code or None, + "show_shortcuts_hint": False, + "code_selectable": False, + "width": DEFAULT_WIDTH, + "code_height": DEFAULT_CODE_HEIGHT, + "call_id": raw.get("call_id"), + "turn_id": raw.get("turn_id"), + "cwd": raw.get("cwd"), + "requester_pid": raw.get("requester_pid"), + "changes": raw.get("changes"), + "protocol_output_type": "patch_approval", + "options": options, + } + + +def _normalize_elicitation_request(raw: dict[str, Any]) -> dict[str, Any]: + options = [ + { + "id": "accept", + "label": "Yes, provide the requested info", + "key": "y", + "default": True, + "decision": "accept", + }, + { + "id": "decline", + "label": "No, but continue without it", + "key": "a", + "decision": "decline", + }, + { + "id": "cancel", + "label": "Cancel this request", + "key": "n", + "cancel": True, + "decision": "cancel", + }, + ] + return { + "kind": "elicitation", + "window_title": "Approval Request", + "title": _default_title({"kind": "elicitation", "server_name": raw.get("server_name")}), + "message": "Codex needs your approval before continuing.", + "thread_id": raw.get("thread_id"), + "thread_label": raw.get("thread_label"), + "thread": raw.get("thread_label"), + "is_current_thread": raw.get("is_current_thread"), + "current_thread_id": raw.get("current_thread_id"), + "server_name": raw.get("server_name"), + "turn_id": raw.get("turn_id"), + "cwd": raw.get("cwd"), + "requester_pid": raw.get("requester_pid"), + "code": raw.get("message"), + "show_shortcuts_hint": False, + "code_selectable": False, + "width": DEFAULT_WIDTH, + "code_height": DEFAULT_CODE_HEIGHT, + "request_id": raw.get("id"), + "protocol_output_type": "resolve_elicitation", + "options": options, + } + + +def _looks_like_exec_request(raw: dict[str, Any]) -> bool: + return "call_id" in raw and "command" in raw and "cwd" in raw + + +def _looks_like_patch_request(raw: dict[str, Any]) -> bool: + return "call_id" in raw and "changes" in raw and "command" not in raw + + +def _looks_like_elicitation_request(raw: dict[str, Any]) -> bool: + return "server_name" in raw and "id" in raw and "message" in raw and "call_id" not in raw + + +def _default_options(kind: str) -> list[dict[str, Any]]: + if kind == "patch": + return [ + {"id": "abort", "label": "No", "key": "n", "cancel": True}, + { + "id": "approved_for_session", + "label": "Yes, and don't ask again for these files", + "key": "a", + }, + {"id": "approved", "label": "Yes, proceed", "key": "y", "default": True}, + ] + if kind == "network": + return [ + {"id": "abort", "label": "No", "key": "n", "cancel": True}, + { + "id": "approved_for_session", + "label": "Yes, and allow this host for this conversation", + "key": "a", + }, + {"id": "approved", "label": "Yes, just this once", "key": "y", "default": True}, + ] + if kind == "elicitation": + return [ + {"id": "cancel", "label": "Cancel this request", "key": "n", "cancel": True}, + {"id": "decline", "label": "No, but continue without it", "key": "a"}, + {"id": "accept", "label": "Yes, provide the requested info", "key": "y", "default": True}, + ] + return [ + {"id": "abort", "label": "No", "key": "n", "cancel": True}, + { + "id": "approved_for_session", + "label": "Yes, and don't ask again this session", + "key": "a", + }, + {"id": "approved", "label": "Yes, proceed", "key": "y", "default": True}, + ] + + +def _default_title(payload: dict[str, Any]) -> str: + kind = payload["kind"] + if kind == "patch": + return "Would you like to make the following edits?" + if kind == "network": + host = payload.get("host", "this host") + return f'Do you want to approve network access to "{host}"?' + if kind == "elicitation": + server = payload.get("server_name", "Tool") + return f"{server} needs your approval." + return "Would you like to run the following command?" + + +def _default_message(payload: dict[str, Any]) -> str: + kind = payload["kind"] + if kind == "patch": + return "Codex needs your approval before continuing." + if kind == "network": + return "Codex needs your approval before continuing." + if kind == "elicitation": + return "Codex needs your approval before continuing." + return "Codex needs your approval before continuing." + + +def normalize_payload(raw: dict[str, Any]) -> dict[str, Any]: + if "requester_pid" not in raw: + requester_pid = os.environ.get(REQUESTER_PID_ENV) + if requester_pid: + raw = dict(raw) + raw["requester_pid"] = requester_pid + if _looks_like_exec_request(raw): + payload = _normalize_exec_request(raw) + elif _looks_like_patch_request(raw): + payload = _normalize_patch_request(raw) + elif _looks_like_elicitation_request(raw): + payload = _normalize_elicitation_request(raw) + else: + payload = dict(raw) + payload.setdefault("kind", "exec") + payload.setdefault("window_title", "Approval Request") + payload.setdefault("title", _default_title(payload)) + payload.setdefault("message", _default_message(payload)) + payload.setdefault("options", _default_options(payload["kind"])) + payload.setdefault("show_shortcuts_hint", False) + payload.setdefault("code_selectable", False) + payload.setdefault("width", DEFAULT_WIDTH) + payload.setdefault("code_height", DEFAULT_CODE_HEIGHT) + + code = payload.get("code") + if code is not None and not isinstance(code, str): + raise ValueError("`code` must be a string when present") + + options = payload["options"] + if not isinstance(options, list) or not options: + raise ValueError("`options` must be a non-empty list") + + seen_ids: set[str] = set() + default_count = 0 + for option in options: + if not isinstance(option, dict): + raise ValueError("each option must be an object") + option.setdefault("id", option["label"]) + if option["id"] in seen_ids: + raise ValueError(f"duplicate option id: {option['id']}") + seen_ids.add(option["id"]) + key = option.get("key") + if key is not None and (not isinstance(key, str) or len(key) != 1): + raise ValueError(f"option key must be a single character: {option!r}") + if option.get("default"): + default_count += 1 + + if default_count > 1: + raise ValueError("at most one option may be marked as default") + + return payload + + +def build_shortcuts_hint(options: list[dict[str, Any]]) -> str: + parts = [] + for option in options: + key = option.get("key") + if key: + parts.append(f"{key} = {option['label']}") + return "Shortcuts: " + ", ".join(parts) + + +def _wrap_dialog_text( + text: str, + *, + width: int, + initial_indent: str = "", + subsequent_indent: str | None = None, +) -> tuple[str, int]: + if subsequent_indent is None: + subsequent_indent = " " * len(initial_indent) + wrapped = textwrap.fill( + text, + width=width, + initial_indent=initial_indent, + subsequent_indent=subsequent_indent, + ) + return wrapped, wrapped.count("\n") + 1 + + +def add_display_labels(options: list[dict[str, Any]]) -> None: + for option in options: + key = option.get("key") + if key: + prefix = f"({key}) " + display_label = textwrap.fill( + option["label"], + width=BUTTON_WRAP_WIDTH, + initial_indent=prefix, + subsequent_indent=" " * (len(prefix) + 2), + ) + else: + display_label = textwrap.fill( + option["label"], + width=BUTTON_WRAP_WIDTH, + subsequent_indent=" ", + ) + option["display_label"] = display_label + option["display_line_count"] = display_label.count("\n") + 1 + + +def add_wrapped_dialog_fields(payload: dict[str, Any]) -> None: + title_display, title_line_count = _wrap_dialog_text( + payload["title"], + width=TITLE_WRAP_WIDTH, + ) + message_display, message_line_count = _wrap_dialog_text( + payload["message"], + width=BODY_WRAP_WIDTH, + ) + payload["title_display"] = title_display + payload["title_line_count"] = title_line_count + payload["message_display"] = message_display + payload["message_line_count"] = message_line_count + + detail_specs: list[tuple[str, str | None]] = [ + ("Thread: ", payload.get("thread")), + ( + "Requester PID: ", + str(payload["requester_pid"]) if payload.get("requester_pid") is not None else None, + ), + ("Working dir: ", payload.get("cwd")), + ("Reason: ", payload.get("reason")), + ("Permission rule: ", payload.get("permission_rule")), + ("Host: ", payload.get("host") if payload.get("kind") == "network" else None), + ( + "Server: ", + payload.get("server_name") if payload.get("kind") == "elicitation" else None, + ), + ] + detail_rows = [] + for prefix, value in detail_specs: + if not value: + continue + text, line_count = _wrap_dialog_text( + f"{prefix}{value}", + width=BODY_WRAP_WIDTH, + subsequent_indent=" " * len(prefix), + ) + detail_rows.append({"text": text, "line_count": line_count}) + payload["detail_rows"] = detail_rows + + if payload.get("show_shortcuts_hint"): + shortcuts_display, shortcuts_line_count = _wrap_dialog_text( + payload["shortcuts_hint"], + width=BODY_WRAP_WIDTH, + ) + payload["shortcuts_display"] = shortcuts_display + payload["shortcuts_line_count"] = shortcuts_line_count + + +def format_thread_summary(payload: dict[str, Any]) -> str | None: + thread_label = payload.get("thread_label") or payload.get("thread") + thread_id = payload.get("thread_id") + is_current_thread = payload.get("is_current_thread") + current_thread_id = payload.get("current_thread_id") + + if is_current_thread is None and thread_id is not None and current_thread_id is not None: + is_current_thread = thread_id == current_thread_id + + if not thread_label and not thread_id: + return None + + current_suffix = " (current)" if is_current_thread else "" + if thread_label and thread_id: + return f"{thread_label}{current_suffix} {thread_id}" + if thread_label: + return f"{thread_label}{current_suffix}" + return str(thread_id) + + +def build_jxa(payload: dict[str, Any]) -> str: + payload_json = json.dumps(payload) + return f""" +ObjC.import("AppKit"); +ObjC.import("Foundation"); + +function nsstr(value) {{ + return $(value); +}} + +function makeLabel(text, width, font) {{ + var field = $.NSTextField.alloc.initWithFrame($.NSMakeRect(0, 0, width, 22)); + field.setStringValue(nsstr(text)); + field.setEditable(false); + field.setSelectable(false); + field.setBezeled(false); + field.setBordered(false); + field.setDrawsBackground(false); + field.setLineBreakMode($.NSLineBreakByWordWrapping); + field.setUsesSingleLineMode(false); + field.setFont(font); + return field; +}} + +function measureWrappedTextHeight(text, width, font) {{ + var field = makeLabel(text, width, font); + field.setFrame($.NSMakeRect(0, 0, width, 1000000)); + var cellSize = field.cell.cellSizeForBounds($.NSMakeRect(0, 0, width, 1000000)); + return Math.ceil(cellSize.height); +}} + +var payload = {payload_json}; +var app = Application.currentApplication(); +app.includeStandardAdditions = true; + +var nsApp = $.NSApplication.sharedApplication; +nsApp.setActivationPolicy($.NSApplicationActivationPolicyRegular); +$.NSRunningApplication.currentApplication.activateWithOptions( + $.NSApplicationActivateIgnoringOtherApps +); +nsApp.activateIgnoringOtherApps(true); +delay(0.25); + +var margin = 18; +var innerWidth = payload.width - (margin * 2); +var titleFont = $.NSFont.boldSystemFontOfSize(16); +var bodyFont = $.NSFont.systemFontOfSize(13); +var buttonFont = $.NSFont.systemFontOfSize(13); +var buttonHorizontalInset = 14; +var buttonVerticalInset = 7; + +var titleHeight = Math.max(22, (payload.title_line_count || 1) * 20); +var messageHeight = Math.max(18, (payload.message_line_count || 1) * 17); +var detailsHeight = 0; +for (var i = 0; i < payload.detail_rows.length; i++) {{ + detailsHeight += Math.max(18, payload.detail_rows[i].line_count * 17) + 6; +}} +var shortcutsHeight = payload.show_shortcuts_hint + ? (Math.max(18, (payload.shortcuts_line_count || 1) * 17) + 8) + : 0; +var codeHeight = payload.code ? (payload.code_height + 10) : 0; +var buttonTextWidth = Math.max(80, innerWidth - (buttonHorizontalInset * 2)); +var buttonHeights = []; +var buttonsBlockHeight = 0; +for (var i = 0; i < payload.options.length; i++) {{ + var label = payload.options[i].display_label || payload.options[i].label; + var textHeight = Math.max(18, measureWrappedTextHeight(label, buttonTextWidth, buttonFont)); + var buttonHeight = Math.max(28, textHeight + (buttonVerticalInset * 2)); + buttonHeights.push(buttonHeight); + buttonsBlockHeight += buttonHeight; + if (i > 0) {{ + buttonsBlockHeight += {BUTTON_VERTICAL_GAP}; + }} +}} +var contentHeight = + margin + + titleHeight + + 8 + + messageHeight + + 12 + + detailsHeight + + (payload.code ? codeHeight + 12 : 0) + + shortcutsHeight + + buttonsBlockHeight + + margin; + +var selection = null; +var cancelIndex = payload.options.findIndex(function(option) {{ + return Boolean(option.cancel); +}}); + +ObjC.registerSubclass({{ + name: "CodexApprovalDialogController", + methods: {{ + "buttonPressed:": {{ + types: ["void", ["id"]], + implementation: function(sender) {{ + selection = Number(sender.tag); + $.NSApp.stopModalWithCode(1000 + selection); + sender.window.orderOut(null); + }} + }} + }} +}}); + +var controller = $.CodexApprovalDialogController.alloc.init; + +var win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer( + $.NSMakeRect(0, 0, payload.width, contentHeight), + $.NSWindowStyleMaskTitled, + $.NSBackingStoreBuffered, + false +); +win.setTitle(nsstr(payload.window_title)); +win.setOpaque(true); +win.setAlphaValue(1.0); +win.setBackgroundColor($.NSColor.windowBackgroundColor); +win.setTitlebarAppearsTransparent(false); +win.setMovableByWindowBackground(false); +win.setLevel($.NSModalPanelWindowLevel); +var screen = $.NSScreen.mainScreen; +if (screen) {{ + var visibleFrame = screen.visibleFrame; + var originX = visibleFrame.origin.x + Math.max(0, (visibleFrame.size.width - payload.width) / 2); + var originY = visibleFrame.origin.y + Math.max(0, (visibleFrame.size.height - contentHeight) / 2); + win.setFrameOrigin($.NSMakePoint(originX, originY)); +}} + +var contentView = win.contentView; +var y = contentHeight - margin; + +var titleField = makeLabel(payload.title_display || payload.title, innerWidth, titleFont); +y -= titleHeight; +titleField.setFrame($.NSMakeRect(margin, y, innerWidth, titleHeight)); +contentView.addSubview(titleField); +y -= 8; + +var messageField = makeLabel(payload.message_display || payload.message, innerWidth, bodyFont); +y -= messageHeight; +messageField.setFrame($.NSMakeRect(margin, y, innerWidth, messageHeight)); +contentView.addSubview(messageField); +y -= 12; + +for (var i = 0; i < payload.detail_rows.length; i++) {{ + var rowHeight = Math.max(18, payload.detail_rows[i].line_count * 17); + var rowField = makeLabel(payload.detail_rows[i].text, innerWidth, bodyFont); + y -= rowHeight; + rowField.setFrame($.NSMakeRect(margin, y, innerWidth, rowHeight)); + contentView.addSubview(rowField); + y -= 6; +}} + +if (payload.code) {{ + var scrollY = y - payload.code_height; + var textView = $.NSTextView.alloc.initWithFrame( + $.NSMakeRect(0, 0, innerWidth, payload.code_height) + ); + textView.setEditable(false); + textView.setSelectable(Boolean(payload.code_selectable)); + textView.setRichText(false); + textView.setImportsGraphics(false); + textView.setUsesFindBar(true); + textView.setFont($.NSFont.userFixedPitchFontOfSize(12)); + textView.textContainer.setWidthTracksTextView(true); + textView.textContainer.setContainerSize($.NSMakeSize(innerWidth, 10000000)); + textView.setHorizontallyResizable(false); + textView.setVerticallyResizable(true); + textView.setMaxSize($.NSMakeSize(innerWidth, 10000000)); + textView.setString(nsstr(payload.code)); + + var scrollView = $.NSScrollView.alloc.initWithFrame( + $.NSMakeRect(margin, scrollY, innerWidth, payload.code_height) + ); + scrollView.setBorderType($.NSBezelBorder); + scrollView.setHasVerticalScroller(true); + scrollView.setHasHorizontalScroller(false); + scrollView.setAutohidesScrollers(true); + scrollView.setDocumentView(textView); + contentView.addSubview(scrollView); + y = scrollY - 12; +}} + +if (payload.show_shortcuts_hint) {{ + var hintHeight = Math.max(18, (payload.shortcuts_line_count || 1) * 17); + var hintField = makeLabel(payload.shortcuts_display || payload.shortcuts_hint, innerWidth, bodyFont); + y -= hintHeight; + hintField.setFrame($.NSMakeRect(margin, y, innerWidth, hintHeight)); + contentView.addSubview(hintField); + y -= 8; +}} + +var defaultIndex = payload.options.findIndex(function(option) {{ + return Boolean(option.default); +}}); +var buttons = []; +for (var i = 0; i < payload.options.length; i++) {{ + var option = payload.options[i]; + var buttonHeight = buttonHeights[i]; + var labelText = option.display_label || option.label; + var labelHeight = Math.max(18, measureWrappedTextHeight(labelText, buttonTextWidth, buttonFont)); + y -= buttonHeight; + var button = $.NSButton.alloc.initWithFrame($.NSMakeRect(margin, y, innerWidth, buttonHeight)); + button.setTitle(nsstr("")); + button.setTag(i); + button.setTarget(controller); + button.setAction("buttonPressed:"); + button.setBordered(false); + button.setWantsLayer(true); + button.layer.setCornerRadius(7); + if (i === defaultIndex) {{ + button.layer.setBackgroundColor($.NSColor.controlAccentColor.CGColor); + }} else {{ + button.layer.setBackgroundColor($.NSColor.controlColor.CGColor); + button.layer.setBorderWidth(1); + button.layer.setBorderColor($.NSColor.separatorColor.CGColor); + }} + button.setFont(buttonFont); + var buttonLabel = makeLabel(labelText, buttonTextWidth, buttonFont); + if (i === defaultIndex) {{ + buttonLabel.setTextColor($.NSColor.alternateSelectedControlTextColor); + }} + buttonLabel.setFrame( + $.NSMakeRect( + buttonHorizontalInset, + Math.max(buttonVerticalInset, Math.floor((buttonHeight - labelHeight) / 2)), + buttonTextWidth, + labelHeight + ) + ); + button.addSubview(buttonLabel); + if (option.key) {{ + button.setKeyEquivalent(nsstr(option.key)); + button.setKeyEquivalentModifierMask(0); + }} + if (i === defaultIndex) {{ + win.setDefaultButtonCell(button.cell); + }} + buttons.push(button); + contentView.addSubview(button); + if (i + 1 < payload.options.length) {{ + y -= {BUTTON_VERTICAL_GAP}; + }} +}} + +for (var i = 0; i < payload.options.length; i++) {{ + var option = payload.options[i]; + if (option.key) {{ + buttons[i].setKeyEquivalent(nsstr(option.key)); + buttons[i].setKeyEquivalentModifierMask(0); + }} +}} + +win.makeKeyAndOrderFront(null); +$.NSRunningApplication.currentApplication.activateWithOptions( + $.NSApplicationActivateIgnoringOtherApps | $.NSApplicationActivateAllWindows +); +nsApp.activateIgnoringOtherApps(true); + +var response = $.NSApp.runModalForWindow(win); +var responseIndex = selection; +if (responseIndex === null || responseIndex < 0) {{ + responseIndex = cancelIndex >= 0 ? cancelIndex : defaultIndex; +}} +var option = payload.options[responseIndex]; +var result = JSON.stringify({{ + thread_id: payload.thread_id || null, + thread_label: payload.thread_label || null, + call_id: payload.call_id || null, + approval_id: payload.approval_id || null, + turn_id: payload.turn_id || null, + id: option.id, + label: option.label, + key: option.key || null, + decision: option.decision || null, + response_index: responseIndex, + response_code: response +}}) + "\\n"; +$.NSFileHandle.fileHandleWithStandardOutput.writeData( + nsstr(result).dataUsingEncoding($.NSUTF8StringEncoding) +); +""".strip() + + +def build_simple_jxa(payload: dict[str, Any]) -> str: + buttons = [option["label"] for option in payload["options"]] + default_button = next( + (option["label"] for option in payload["options"] if option.get("default")), + buttons[-1], + ) + cancel_button = next( + (option["label"] for option in payload["options"] if option.get("cancel")), + None, + ) + + message_lines = [payload["message"]] + if payload.get("thread"): + message_lines.append(f"Thread: {payload['thread']}") + if payload.get("requester_pid"): + message_lines.append(f"Requester PID: {payload['requester_pid']}") + if payload.get("cwd"): + message_lines.append(f"Working dir: {payload['cwd']}") + if payload.get("reason"): + message_lines.append(f"Reason: {payload['reason']}") + if payload.get("permission_rule"): + message_lines.append(f"Permission rule: {payload['permission_rule']}") + if payload.get("host") and payload.get("kind") == "network": + message_lines.append(f"Host: {payload['host']}") + if payload.get("server_name") and payload.get("kind") == "elicitation": + message_lines.append(f"Server: {payload['server_name']}") + if payload.get("code"): + message_lines.append("") + message_lines.append(payload["code"]) + + payload_json = json.dumps(payload) + dialog_args = { + "withTitle": payload["title"], + "buttons": buttons, + "defaultButton": default_button, + } + if cancel_button is not None: + dialog_args["cancelButton"] = cancel_button + dialog_args_json = json.dumps(dialog_args) + message = "\n".join(message_lines) + + return f""" +var payload = {payload_json}; +var dialogArgs = {dialog_args_json}; +var app = Application.currentApplication(); +app.includeStandardAdditions = true; +app.activate(); + +var response = app.displayDialog({json.dumps(message)}, dialogArgs); +var button = response.buttonReturned(); +var selected = null; +for (var i = 0; i < payload.options.length; i++) {{ + if (payload.options[i].label === button) {{ + selected = payload.options[i]; + selected.response_index = i; + selected.response_code = String(1000 + i); + break; + }} +}} +if (selected === null) {{ + throw new Error("unknown dialog button returned: " + button); +}} + +var result = JSON.stringify(selected) + "\\n"; +$.NSFileHandle.fileHandleWithStandardOutput.writeData( + $(result).dataUsingEncoding($.NSUTF8StringEncoding) +); +""".strip() + + +def _debug_dir() -> Path | None: + debug_dir = os.environ.get(DEBUG_DIR_ENV) + debug_enabled = os.environ.get(DEBUG_ENV) + if debug_dir is None and debug_enabled != "1": + return None + base = Path(debug_dir) if debug_dir else Path(tempfile.gettempdir()) / "codex-approval-dialog" + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + path = base / f"{stamp}-{os.getpid()}" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _write_debug_file(debug_dir: Path | None, name: str, content: str) -> None: + if debug_dir is None: + return + (debug_dir / name).write_text(content, encoding="utf-8") + + +def _append_debug_event(debug_dir: Path | None, message: str) -> None: + if debug_dir is None: + return + timestamp = datetime.now().isoformat(timespec="milliseconds") + with (debug_dir / "timeline.log").open("a", encoding="utf-8") as handle: + handle.write(f"{timestamp} {message}\n") + + +def _read_stdin_text(debug_dir: Path | None) -> str: + stdin_fd = sys.stdin.fileno() + chunks: list[bytes] = [] + total_bytes = 0 + timeout_ms = max(int(os.environ.get(STDIN_TIMEOUT_ENV, DEFAULT_STDIN_TIMEOUT_MS)), 1) + _append_debug_event(debug_dir, "load_input:stdin_read:start") + while True: + readable, _, _ = select.select([stdin_fd], [], [], timeout_ms / 1000.0) + if not readable: + _append_debug_event( + debug_dir, + f"load_input:stdin_read:timeout total_bytes={total_bytes} timeout_ms={timeout_ms}", + ) + if total_bytes > 0: + _append_debug_event( + debug_dir, + f"load_input:stdin_read:progress_after_timeout total_bytes={total_bytes}", + ) + break + raise TimeoutError( + f"stdin read timed out after {timeout_ms} ms waiting for input" + ) + chunk = os.read(stdin_fd, 65536) + if not chunk: + _append_debug_event( + debug_dir, + f"load_input:stdin_read:eof total_bytes={total_bytes}", + ) + break + nul_index = chunk.find(b"\0") + if nul_index >= 0: + chunk = chunk[:nul_index] + if chunk: + chunks.append(chunk) + total_bytes += len(chunk) + _append_debug_event( + debug_dir, + f"load_input:stdin_read:nul_terminator total_bytes={total_bytes}", + ) + break + chunks.append(chunk) + total_bytes += len(chunk) + _append_debug_event( + debug_dir, + f"load_input:stdin_read:chunk bytes={len(chunk)} total_bytes={total_bytes}", + ) + return b"".join(chunks).decode("utf-8") + + +def run_dialog(payload: dict[str, Any], debug_dir: Path | None) -> dict[str, Any]: + _append_debug_event(debug_dir, "run_dialog:start") + raw_mode = "simple" if os.environ.get(SIMPLE_DIALOG_ENV) else "nsalert" + script = build_simple_jxa(payload) if raw_mode == "simple" else build_jxa(payload) + _write_debug_file(debug_dir, "normalized-payload.json", json.dumps(payload, indent=2)) + _write_debug_file(debug_dir, "dialog-mode.txt", raw_mode + "\n") + _append_debug_event(debug_dir, f"run_dialog:mode={raw_mode}") + temp_path: Path | None = None + try: + if debug_dir is not None: + temp_path = debug_dir / "dialog.js" + temp_path.write_text(script, encoding="utf-8") + else: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".js", prefix="codex_approval_", delete=False + ) as temp_file: + temp_file.write(script) + temp_path = Path(temp_file.name) + + command = ["osascript", "-l", "JavaScript", str(temp_path)] + _write_debug_file(debug_dir, "osascript-command.txt", " ".join(command) + "\n") + _write_debug_file(debug_dir, "run-status.txt", "starting\n") + _append_debug_event(debug_dir, f"run_dialog:command={' '.join(command)}") + + timeout_ms = os.environ.get(OSASCRIPT_TIMEOUT_ENV) + timeout = None + if timeout_ms: + timeout = max(int(timeout_ms), 1) / 1000.0 + _append_debug_event(debug_dir, f"run_dialog:timeout={timeout!r}") + + try: + _append_debug_event(debug_dir, "run_dialog:subprocess_run:start") + completed = subprocess.run( + command, + capture_output=True, + check=False, + text=True, + timeout=timeout, + ) + _append_debug_event( + debug_dir, + f"run_dialog:subprocess_run:done returncode={completed.returncode}", + ) + except subprocess.TimeoutExpired as exc: + _write_debug_file( + debug_dir, + "osascript-timeout.txt", + f"timeout_seconds={exc.timeout}\nstdout={exc.stdout or ''}\nstderr={exc.stderr or ''}\n", + ) + _append_debug_event(debug_dir, f"run_dialog:timeout_expired seconds={exc.timeout}") + raise RuntimeError(f"osascript timed out after {exc.timeout} seconds") from exc + finally: + if temp_path is not None and debug_dir is None: + temp_path.unlink(missing_ok=True) + + _write_debug_file(debug_dir, "osascript-stdout.txt", completed.stdout) + _write_debug_file(debug_dir, "osascript-stderr.txt", completed.stderr) + _write_debug_file(debug_dir, "osascript-returncode.txt", f"{completed.returncode}\n") + _write_debug_file(debug_dir, "run-status.txt", "completed\n") + _append_debug_event(debug_dir, "run_dialog:completed") + + if completed.returncode != 0: + stderr = completed.stderr.strip() + stdout = completed.stdout.strip() + _append_debug_event(debug_dir, "run_dialog:error:nonzero_return") + raise RuntimeError(stderr or stdout or "osascript failed") + + stdout = completed.stdout.strip() + if not stdout: + _append_debug_event(debug_dir, "run_dialog:error:no_stdout") + raise RuntimeError("osascript returned no output") + _append_debug_event(debug_dir, "run_dialog:parsing_stdout_json") + return json.loads(stdout) + + +def build_protocol_output(payload: dict[str, Any], selection: dict[str, Any]) -> dict[str, Any]: + protocol_output_type = payload.get("protocol_output_type") + decision = selection.get("decision") + + if protocol_output_type == "exec_approval": + approval_id = payload.get("approval_id") or payload.get("call_id") + if not approval_id: + raise ValueError("exec approval payload is missing approval_id/call_id") + result = { + "type": "exec_approval", + "id": approval_id, + "decision": decision, + } + turn_id = payload.get("turn_id") + if turn_id is not None: + result["turn_id"] = turn_id + return result + + if protocol_output_type == "patch_approval": + call_id = payload.get("call_id") + if not call_id: + raise ValueError("patch approval payload is missing call_id") + return { + "type": "patch_approval", + "id": call_id, + "decision": decision, + } + + if protocol_output_type == "resolve_elicitation": + server_name = payload.get("server_name") + request_id = payload.get("request_id") + if server_name is None or request_id is None: + raise ValueError("elicitation payload is missing server_name or request_id") + return { + "type": "resolve_elicitation", + "server_name": server_name, + "request_id": request_id, + "decision": decision, + } + + return selection + + +def build_test_payload() -> dict[str, Any]: + return { + "kind": "exec", + "window_title": "Approval Request", + "title": "Would you like to run the following command?", + "message": "Codex needs your approval before continuing.", + "thread_label": "Main [default]", + "thread_id": "test-thread-id", + "is_current_thread": True, + "reason": "Smoke-test the custom macOS approval dialog renderer.", + "cwd": "/Users/ebrevdo/code/codex", + "code": ( + "python -m pytest tests/test_example.py " + "--maxfail=1 -q --some-very-long-flag=value " + "--another-long-flag='wrapped command text should stay inside the code box'" + ), + "protocol_output_type": "exec_approval", + "approval_id": "test-approval-id", + "turn_id": "test-turn-id", + "options": [ + { + "id": "approved", + "label": "Yes, proceed", + "key": "y", + "default": True, + "decision": "approved", + }, + { + "id": "approved_for_session", + "label": "Yes, and do not ask again for this exact command during this session even if it is requested repeatedly from the same approval flow or a closely related one", + "key": "a", + "decision": "approved_for_session", + }, + { + "id": "abort", + "label": "No, and tell Codex what to do differently", + "key": "n", + "cancel": True, + "decision": "abort", + }, + ], + } + + +def load_input(path: str | None, *, test_mode: bool, debug_dir: Path | None) -> dict[str, Any]: + if path: + return json.loads(Path(path).read_text()) + if test_mode: + if sys.stdin.isatty(): + return build_test_payload() + readable, _, _ = select.select([sys.stdin], [], [], 0) + if not readable: + return build_test_payload() + raw_text = _read_stdin_text(debug_dir) + if test_mode and not raw_text.strip(): + return build_test_payload() + return json.loads(raw_text) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", help="Path to the JSON request payload") + parser.add_argument( + "--print-jxa", + action="store_true", + help="Print the generated JXA script and exit without running it", + ) + parser.add_argument( + "--normalize-only", + action="store_true", + help="Print the normalized request JSON and exit without running it", + ) + parser.add_argument( + "--test", + action="store_true", + help="Run a dialog smoke test; if no input is provided on stdin or via --input, use a built-in sample payload", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + debug_dir = _debug_dir() + try: + _append_debug_event(debug_dir, "main:start") + raw = load_input(args.input, test_mode=args.test, debug_dir=debug_dir) + _append_debug_event(debug_dir, "main:input_loaded") + _write_debug_file(debug_dir, "raw-input.json", json.dumps(raw, indent=2)) + payload = normalize_payload(raw) + _append_debug_event(debug_dir, "main:payload_normalized") + payload["thread"] = format_thread_summary(payload) + add_display_labels(payload["options"]) + payload["shortcuts_hint"] = build_shortcuts_hint(payload["options"]) + add_wrapped_dialog_fields(payload) + if args.normalize_only: + _append_debug_event(debug_dir, "main:normalize_only") + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + if args.print_jxa: + _append_debug_event(debug_dir, "main:print_jxa") + jxa = build_simple_jxa(payload) if os.environ.get(SIMPLE_DIALOG_ENV) else build_jxa(payload) + sys.stdout.write(jxa) + sys.stdout.write("\n") + return 0 + _append_debug_event(debug_dir, "main:run_dialog") + selection = run_dialog(payload, debug_dir) + _append_debug_event(debug_dir, "main:dialog_selection_received") + protocol_output = build_protocol_output(payload, selection) + result = ( + {"selection": selection, "protocol_output": protocol_output} + if args.test + else protocol_output + ) + _append_debug_event(debug_dir, "main:protocol_output_built") + except Exception as exc: # pragma: no cover - CLI error path + _append_debug_event(debug_dir, f"main:error {exc}") + _write_debug_file(debug_dir, "python-error.txt", f"{exc}\n") + json.dump({"error": str(exc)}, sys.stderr) + sys.stderr.write("\n") + return 1 + + _append_debug_event(debug_dir, "main:success") + json.dump(result, sys.stdout) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())