mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
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:
@@ -94,6 +94,10 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
|
||||
use codex_protocol::items::ModelInvocationContext;
|
||||
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::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::Event;
|
||||
@@ -874,6 +878,11 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
});
|
||||
}
|
||||
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!(
|
||||
&request.request,
|
||||
codex_protocol::approvals::ElicitationRequest::UserVerification { .. }
|
||||
@@ -930,6 +939,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
PendingMcpElicitationResponse {
|
||||
receiver: rx,
|
||||
user_verification,
|
||||
mcp_tool_approval,
|
||||
},
|
||||
conversation,
|
||||
thread_state,
|
||||
@@ -1723,6 +1733,7 @@ async fn on_request_user_input_response(
|
||||
struct PendingMcpElicitationResponse {
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
user_verification: bool,
|
||||
mcp_tool_approval: bool,
|
||||
}
|
||||
|
||||
async fn on_mcp_server_elicitation_response(
|
||||
@@ -1737,12 +1748,30 @@ async fn on_mcp_server_elicitation_response(
|
||||
let response = pending.receiver.await;
|
||||
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
|
||||
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)
|
||||
} else {
|
||||
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
|
||||
.submit(Op::ResolveElicitation {
|
||||
server_name,
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::ApprovalContext;
|
||||
use crate::tools::ApprovalResolution;
|
||||
use crate::tools::ApprovalResolutionSource;
|
||||
use crate::tools::context::ToolCallOrigin;
|
||||
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_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::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_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::McpApprovalCancellationReason;
|
||||
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_SESSION as MCP_TOOL_APPROVAL_PERSIST_SESSION;
|
||||
@@ -344,7 +347,20 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.await
|
||||
}
|
||||
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(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -1484,6 +1500,7 @@ fn mcp_tool_approval_prompt_options(
|
||||
struct McpToolApproval {
|
||||
decision: ReviewDecision,
|
||||
source: Option<ApprovalResolutionSource>,
|
||||
cancellation_reason: Option<McpApprovalCancellationReason>,
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
@@ -1544,6 +1561,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
return Some(McpToolApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: None,
|
||||
cancellation_reason: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1593,6 +1611,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
Err(ToolError::Codex(_)) => ReviewDecision::Abort,
|
||||
},
|
||||
source: approval.source,
|
||||
cancellation_reason: approval.cancellation_reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1601,7 +1620,7 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
action: &ApprovalAction,
|
||||
) -> ReviewDecision {
|
||||
) -> ApprovalResolution {
|
||||
let ApprovalAction::McpToolCall {
|
||||
id,
|
||||
server,
|
||||
@@ -1624,9 +1643,13 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
};
|
||||
|
||||
if *approval_policy == AskForApproval::Never {
|
||||
return ReviewDecision::denied(
|
||||
"MCP tool call requires approval, but approval policy is never",
|
||||
);
|
||||
return ApprovalResolution {
|
||||
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
|
||||
@@ -1660,7 +1683,7 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
.as_ref()
|
||||
.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
|
||||
.mcp_tool_approval_metadata(server, id)
|
||||
.and_then(|context| context.metadata.link_id);
|
||||
@@ -1698,17 +1721,33 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
let outcome = sess
|
||||
.request_mcp_server_elicitation(turn_context, server.clone(), request_id, request)
|
||||
.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,
|
||||
parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id),
|
||||
cancellation_reason,
|
||||
)
|
||||
} else {
|
||||
if turn_context.session_source.is_non_root_agent() {
|
||||
return ReviewDecision::denied(concat!(
|
||||
"MCP tool approval via request_user_input requires the root thread. ",
|
||||
"Ask the parent agent to handle this request. ",
|
||||
"Do not retry the blocked action until the parent confirms the blocker is resolved."
|
||||
));
|
||||
return ApprovalResolution {
|
||||
decision: ReviewDecision::denied(concat!(
|
||||
"MCP tool approval via request_user_input requires the root thread. ",
|
||||
"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 {
|
||||
questions: vec![question],
|
||||
@@ -1718,12 +1757,28 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
let response = sess
|
||||
.request_user_input(turn_context, call_id.to_string(), args)
|
||||
.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,
|
||||
parse_mcp_tool_approval_response(
|
||||
response.map(|accepted| accepted.response),
|
||||
&question_id,
|
||||
),
|
||||
cancellation_reason,
|
||||
)
|
||||
};
|
||||
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);
|
||||
}
|
||||
decision
|
||||
ApprovalResolution {
|
||||
cancellation_reason: if decision == ReviewDecision::Abort {
|
||||
cancellation_reason
|
||||
} else {
|
||||
None
|
||||
},
|
||||
decision,
|
||||
source: ApprovalResolutionSource::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_mcp_tool_approval_key(
|
||||
|
||||
@@ -2330,7 +2330,11 @@ async fn dispatched_mcp_approval_with_closed_response_is_classified_as_approval(
|
||||
*session.active_turn.lock().await = None;
|
||||
assert_eq!(
|
||||
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");
|
||||
@@ -3002,6 +3006,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
Some(McpToolApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: Some(ApprovalResolutionSource::Hook),
|
||||
cancellation_reason: None,
|
||||
})
|
||||
);
|
||||
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 {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: Some(ApprovalResolutionSource::Hook),
|
||||
cancellation_reason: None,
|
||||
})
|
||||
);
|
||||
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 {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: None,
|
||||
cancellation_reason: None,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
@@ -3276,6 +3283,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
|
||||
let Some(McpToolApproval {
|
||||
decision: ReviewDecision::Denied { rejection: message },
|
||||
source: Some(ApprovalResolutionSource::Guardian),
|
||||
cancellation_reason: None,
|
||||
}) = decision
|
||||
else {
|
||||
panic!("guardian-denied MCP approval should carry a rejection message");
|
||||
@@ -3391,6 +3399,7 @@ async fn assert_mcp_user_approval_persistence(
|
||||
Some(McpToolApproval {
|
||||
decision: expected_decision,
|
||||
source: Some(ApprovalResolutionSource::User),
|
||||
cancellation_reason: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::mcp_approval_meta::McpApprovalCancellationReason;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -404,12 +405,14 @@ pub(crate) enum ApprovalResolutionSource {
|
||||
pub(crate) struct ApprovalResult {
|
||||
pub(crate) result: Result<ReviewDecision, ToolError>,
|
||||
pub(crate) source: Option<ApprovalResolutionSource>,
|
||||
pub(crate) cancellation_reason: Option<McpApprovalCancellationReason>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ApprovalResolution {
|
||||
decision: ReviewDecision,
|
||||
source: ApprovalResolutionSource,
|
||||
pub(crate) struct ApprovalResolution {
|
||||
pub(crate) decision: ReviewDecision,
|
||||
pub(crate) source: ApprovalResolutionSource,
|
||||
pub(crate) cancellation_reason: Option<McpApprovalCancellationReason>,
|
||||
}
|
||||
|
||||
impl ApprovalResolution {
|
||||
@@ -466,6 +469,7 @@ impl Session {
|
||||
return ApprovalResult {
|
||||
result: Err(ToolError::Rejected(reason.to_string())),
|
||||
source: None,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
}
|
||||
let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. });
|
||||
@@ -490,10 +494,12 @@ impl Session {
|
||||
Some(PermissionRequestDecision::Allow) => ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
|
||||
decision: ReviewDecision::denied(message),
|
||||
source: ApprovalResolutionSource::Hook,
|
||||
cancellation_reason: None,
|
||||
},
|
||||
None => self.request_reviewer_approval(action, &ctx).await,
|
||||
};
|
||||
@@ -502,10 +508,12 @@ impl Session {
|
||||
record_resolution(&ctx, &resolution);
|
||||
}
|
||||
let source = Some(resolution.source);
|
||||
let cancellation_reason = resolution.cancellation_reason;
|
||||
if is_mcp_tool_call && resolution.decision == ReviewDecision::ApprovedMcpPolicyAmendment {
|
||||
return ApprovalResult {
|
||||
result: Ok(resolution.decision),
|
||||
source,
|
||||
cancellation_reason,
|
||||
};
|
||||
}
|
||||
if is_network_approval {
|
||||
@@ -519,6 +527,7 @@ impl Session {
|
||||
return ApprovalResult {
|
||||
result: Ok(resolution.decision),
|
||||
source,
|
||||
cancellation_reason,
|
||||
};
|
||||
}
|
||||
(ReviewDecision::Abort, ApprovalResolutionSource::Guardian) => {
|
||||
@@ -527,6 +536,7 @@ impl Session {
|
||||
"automatic approval review was cancelled".to_string(),
|
||||
)),
|
||||
source,
|
||||
cancellation_reason,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
@@ -535,6 +545,7 @@ impl Session {
|
||||
ApprovalResult {
|
||||
result: resolution.into_tool_result(ctx.review_context.turn().model_info()),
|
||||
source,
|
||||
cancellation_reason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,14 +556,13 @@ impl Session {
|
||||
) -> ApprovalResolution {
|
||||
if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
|
||||
ApprovalResolution {
|
||||
cancellation_reason: (decision == ReviewDecision::Abort)
|
||||
.then_some(McpApprovalCancellationReason::Interrupted),
|
||||
decision,
|
||||
source: ApprovalResolutionSource::Guardian,
|
||||
}
|
||||
} else {
|
||||
ApprovalResolution {
|
||||
decision: self.request_user_approval(&action, ctx).await,
|
||||
source: ApprovalResolutionSource::User,
|
||||
}
|
||||
self.request_user_approval(&action, ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,8 +677,8 @@ impl Session {
|
||||
&self,
|
||||
action: &ApprovalAction,
|
||||
ctx: &ApprovalContext,
|
||||
) -> ReviewDecision {
|
||||
match action {
|
||||
) -> ApprovalResolution {
|
||||
let decision = match action {
|
||||
ApprovalAction::ExecCommand {
|
||||
environment_id,
|
||||
command,
|
||||
@@ -760,44 +770,43 @@ impl Session {
|
||||
.clone()
|
||||
.or_else(|| ctx.approval_reason.clone());
|
||||
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 { .. } => {
|
||||
request_mcp_tool_user_approval(
|
||||
return request_mcp_tool_user_approval(
|
||||
self,
|
||||
ctx.review_context.turn(),
|
||||
&ctx.call_id,
|
||||
action,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
}
|
||||
ApprovalAction::NetworkAccess {
|
||||
environment_id,
|
||||
@@ -826,6 +835,11 @@ impl Session {
|
||||
ApprovalAction::RequestPermissions { .. } => {
|
||||
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,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
@@ -27,6 +28,7 @@ fn approval_resolution_rejects_mcp_policy_amendment() {
|
||||
let resolution = ApprovalResolution {
|
||||
decision: ReviewDecision::ApprovedMcpPolicyAmendment,
|
||||
source: ApprovalResolutionSource::User,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
@@ -40,6 +42,7 @@ fn approval_resolution_aborts_turn_when_approval_is_aborted() {
|
||||
let resolution = ApprovalResolution {
|
||||
decision: ReviewDecision::Abort,
|
||||
source: ApprovalResolutionSource::User,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
@@ -67,6 +70,7 @@ fn approval_resolution_uses_acting_model_timeout_instructions() {
|
||||
let resolution = ApprovalResolution {
|
||||
decision: ReviewDecision::TimedOut,
|
||||
source: ApprovalResolutionSource::Guardian,
|
||||
cancellation_reason: None,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
@@ -167,11 +171,13 @@ async fn non_utf8_cwd_preserves_approval_routing(
|
||||
ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::User,
|
||||
cancellation_reason: None,
|
||||
}
|
||||
} else {
|
||||
ApprovalResolution {
|
||||
decision: ReviewDecision::Approved,
|
||||
source: ApprovalResolutionSource::Guardian,
|
||||
cancellation_reason: None,
|
||||
}
|
||||
};
|
||||
assert_eq!(approval.await, expected);
|
||||
|
||||
@@ -29,6 +29,7 @@ use std::borrow::Cow;
|
||||
|
||||
use crate::session::turn_context::TurnContext;
|
||||
pub(crate) use approvals::ApprovalContext;
|
||||
pub(crate) use approvals::ApprovalResolution;
|
||||
pub(crate) use approvals::ApprovalResolutionSource;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
|
||||
@@ -27,6 +27,7 @@ use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -96,6 +97,10 @@ use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
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::PermissionProfile;
|
||||
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 {
|
||||
action: McpServerElicitationAction::Cancel,
|
||||
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}"))
|
||||
}
|
||||
@@ -2077,11 +2090,11 @@ async fn handle_server_request(
|
||||
) {
|
||||
let method = server_request_method_name(&request);
|
||||
let handle_result = match request {
|
||||
ServerRequest::McpServerElicitationRequest { request_id, .. } => {
|
||||
ServerRequest::McpServerElicitationRequest { request_id, params } => {
|
||||
// Exec auto-cancels elicitation instead of surfacing it
|
||||
// interactively. Preserve that behavior for attached subagent
|
||||
// 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(¶ms.request) {
|
||||
Ok(value) => {
|
||||
resolve_server_request(
|
||||
client,
|
||||
|
||||
@@ -462,7 +462,14 @@ fn should_backfill_turn_completed_items_backfills_persisted_summaries_only() {
|
||||
|
||||
#[test]
|
||||
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");
|
||||
let response: McpServerElicitationRequestResponse =
|
||||
serde_json::from_value(value).expect("cancel response should deserialize");
|
||||
@@ -472,7 +479,9 @@ fn canceled_mcp_server_elicitation_response_uses_cancel_action() {
|
||||
McpServerElicitationRequestResponse {
|
||||
action: McpServerElicitationAction::Cancel,
|
||||
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_SESSION: &str = "session";
|
||||
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_CONNECTOR: &str = "connector";
|
||||
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_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::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_KEY as APPROVAL_PERSIST_KEY;
|
||||
use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE;
|
||||
@@ -175,6 +177,7 @@ pub(crate) struct McpServerElicitationFormRequest {
|
||||
message: String,
|
||||
approval_display_params: Vec<McpToolApprovalDisplayParam>,
|
||||
response_mode: McpServerElicitationResponseMode,
|
||||
is_tool_approval: bool,
|
||||
fields: Vec<McpServerElicitationField>,
|
||||
tool_suggestion: Option<ToolSuggestionRequest>,
|
||||
}
|
||||
@@ -330,6 +333,7 @@ impl McpServerElicitationFormRequest {
|
||||
message,
|
||||
approval_display_params,
|
||||
response_mode,
|
||||
is_tool_approval,
|
||||
fields,
|
||||
tool_suggestion,
|
||||
})
|
||||
@@ -1151,7 +1155,11 @@ impl McpServerElicitationOverlay {
|
||||
self.request.request_id.clone(),
|
||||
McpServerElicitationAction::Cancel,
|
||||
/*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_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),
|
||||
};
|
||||
self.app_event_tx.resolve_elicitation(
|
||||
@@ -1875,6 +1890,7 @@ mod tests {
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::FormContent,
|
||||
is_tool_approval: false,
|
||||
fields: vec![McpServerElicitationField {
|
||||
id: "confirmed".to_string(),
|
||||
label: "Confirm".to_string(),
|
||||
@@ -1945,6 +1961,7 @@ mod tests {
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::ApprovalAction,
|
||||
is_tool_approval: false,
|
||||
fields: vec![McpServerElicitationField {
|
||||
id: APPROVAL_FIELD_ID.to_string(),
|
||||
label: String::new(),
|
||||
@@ -2002,6 +2019,7 @@ mod tests {
|
||||
message: "Allow this request?".to_string(),
|
||||
approval_display_params: Vec::new(),
|
||||
response_mode: McpServerElicitationResponseMode::ApprovalAction,
|
||||
is_tool_approval: true,
|
||||
fields: vec![McpServerElicitationField {
|
||||
id: APPROVAL_FIELD_ID.to_string(),
|
||||
label: String::new(),
|
||||
|
||||
Reference in New Issue
Block a user