Distinguish MCP approval cancellation reasons (#54679)

## Why

MCP tool calls reported every aborted approval as "user cancelled MCP tool call", including interrupted requests and automatic cancellations in `exec` mode.

## What changed

Carry `user`, `interrupted`, or `non_interactive` cancellation reasons through MCP approval metadata and resolution. Mark explicit TUI cancellations as user actions, interrupted requests as interruptions, and automatic `exec` cancellations as non-interactive.

Use the reason to report when a call was cancelled before approval or because interactive approval is unavailable in `exec` mode. Preserve the existing cancellation message for clients that omit the reason.

## Testing

Update tests to verify that a closed approval response carries the interruption reason and that `exec` cancellation responses include the non-interactive reason.

GitOrigin-RevId: be2a5f7fef539b5db2add53b46aea27e2b8a66fc
This commit is contained in:
vkg-oai
2026-10-16 19:08:19 +00:00
committed by copyberry
parent e7de1f4440
commit a083a1e49e
10 changed files with 235 additions and 61 deletions
@@ -94,6 +94,10 @@ use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool; use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::ModelInvocationContext; use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY;
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::Event; use codex_protocol::protocol::Event;
@@ -874,6 +878,11 @@ pub(crate) async fn apply_bespoke_event_handling(
}); });
} }
EventMsg::ElicitationRequest(request) => { EventMsg::ElicitationRequest(request) => {
let mcp_tool_approval = matches!(
&request.request,
codex_protocol::approvals::ElicitationRequest::Form { meta: Some(meta), .. }
if meta.get(APPROVAL_KIND_KEY).and_then(serde_json::Value::as_str) == Some(APPROVAL_KIND_MCP_TOOL_CALL)
);
let user_verification = matches!( let user_verification = matches!(
&request.request, &request.request,
codex_protocol::approvals::ElicitationRequest::UserVerification { .. } codex_protocol::approvals::ElicitationRequest::UserVerification { .. }
@@ -930,6 +939,7 @@ pub(crate) async fn apply_bespoke_event_handling(
PendingMcpElicitationResponse { PendingMcpElicitationResponse {
receiver: rx, receiver: rx,
user_verification, user_verification,
mcp_tool_approval,
}, },
conversation, conversation,
thread_state, thread_state,
@@ -1723,6 +1733,7 @@ async fn on_request_user_input_response(
struct PendingMcpElicitationResponse { struct PendingMcpElicitationResponse {
receiver: oneshot::Receiver<ClientRequestResult>, receiver: oneshot::Receiver<ClientRequestResult>,
user_verification: bool, user_verification: bool,
mcp_tool_approval: bool,
} }
async fn on_mcp_server_elicitation_response( async fn on_mcp_server_elicitation_response(
@@ -1737,12 +1748,30 @@ async fn on_mcp_server_elicitation_response(
let response = pending.receiver.await; let response = pending.receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(permission_guard); drop(permission_guard);
let response = if pending.user_verification { let interrupted = !matches!(&response, Ok(Ok(_)));
let mut response = if pending.user_verification {
crate::user_verification_response::from_client_result(response) crate::user_verification_response::from_client_result(response)
} else { } else {
mcp_server_elicitation_response_from_client_result(response) mcp_server_elicitation_response_from_client_result(response)
}; };
if pending.mcp_tool_approval
&& interrupted
&& response.action == McpServerElicitationAction::Cancel
{
// Only the synthesized response is ours to attribute. Successful client
// replies retain their provenance, including an absent reason.
let mut meta = match response.meta.take() {
Some(serde_json::Value::Object(meta)) => meta,
_ => serde_json::Map::new(),
};
meta.insert(
CANCELLATION_REASON_KEY.to_string(),
serde_json::json!(McpApprovalCancellationReason::Interrupted),
);
response.meta = Some(serde_json::Value::Object(meta));
}
if let Err(err) = conversation if let Err(err) = conversation
.submit(Op::ResolveElicitation { .submit(Op::ResolveElicitation {
server_name, server_name,
+75 -12
View File
@@ -16,6 +16,7 @@ use crate::session::session::Session;
use crate::session::step_context::StepContext; use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnContext;
use crate::tools::ApprovalContext; use crate::tools::ApprovalContext;
use crate::tools::ApprovalResolution;
use crate::tools::ApprovalResolutionSource; use crate::tools::ApprovalResolutionSource;
use crate::tools::context::ToolCallOrigin; use crate::tools::context::ToolCallOrigin;
use crate::tools::hook_names::HookToolName; use crate::tools::hook_names::HookToolName;
@@ -60,9 +61,11 @@ use codex_protocol::mcp::is_node_repl_backed_connector;
use codex_protocol::mcp::is_node_repl_backed_server; use codex_protocol::mcp::is_node_repl_backed_server;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_TOOL_APPROVAL_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_TOOL_APPROVAL_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_DESCRIPTION_KEY as MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_ID_KEY as MCP_TOOL_APPROVAL_CONNECTOR_ID_KEY;
use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY; use codex_protocol::mcp_approval_meta::CONNECTOR_NAME_KEY as MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY;
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as MCP_TOOL_APPROVAL_PERSIST_ALWAYS; use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as MCP_TOOL_APPROVAL_PERSIST_ALWAYS;
use codex_protocol::mcp_approval_meta::PERSIST_KEY as MCP_TOOL_APPROVAL_PERSIST_KEY; use codex_protocol::mcp_approval_meta::PERSIST_KEY as MCP_TOOL_APPROVAL_PERSIST_KEY;
use codex_protocol::mcp_approval_meta::PERSIST_SESSION as MCP_TOOL_APPROVAL_PERSIST_SESSION; use codex_protocol::mcp_approval_meta::PERSIST_SESSION as MCP_TOOL_APPROVAL_PERSIST_SESSION;
@@ -344,7 +347,20 @@ pub(crate) async fn handle_mcp_tool_call(
.await .await
} }
ReviewDecision::Abort => { ReviewDecision::Abort => {
let message = "user cancelled MCP tool call".to_string(); let message = match approval.cancellation_reason {
// Older clients do not send provenance. Preserve their
// cancellation behavior without assigning them a user reason.
Some(McpApprovalCancellationReason::User) | None => {
"user cancelled MCP tool call"
}
Some(McpApprovalCancellationReason::Interrupted) => {
"MCP tool call cancelled before approval"
}
Some(McpApprovalCancellationReason::NonInteractive) => {
"MCP tool call cancelled: interactive approval is unavailable in exec mode"
}
}
.to_string();
notify_mcp_tool_call_skip( notify_mcp_tool_call_skip(
sess.as_ref(), sess.as_ref(),
turn_context.as_ref(), turn_context.as_ref(),
@@ -1484,6 +1500,7 @@ fn mcp_tool_approval_prompt_options(
struct McpToolApproval { struct McpToolApproval {
decision: ReviewDecision, decision: ReviewDecision,
source: Option<ApprovalResolutionSource>, source: Option<ApprovalResolutionSource>,
cancellation_reason: Option<McpApprovalCancellationReason>,
} }
#[expect(clippy::too_many_arguments)] #[expect(clippy::too_many_arguments)]
@@ -1544,6 +1561,7 @@ async fn maybe_request_mcp_tool_approval(
return Some(McpToolApproval { return Some(McpToolApproval {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: None, source: None,
cancellation_reason: None,
}); });
} }
@@ -1593,6 +1611,7 @@ async fn maybe_request_mcp_tool_approval(
Err(ToolError::Codex(_)) => ReviewDecision::Abort, Err(ToolError::Codex(_)) => ReviewDecision::Abort,
}, },
source: approval.source, source: approval.source,
cancellation_reason: approval.cancellation_reason,
}) })
} }
@@ -1601,7 +1620,7 @@ pub(crate) async fn request_mcp_tool_user_approval(
turn_context: &TurnContext, turn_context: &TurnContext,
call_id: &str, call_id: &str,
action: &ApprovalAction, action: &ApprovalAction,
) -> ReviewDecision { ) -> ApprovalResolution {
let ApprovalAction::McpToolCall { let ApprovalAction::McpToolCall {
id, id,
server, server,
@@ -1624,9 +1643,13 @@ pub(crate) async fn request_mcp_tool_user_approval(
}; };
if *approval_policy == AskForApproval::Never { if *approval_policy == AskForApproval::Never {
return ReviewDecision::denied( return ApprovalResolution {
"MCP tool call requires approval, but approval policy is never", decision: ReviewDecision::denied(
); "MCP tool call requires approval, but approval policy is never",
),
source: ApprovalResolutionSource::User,
cancellation_reason: None,
};
} }
let tool_call_mcp_elicitation_enabled = turn_context let tool_call_mcp_elicitation_enabled = turn_context
@@ -1660,7 +1683,7 @@ pub(crate) async fn request_mcp_tool_user_approval(
.as_ref() .as_ref()
.map(|rendered_template| rendered_template.question.as_str()), .map(|rendered_template| rendered_template.question.as_str()),
); );
let (request_dispatched, decision) = if tool_call_mcp_elicitation_enabled { let (request_dispatched, decision, cancellation_reason) = if tool_call_mcp_elicitation_enabled {
let link_id = sess let link_id = sess
.mcp_tool_approval_metadata(server, id) .mcp_tool_approval_metadata(server, id)
.and_then(|context| context.metadata.link_id); .and_then(|context| context.metadata.link_id);
@@ -1698,17 +1721,33 @@ pub(crate) async fn request_mcp_tool_user_approval(
let outcome = sess let outcome = sess
.request_mcp_server_elicitation(turn_context, server.clone(), request_id, request) .request_mcp_server_elicitation(turn_context, server.clone(), request_id, request)
.await; .await;
// Missing replies are interruptions. A received Cancel needs provenance
// from its source; RPC success alone does not establish a user action.
let cancellation_reason = match outcome.response.as_ref() {
None => Some(McpApprovalCancellationReason::Interrupted),
Some(response) if response.action == ElicitationAction::Cancel => response
.meta
.as_ref()
.and_then(|meta| meta.get(CANCELLATION_REASON_KEY))
.and_then(|reason| serde_json::from_value(reason.clone()).ok()),
Some(_) => None,
};
( (
outcome.sent, outcome.sent,
parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id), parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id),
cancellation_reason,
) )
} else { } else {
if turn_context.session_source.is_non_root_agent() { if turn_context.session_source.is_non_root_agent() {
return ReviewDecision::denied(concat!( return ApprovalResolution {
"MCP tool approval via request_user_input requires the root thread. ", decision: ReviewDecision::denied(concat!(
"Ask the parent agent to handle this request. ", "MCP tool approval via request_user_input requires the root thread. ",
"Do not retry the blocked action until the parent confirms the blocker is resolved." "Ask the parent agent to handle this request. ",
)); "Do not retry the blocked action until the parent confirms the blocker is resolved."
)),
source: ApprovalResolutionSource::User,
cancellation_reason: None,
};
} }
let args = RequestUserInputArgs { let args = RequestUserInputArgs {
questions: vec![question], questions: vec![question],
@@ -1718,12 +1757,28 @@ pub(crate) async fn request_mcp_tool_user_approval(
let response = sess let response = sess
.request_user_input(turn_context, call_id.to_string(), args) .request_user_input(turn_context, call_id.to_string(), args)
.await; .await;
let cancellation_reason = response
.as_ref()
.and_then(|accepted| accepted.response.answers.get(&question_id))
.filter(|answer| {
answer
.answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_CANCEL)
})
.map(|_| McpApprovalCancellationReason::User)
.or_else(|| {
response
.is_none()
.then_some(McpApprovalCancellationReason::Interrupted)
});
( (
true, true,
parse_mcp_tool_approval_response( parse_mcp_tool_approval_response(
response.map(|accepted| accepted.response), response.map(|accepted| accepted.response),
&question_id, &question_id,
), ),
cancellation_reason,
) )
}; };
let decision = normalize_approval_decision_for_mode(decision, *approval_mode); let decision = normalize_approval_decision_for_mode(decision, *approval_mode);
@@ -1735,7 +1790,15 @@ pub(crate) async fn request_mcp_tool_user_approval(
{ {
track_mcp_tool_call_elicitation(sess, turn_context, call_id, ElicitationType::Approval); track_mcp_tool_call_elicitation(sess, turn_context, call_id, ElicitationType::Approval);
} }
decision ApprovalResolution {
cancellation_reason: if decision == ReviewDecision::Abort {
cancellation_reason
} else {
None
},
decision,
source: ApprovalResolutionSource::User,
}
} }
fn session_mcp_tool_approval_key( fn session_mcp_tool_approval_key(
+10 -1
View File
@@ -2330,7 +2330,11 @@ async fn dispatched_mcp_approval_with_closed_response_is_classified_as_approval(
*session.active_turn.lock().await = None; *session.active_turn.lock().await = None;
assert_eq!( assert_eq!(
approval.await.expect("approval task should complete"), approval.await.expect("approval task should complete"),
ReviewDecision::Abort ApprovalResolution {
decision: ReviewDecision::Abort,
source: ApprovalResolutionSource::User,
cancellation_reason: Some(McpApprovalCancellationReason::Interrupted),
}
); );
item["status"] = serde_json::json!("failed"); item["status"] = serde_json::json!("failed");
@@ -3002,6 +3006,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
Some(McpToolApproval { Some(McpToolApproval {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: Some(ApprovalResolutionSource::Hook), source: Some(ApprovalResolutionSource::Hook),
cancellation_reason: None,
}) })
); );
let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log"); let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");
@@ -3079,6 +3084,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
Some(McpToolApproval { Some(McpToolApproval {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: Some(ApprovalResolutionSource::Hook), source: Some(ApprovalResolutionSource::Hook),
cancellation_reason: None,
}) })
); );
let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log"); let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");
@@ -3172,6 +3178,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
Some(McpToolApproval { Some(McpToolApproval {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: None, source: None,
cancellation_reason: None,
}) })
); );
assert!( assert!(
@@ -3276,6 +3283,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
let Some(McpToolApproval { let Some(McpToolApproval {
decision: ReviewDecision::Denied { rejection: message }, decision: ReviewDecision::Denied { rejection: message },
source: Some(ApprovalResolutionSource::Guardian), source: Some(ApprovalResolutionSource::Guardian),
cancellation_reason: None,
}) = decision }) = decision
else { else {
panic!("guardian-denied MCP approval should carry a rejection message"); panic!("guardian-denied MCP approval should carry a rejection message");
@@ -3391,6 +3399,7 @@ async fn assert_mcp_user_approval_persistence(
Some(McpToolApproval { Some(McpToolApproval {
decision: expected_decision, decision: expected_decision,
source: Some(ApprovalResolutionSource::User), source: Some(ApprovalResolutionSource::User),
cancellation_reason: None,
}), }),
); );
} }
+53 -39
View File
@@ -30,6 +30,7 @@ use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErr;
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
@@ -404,12 +405,14 @@ pub(crate) enum ApprovalResolutionSource {
pub(crate) struct ApprovalResult { pub(crate) struct ApprovalResult {
pub(crate) result: Result<ReviewDecision, ToolError>, pub(crate) result: Result<ReviewDecision, ToolError>,
pub(crate) source: Option<ApprovalResolutionSource>, pub(crate) source: Option<ApprovalResolutionSource>,
pub(crate) cancellation_reason: Option<McpApprovalCancellationReason>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct ApprovalResolution { pub(crate) struct ApprovalResolution {
decision: ReviewDecision, pub(crate) decision: ReviewDecision,
source: ApprovalResolutionSource, pub(crate) source: ApprovalResolutionSource,
pub(crate) cancellation_reason: Option<McpApprovalCancellationReason>,
} }
impl ApprovalResolution { impl ApprovalResolution {
@@ -466,6 +469,7 @@ impl Session {
return ApprovalResult { return ApprovalResult {
result: Err(ToolError::Rejected(reason.to_string())), result: Err(ToolError::Rejected(reason.to_string())),
source: None, source: None,
cancellation_reason: None,
}; };
} }
let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. }); let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. });
@@ -490,10 +494,12 @@ impl Session {
Some(PermissionRequestDecision::Allow) => ApprovalResolution { Some(PermissionRequestDecision::Allow) => ApprovalResolution {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: ApprovalResolutionSource::Hook, source: ApprovalResolutionSource::Hook,
cancellation_reason: None,
}, },
Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution { Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
decision: ReviewDecision::denied(message), decision: ReviewDecision::denied(message),
source: ApprovalResolutionSource::Hook, source: ApprovalResolutionSource::Hook,
cancellation_reason: None,
}, },
None => self.request_reviewer_approval(action, &ctx).await, None => self.request_reviewer_approval(action, &ctx).await,
}; };
@@ -502,10 +508,12 @@ impl Session {
record_resolution(&ctx, &resolution); record_resolution(&ctx, &resolution);
} }
let source = Some(resolution.source); let source = Some(resolution.source);
let cancellation_reason = resolution.cancellation_reason;
if is_mcp_tool_call && resolution.decision == ReviewDecision::ApprovedMcpPolicyAmendment { if is_mcp_tool_call && resolution.decision == ReviewDecision::ApprovedMcpPolicyAmendment {
return ApprovalResult { return ApprovalResult {
result: Ok(resolution.decision), result: Ok(resolution.decision),
source, source,
cancellation_reason,
}; };
} }
if is_network_approval { if is_network_approval {
@@ -519,6 +527,7 @@ impl Session {
return ApprovalResult { return ApprovalResult {
result: Ok(resolution.decision), result: Ok(resolution.decision),
source, source,
cancellation_reason,
}; };
} }
(ReviewDecision::Abort, ApprovalResolutionSource::Guardian) => { (ReviewDecision::Abort, ApprovalResolutionSource::Guardian) => {
@@ -527,6 +536,7 @@ impl Session {
"automatic approval review was cancelled".to_string(), "automatic approval review was cancelled".to_string(),
)), )),
source, source,
cancellation_reason,
}; };
} }
_ => {} _ => {}
@@ -535,6 +545,7 @@ impl Session {
ApprovalResult { ApprovalResult {
result: resolution.into_tool_result(ctx.review_context.turn().model_info()), result: resolution.into_tool_result(ctx.review_context.turn().model_info()),
source, source,
cancellation_reason,
} }
} }
@@ -545,14 +556,13 @@ impl Session {
) -> ApprovalResolution { ) -> ApprovalResolution {
if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await { if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
ApprovalResolution { ApprovalResolution {
cancellation_reason: (decision == ReviewDecision::Abort)
.then_some(McpApprovalCancellationReason::Interrupted),
decision, decision,
source: ApprovalResolutionSource::Guardian, source: ApprovalResolutionSource::Guardian,
} }
} else { } else {
ApprovalResolution { self.request_user_approval(&action, ctx).await
decision: self.request_user_approval(&action, ctx).await,
source: ApprovalResolutionSource::User,
}
} }
} }
@@ -667,8 +677,8 @@ impl Session {
&self, &self,
action: &ApprovalAction, action: &ApprovalAction,
ctx: &ApprovalContext, ctx: &ApprovalContext,
) -> ReviewDecision { ) -> ApprovalResolution {
match action { let decision = match action {
ApprovalAction::ExecCommand { ApprovalAction::ExecCommand {
environment_id, environment_id,
command, command,
@@ -760,44 +770,43 @@ impl Session {
.clone() .clone()
.or_else(|| ctx.approval_reason.clone()); .or_else(|| ctx.approval_reason.clone());
if *permissions_preapproved && reason.is_none() { if *permissions_preapproved && reason.is_none() {
return ReviewDecision::Approved; ReviewDecision::Approved
} else if reason.is_some() {
self.request_patch_approval(
ctx.review_context.turn(),
ctx.call_id.clone(),
changes.as_ref().clone(),
reason,
/*grant_root*/ None,
)
.await
} else {
with_cached_approval(
&self.services,
"apply_patch",
action.cache_keys(),
|| async {
self.request_patch_approval(
ctx.review_context.turn(),
ctx.call_id.clone(),
changes.as_ref().clone(),
/*reason*/ None,
/*grant_root*/ None,
)
.await
},
)
.await
} }
if reason.is_some() {
return self
.request_patch_approval(
ctx.review_context.turn(),
ctx.call_id.clone(),
changes.as_ref().clone(),
reason,
/*grant_root*/ None,
)
.await;
}
with_cached_approval(
&self.services,
"apply_patch",
action.cache_keys(),
|| async {
self.request_patch_approval(
ctx.review_context.turn(),
ctx.call_id.clone(),
changes.as_ref().clone(),
/*reason*/ None,
/*grant_root*/ None,
)
.await
},
)
.await
} }
ApprovalAction::McpToolCall { .. } => { ApprovalAction::McpToolCall { .. } => {
request_mcp_tool_user_approval( return request_mcp_tool_user_approval(
self, self,
ctx.review_context.turn(), ctx.review_context.turn(),
&ctx.call_id, &ctx.call_id,
action, action,
) )
.await .await;
} }
ApprovalAction::NetworkAccess { ApprovalAction::NetworkAccess {
environment_id, environment_id,
@@ -826,6 +835,11 @@ impl Session {
ApprovalAction::RequestPermissions { .. } => { ApprovalAction::RequestPermissions { .. } => {
unreachable!("permission requests are routed directly to Guardian") unreachable!("permission requests are routed directly to Guardian")
} }
};
ApprovalResolution {
decision,
source: ApprovalResolutionSource::User,
cancellation_reason: None,
} }
} }
} }
@@ -14,6 +14,7 @@ fn approval_resolution_rejects_denied_network_policy_amendment() {
}, },
}, },
source: ApprovalResolutionSource::User, source: ApprovalResolutionSource::User,
cancellation_reason: None,
}; };
assert!(matches!( assert!(matches!(
@@ -27,6 +28,7 @@ fn approval_resolution_rejects_mcp_policy_amendment() {
let resolution = ApprovalResolution { let resolution = ApprovalResolution {
decision: ReviewDecision::ApprovedMcpPolicyAmendment, decision: ReviewDecision::ApprovedMcpPolicyAmendment,
source: ApprovalResolutionSource::User, source: ApprovalResolutionSource::User,
cancellation_reason: None,
}; };
assert!(matches!( assert!(matches!(
@@ -40,6 +42,7 @@ fn approval_resolution_aborts_turn_when_approval_is_aborted() {
let resolution = ApprovalResolution { let resolution = ApprovalResolution {
decision: ReviewDecision::Abort, decision: ReviewDecision::Abort,
source: ApprovalResolutionSource::User, source: ApprovalResolutionSource::User,
cancellation_reason: None,
}; };
assert!(matches!( assert!(matches!(
@@ -67,6 +70,7 @@ fn approval_resolution_uses_acting_model_timeout_instructions() {
let resolution = ApprovalResolution { let resolution = ApprovalResolution {
decision: ReviewDecision::TimedOut, decision: ReviewDecision::TimedOut,
source: ApprovalResolutionSource::Guardian, source: ApprovalResolutionSource::Guardian,
cancellation_reason: None,
}; };
assert!(matches!( assert!(matches!(
@@ -167,11 +171,13 @@ async fn non_utf8_cwd_preserves_approval_routing(
ApprovalResolution { ApprovalResolution {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: ApprovalResolutionSource::User, source: ApprovalResolutionSource::User,
cancellation_reason: None,
} }
} else { } else {
ApprovalResolution { ApprovalResolution {
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
source: ApprovalResolutionSource::Guardian, source: ApprovalResolutionSource::Guardian,
cancellation_reason: None,
} }
}; };
assert_eq!(approval.await, expected); assert_eq!(approval.await, expected);
+1
View File
@@ -29,6 +29,7 @@ use std::borrow::Cow;
use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnContext;
pub(crate) use approvals::ApprovalContext; pub(crate) use approvals::ApprovalContext;
pub(crate) use approvals::ApprovalResolution;
pub(crate) use approvals::ApprovalResolutionSource; pub(crate) use approvals::ApprovalResolutionSource;
use codex_features::Feature; use codex_features::Feature;
use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::ExecToolCallOutput;
+17 -4
View File
@@ -27,6 +27,7 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::RequestId; use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartParams;
@@ -96,6 +97,10 @@ use codex_protocol::SessionId;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY;
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
@@ -2002,11 +2007,19 @@ fn resume_lookup_model_providers(
} }
} }
fn canceled_mcp_server_elicitation_response() -> Result<Value, String> { fn canceled_mcp_server_elicitation_response(
request: &McpServerElicitationRequest,
) -> Result<Value, String> {
let tool_approval = matches!(request, McpServerElicitationRequest::Form { meta: Some(meta), .. }
if meta.get(APPROVAL_KIND_KEY).and_then(Value::as_str) == Some(APPROVAL_KIND_MCP_TOOL_CALL));
serde_json::to_value(McpServerElicitationRequestResponse { serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Cancel, action: McpServerElicitationAction::Cancel,
content: None, content: None,
meta: None, meta: tool_approval.then(|| {
serde_json::json!({
CANCELLATION_REASON_KEY: McpApprovalCancellationReason::NonInteractive,
})
}),
}) })
.map_err(|err| format!("failed to encode mcp elicitation response: {err}")) .map_err(|err| format!("failed to encode mcp elicitation response: {err}"))
} }
@@ -2077,11 +2090,11 @@ async fn handle_server_request(
) { ) {
let method = server_request_method_name(&request); let method = server_request_method_name(&request);
let handle_result = match request { let handle_result = match request {
ServerRequest::McpServerElicitationRequest { request_id, .. } => { ServerRequest::McpServerElicitationRequest { request_id, params } => {
// Exec auto-cancels elicitation instead of surfacing it // Exec auto-cancels elicitation instead of surfacing it
// interactively. Preserve that behavior for attached subagent // interactively. Preserve that behavior for attached subagent
// threads too so we do not turn a cancel into a decline/error. // threads too so we do not turn a cancel into a decline/error.
match canceled_mcp_server_elicitation_response() { match canceled_mcp_server_elicitation_response(&params.request) {
Ok(value) => { Ok(value) => {
resolve_server_request( resolve_server_request(
client, client,
+11 -2
View File
@@ -462,7 +462,14 @@ fn should_backfill_turn_completed_items_backfills_persisted_summaries_only() {
#[test] #[test]
fn canceled_mcp_server_elicitation_response_uses_cancel_action() { fn canceled_mcp_server_elicitation_response_uses_cancel_action() {
let value = canceled_mcp_server_elicitation_response() let request = serde_json::from_value(serde_json::json!({
"mode": "form",
"message": "Allow the tool call?",
"requestedSchema": { "type": "object", "properties": {} },
"_meta": { APPROVAL_KIND_KEY: APPROVAL_KIND_MCP_TOOL_CALL },
}))
.expect("valid MCP tool approval request");
let value = canceled_mcp_server_elicitation_response(&request)
.expect("mcp elicitation cancel response should serialize"); .expect("mcp elicitation cancel response should serialize");
let response: McpServerElicitationRequestResponse = let response: McpServerElicitationRequestResponse =
serde_json::from_value(value).expect("cancel response should deserialize"); serde_json::from_value(value).expect("cancel response should deserialize");
@@ -472,7 +479,9 @@ fn canceled_mcp_server_elicitation_response_uses_cancel_action() {
McpServerElicitationRequestResponse { McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Cancel, action: McpServerElicitationAction::Cancel,
content: None, content: None,
meta: None, meta: Some(serde_json::json!({
CANCELLATION_REASON_KEY: McpApprovalCancellationReason::NonInteractive,
})),
} }
); );
} }
@@ -17,6 +17,18 @@ pub const APPROVALS_REVIEWER_KEY: &str = "approvals_reviewer";
pub const PERSIST_KEY: &str = "persist"; pub const PERSIST_KEY: &str = "persist";
pub const PERSIST_SESSION: &str = "session"; pub const PERSIST_SESSION: &str = "session";
pub const PERSIST_ALWAYS: &str = "always"; pub const PERSIST_ALWAYS: &str = "always";
/// Set where cancellation originates, before user actions and runtime failures
/// are flattened into the same elicitation action. Absence means unknown.
pub const CANCELLATION_REASON_KEY: &str = "codex_approval_cancellation_reason";
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpApprovalCancellationReason {
User,
Interrupted,
NonInteractive,
}
pub const SOURCE_KEY: &str = "source"; pub const SOURCE_KEY: &str = "source";
pub const SOURCE_CONNECTOR: &str = "connector"; pub const SOURCE_CONNECTOR: &str = "connector";
pub const CONNECTOR_ID_KEY: &str = "connector_id"; pub const CONNECTOR_ID_KEY: &str = "connector_id";
@@ -15,6 +15,8 @@ use codex_protocol::ThreadId;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION; use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION;
use codex_protocol::mcp_approval_meta::CANCELLATION_REASON_KEY;
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE; use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE;
use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY; use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY;
use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE; use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE;
@@ -175,6 +177,7 @@ pub(crate) struct McpServerElicitationFormRequest {
message: String, message: String,
approval_display_params: Vec<McpToolApprovalDisplayParam>, approval_display_params: Vec<McpToolApprovalDisplayParam>,
response_mode: McpServerElicitationResponseMode, response_mode: McpServerElicitationResponseMode,
is_tool_approval: bool,
fields: Vec<McpServerElicitationField>, fields: Vec<McpServerElicitationField>,
tool_suggestion: Option<ToolSuggestionRequest>, tool_suggestion: Option<ToolSuggestionRequest>,
} }
@@ -330,6 +333,7 @@ impl McpServerElicitationFormRequest {
message, message,
approval_display_params, approval_display_params,
response_mode, response_mode,
is_tool_approval,
fields, fields,
tool_suggestion, tool_suggestion,
}) })
@@ -1151,7 +1155,11 @@ impl McpServerElicitationOverlay {
self.request.request_id.clone(), self.request.request_id.clone(),
McpServerElicitationAction::Cancel, McpServerElicitationAction::Cancel,
/*content*/ None, /*content*/ None,
/*meta*/ None, self.request.is_tool_approval.then(|| {
serde_json::json!({
CANCELLATION_REASON_KEY: McpApprovalCancellationReason::User,
})
}),
); );
} }
@@ -1190,7 +1198,14 @@ impl McpServerElicitationOverlay {
})), })),
), ),
Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None), Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None),
Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None), Some(APPROVAL_CANCEL_VALUE) => (
McpServerElicitationAction::Cancel,
self.request.is_tool_approval.then(|| {
serde_json::json!({
CANCELLATION_REASON_KEY: McpApprovalCancellationReason::User,
})
}),
),
_ => (McpServerElicitationAction::Cancel, None), _ => (McpServerElicitationAction::Cancel, None),
}; };
self.app_event_tx.resolve_elicitation( self.app_event_tx.resolve_elicitation(
@@ -1875,6 +1890,7 @@ mod tests {
message: "Allow this request?".to_string(), message: "Allow this request?".to_string(),
approval_display_params: Vec::new(), approval_display_params: Vec::new(),
response_mode: McpServerElicitationResponseMode::FormContent, response_mode: McpServerElicitationResponseMode::FormContent,
is_tool_approval: false,
fields: vec![McpServerElicitationField { fields: vec![McpServerElicitationField {
id: "confirmed".to_string(), id: "confirmed".to_string(),
label: "Confirm".to_string(), label: "Confirm".to_string(),
@@ -1945,6 +1961,7 @@ mod tests {
message: "Allow this request?".to_string(), message: "Allow this request?".to_string(),
approval_display_params: Vec::new(), approval_display_params: Vec::new(),
response_mode: McpServerElicitationResponseMode::ApprovalAction, response_mode: McpServerElicitationResponseMode::ApprovalAction,
is_tool_approval: false,
fields: vec![McpServerElicitationField { fields: vec![McpServerElicitationField {
id: APPROVAL_FIELD_ID.to_string(), id: APPROVAL_FIELD_ID.to_string(),
label: String::new(), label: String::new(),
@@ -2002,6 +2019,7 @@ mod tests {
message: "Allow this request?".to_string(), message: "Allow this request?".to_string(),
approval_display_params: Vec::new(), approval_display_params: Vec::new(),
response_mode: McpServerElicitationResponseMode::ApprovalAction, response_mode: McpServerElicitationResponseMode::ApprovalAction,
is_tool_approval: true,
fields: vec![McpServerElicitationField { fields: vec![McpServerElicitationField {
id: APPROVAL_FIELD_ID.to_string(), id: APPROVAL_FIELD_ID.to_string(),
label: String::new(), label: String::new(),