diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index cada71f0cd70..4be2f4c6af7e 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -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", diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index 3e0d084ec6ce..879a891b10c8 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -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 { +}): Promise { 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);