mirror of
https://github.com/openai/codex.git
synced 2026-03-04 21:53:21 +00:00
Compare commits
3 Commits
pr13453
...
external_a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290beeadb1 | ||
|
|
f7b242b81d | ||
|
|
dbaf411b93 |
@@ -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": [
|
||||
{
|
||||
|
||||
456
codex-rs/core/src/approval_handler.rs
Normal file
456
codex-rs/core/src/approval_handler.rs
Normal 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.as_deref(), Some(event.turn_id.as_str()));
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
mod analytics_client;
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod approval_handler;
|
||||
mod apps;
|
||||
pub mod auth;
|
||||
mod client;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
let _ = 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<()> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
1268
scripts/macos_approval_dialog.py
Normal file
1268
scripts/macos_approval_dialog.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user