Compare commits

...

1 Commits

Author SHA1 Message Date
Eugene Brevdo
dbaf411b93 Add external approval handler support
Add config-driven external approval handling in core, surface handler failures in the TUI, and share thread labels across core and TUI.

Check in the macOS approval dialog helper under scripts/, document the config path, and fix handler stdin delivery by dropping the child stdin handle after writes so EOF reaches the helper promptly.

Co-authored-by: Codex <noreply@openai.com>
2026-03-03 19:35:30 -08:00
17 changed files with 2355 additions and 82 deletions

View File

@@ -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": [
{

View File

@@ -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<u8>,
stderr: Vec<u8>,
}
pub(crate) async fn request_exec_approval(
config: &ApprovalHandlerConfig,
thread_id: ThreadId,
thread_label: Option<&str>,
event: &ExecApprovalRequestEvent,
) -> Result<ReviewDecision> {
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<ReviewDecision> {
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<ElicitationAction> {
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<T: Serialize>(
config: &ApprovalHandlerConfig,
request: &T,
) -> Result<Op> {
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::<Op>(&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<u8>,
timeout: Duration,
) -> Result<CommandOutput> {
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"
);
}
}

View File

@@ -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<PermissionProfile>,
available_decisions: Option<Vec<ReviewDecision>>,
) -> 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<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
// 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,

View File

@@ -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<Vec<String>>,
/// Optional external approval handler command that synchronously resolves
/// exec, patch, and MCP elicitation approvals over stdin/stdout.
pub approval_handler: Option<ApprovalHandlerConfig>,
/// 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<Vec<String>>,
/// Optional external command to synchronously resolve approvals over stdin/stdout.
#[serde(default)]
pub approval_handler: Option<ApprovalHandlerToml>,
/// System instructions.
pub instructions: Option<String>,
@@ -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()),

View File

@@ -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<Vec<String>>,
/// Timeout for the handler process, in milliseconds.
pub timeout_ms: Option<u64>,
/// Behavior when the handler exits non-zero, times out, or returns invalid JSON.
#[serde(default)]
pub on_error: Option<ApprovalHandlerOnError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalHandlerConfig {
pub command: Vec<String>,
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<Option<ApprovalHandlerConfig>> {
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.

View File

@@ -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(),

View File

@@ -8,6 +8,7 @@
mod analytics_client;
pub mod api_bridge;
mod apply_patch;
mod approval_handler;
mod apps;
pub mod auth;
mod client;

View File

@@ -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(),

View File

@@ -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<Mutex<ResponderMap>>,
approval_policy: Arc<StdMutex<AskForApproval>>,
approval_handler: Arc<StdMutex<Option<ApprovalHandlerConfig>>>,
thread_id: ThreadId,
thread_label: Option<String>,
}
impl ElicitationRequestManager {
fn new(approval_policy: AskForApproval) -> Self {
fn new(
approval_policy: AskForApproval,
approval_handler: Option<ApprovalHandlerConfig>,
thread_id: ThreadId,
thread_label: Option<String>,
) -> 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<Event>) -> 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<AskForApproval>) -> Self {
pub(crate) fn new_uninitialized(
approval_policy: &Constrained<AskForApproval>,
approval_handler: Option<ApprovalHandlerConfig>,
thread_id: ThreadId,
thread_label: Option<String>,
) -> 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<AskForApproval>,
approval_handler: Option<ApprovalHandlerConfig>,
thread_id: ThreadId,
thread_label: Option<String>,
) -> 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<String, McpAuthStatusEntry>,
approval_policy: &Constrained<AskForApproval>,
approval_handler: Option<ApprovalHandlerConfig>,
thread_id: ThreadId,
thread_label: Option<String>,
tx_event: Sender<Event>,
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(),

View File

@@ -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<StartedNetworkProxy>,
pub(crate) network_approval: Arc<NetworkApprovalService>,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) approval_handler: Option<ApprovalHandlerConfig>,
/// Session-scoped model client shared across turns.
pub(crate) model_client: ModelClient,
}

View File

@@ -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<()> {

View File

@@ -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<String> {
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) {

View File

@@ -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<DynamicImage> {
#[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;

View File

@@ -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<Span<'static
vec![dot, " ".into()]
}
pub(crate) fn format_agent_picker_item_name(
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(),
}
}
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

View File

@@ -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

View File

@@ -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`.

File diff suppressed because it is too large Load Diff