fix(codex): explain timed-out MCP approvals (#125860)

This commit is contained in:
Peter Steinberger
2026-08-18 08:02:54 -07:00
committed by GitHub
parent 8131f56cb1
commit 1ed682f883
2 changed files with 40 additions and 13 deletions
@@ -356,6 +356,28 @@ describe("Codex app-server elicitation bridge", () => {
]);
});
it("declines timed-out MCP approvals with explanatory metadata", async () => {
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-timeout", status: "accepted" })
.mockResolvedValueOnce({
id: "plugin:approval-timeout",
decision: "deny",
terminalReason: "timeout",
});
const result = await handleCodexAppServerElicitationRequest({
requestParams: buildApprovalElicitation(),
paramsForRun: createParams(),
...codexTestTurnIds(),
});
expect(result).toEqual({
action: "decline",
content: null,
_meta: { message: "Approval timed out before an operator responded." },
});
});
it("does not treat inherited request-time MCP decisions as final", async () => {
const inheritedDecisionResult = Object.assign(Object.create({ decision: null }), {
id: "plugin:approval-inherited",
@@ -7,6 +7,7 @@ import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
import {
approvalRequestExplicitlyUnavailable,
codexApprovalTimeoutText,
mapExecDecisionToOutcome,
requestPluginApproval,
sanitizeCodexApprovalVisibleText,
@@ -37,6 +38,8 @@ type BridgeableApprovalElicitation = {
allowedDecisions?: ExecApprovalDecision[];
};
type ElicitationApprovalOutcome = AppServerApprovalOutcome | "timed-out";
type PluginElicitationResolution =
| { kind: "not_plugin" }
| { kind: "matched"; entry: CodexAppPolicyContextEntry }
@@ -309,7 +312,7 @@ async function buildPluginPolicyElicitationResponse(params: {
});
return buildElicitationResponse(
approvalPrompt,
oneShotPluginPolicyApprovalOutcome(mode, outcome),
mode === "ask" && outcome === "approved-session" ? "approved-once" : outcome,
);
}
logPluginElicitationDecline("unmappable_schema", params.requestParams);
@@ -333,13 +336,6 @@ function allowedPluginPolicyApprovalDecisions(
return allowedDecisions.filter((decision) => decision !== "allow-always");
}
function oneShotPluginPolicyApprovalOutcome(
mode: "allow" | "deny" | "auto" | "ask",
outcome: AppServerApprovalOutcome,
): AppServerApprovalOutcome {
return mode === "ask" && outcome === "approved-session" ? "approved-once" : outcome;
}
function readPluginApprovalElicitation(
entry: CodexAppPolicyContextEntry,
requestParams: JsonObject,
@@ -408,8 +404,8 @@ function canMapPersistentApproval(requestedSchema: JsonObject, meta: JsonObject)
});
}
function declineElicitationResponse(): JsonValue {
return { action: "decline", content: null, _meta: null };
function declineElicitationResponse(message?: string): JsonValue {
return { action: "decline", content: null, _meta: message ? { message } : null };
}
function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void {
@@ -655,7 +651,7 @@ async function requestPluginApprovalOutcome(params: {
description: string;
allowedDecisions?: ExecApprovalDecision[];
signal?: AbortSignal;
}): Promise<AppServerApprovalOutcome> {
}): Promise<ElicitationApprovalOutcome> {
try {
const requestResult = await requestPluginApproval({
hostCapabilities: params.paramsForRun.hostCapabilities,
@@ -678,6 +674,12 @@ async function requestPluginApprovalOutcome(params: {
approvalId,
signal: params.signal,
});
if (params.signal?.aborted) {
return "cancelled";
}
if (approvalResult?.terminalReason === "timeout") {
return "timed-out";
}
return mapExecDecisionToOutcome(approvalResult?.decision);
} catch {
return params.signal?.aborted ? "cancelled" : "denied";
@@ -689,14 +691,17 @@ function buildElicitationResponse(
BridgeableApprovalElicitation,
"requestedSchema" | "meta" | "persistHintsMode"
>,
outcome: AppServerApprovalOutcome,
outcome: ElicitationApprovalOutcome,
): JsonValue {
const { requestedSchema, meta } = approvalPrompt;
if (outcome === "cancelled") {
return { action: "cancel", content: null, _meta: null };
}
if (outcome === "timed-out") {
return declineElicitationResponse(codexApprovalTimeoutText("other"));
}
if (outcome === "denied" || outcome === "unavailable") {
return { action: "decline", content: null, _meta: null };
return declineElicitationResponse();
}
const content = buildAcceptedContent(approvalPrompt, outcome);