// Approval shared helpers normalize pending exec/plugin approval lookups, // decision payloads, turn-source routing, and gateway error responses. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import type { ApprovalChannelReviewer, ValidationError, } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js"; import type { ChannelApprovalKind } from "../../infra/approval-types.js"; import type { ExecApprovalDecision, ExecApprovalRequestPayload, } from "../../infra/exec-approvals.js"; import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js"; import { createDeferredCore } from "../../shared/deferred.js"; import { prepareApprovalChannelCustody } from "../approval-channel-custody.js"; import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; import { type ApprovalRecordLookupResult, isApprovalRecordVisibleToClient, normalizeApprovalIdentities, resolvePendingApprovalRecord, resolveResolvedApprovalRecord, respondPendingApprovalLookupError, respondUnknownOrExpiredApproval, } from "./approval-record-lookup.js"; import { buildWaitResponse, type WaitReasonResolver } from "./approval-wait-response.js"; import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; export { isApprovalRecordVisibleToClient, listVisiblePendingApprovalRequests, resolvePendingApprovalRecord, respondPendingApprovalLookupError, } from "./approval-record-lookup.js"; const APPROVAL_ALREADY_RESOLVED_DETAILS = { reason: "APPROVAL_ALREADY_RESOLVED", } as const; function resolveRecordedApprovalDecision( record: ExecApprovalRecord, ): ExecApprovalDecision | undefined { return record.decision ?? record.consumedDecision; } type ApprovalTurnSourceFields = { turnSourceChannel?: string | null; turnSourceAccountId?: string | null; }; type RequestedApprovalEvent< TPayload extends ApprovalTurnSourceFields, TKind extends ChannelApprovalKind = ChannelApprovalKind, > = { approvalKind?: TKind; id: string; request: TPayload; createdAtMs: number; expiresAtMs: number; }; type ResolvedApprovalEvent = { id: string; decision: ExecApprovalDecision; resolvedBy: string | null; ts: number; request: TPayload; }; type ApprovalRequestDeliveryRoute = "approval-client" | "forwarder" | "turn-source" | "none"; type ApprovalResolveParams = { id: string; decision: string; reviewer?: ApprovalChannelReviewer; }; type ApprovalResolveParamsValidator = (( params: unknown, ) => params is TParams) & { errors?: ValidationError[] | null; }; function isApprovalDecision(value: string): value is ExecApprovalDecision { return value === "allow-once" || value === "allow-always" || value === "deny"; } /** Binds the current gateway client identity onto a newly-created approval record. */ export function bindApprovalRequesterMetadata(params: { record: ExecApprovalRecord; client?: GatewayClient | null; }): void { params.record.requestedByConnId = params.client?.connId ?? null; params.record.requestedByDeviceId = params.client?.connect?.device?.id ?? null; params.record.requestedByClientId = params.client?.connect?.client?.id ?? null; params.record.requestedByDeviceTokenAuth = params.client?.isDeviceTokenAuth === true; } export function bindApprovalReviewerDeviceIds(params: { record: ExecApprovalRecord; deviceIds?: readonly string[] | null; }): void { const deviceIds = normalizeApprovalIdentities(params.deviceIds); if (deviceIds.length > 0) { params.record.approvalReviewerDeviceIds = deviceIds; } } export function respondApprovalStorageUnavailable(params: { context: GatewayRequestContext; respond: RespondFn; operation: "request" | "resolve" | "history" | "lookup"; error: unknown; }): void { params.context.logGateway?.error?.( `approval ${params.operation} storage failure: ${String(params.error)}`, ); params.respond( false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `approval ${params.operation} unavailable`), ); } /** Registers an approval record and converts manager registration errors to gateway errors. */ export function registerPendingApprovalRecord(params: { manager: ExecApprovalManager; record: ExecApprovalRecord; timeoutMs: number; respond: RespondFn; context: GatewayRequestContext; }): Promise | undefined { try { return params.manager.register(params.record, params.timeoutMs); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "request", error: err }); return undefined; } } /** Builds the gateway event payload broadcast when an approval starts waiting. */ export function buildRequestedApprovalEvent< TPayload extends ApprovalTurnSourceFields, TKind extends ChannelApprovalKind, >( record: ExecApprovalRecord, approvalKind?: TKind, ): RequestedApprovalEvent { return { ...(approvalKind ? { approvalKind } : {}), id: record.id, request: record.request, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs, }; } /** Validates approval resolve params and narrows the decision to the supported enum. */ export function resolveApprovalDecisionParams(params: { rawParams: unknown; validate: ApprovalResolveParamsValidator; methodName: string; respond: RespondFn; }): { inputId: string; decision: ExecApprovalDecision; reviewer?: ApprovalChannelReviewer; } | null { const rawParams = params.rawParams; if (!assertValidParams(rawParams, params.validate, params.methodName, params.respond)) { return null; } if (!isApprovalDecision(rawParams.decision)) { params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision")); return null; } return { inputId: rawParams.id, decision: rawParams.decision, ...(rawParams.reviewer ? { reviewer: rawParams.reviewer } : {}), }; } /** Resolves the approval clients that should receive request or resolution events. */ function resolveApprovalRequestRecipientConnIds(params: { approvalKind: "exec" | "plugin" | "system-agent"; context: GatewayRequestContext; record: ExecApprovalRecord; excludeConnId?: string; }): ReadonlySet | null { return ( params.context.getApprovalClientConnIds?.({ approvalKind: params.approvalKind, excludeConnId: params.excludeConnId, record: params.record, filter: (client) => isApprovalRecordVisibleToClient({ record: params.record, client, }), }) ?? null ); } /** Sends a resolved approval only to clients authorized for its live binding. */ export function broadcastApprovalResolvedEvent(params: { approvalKind: "exec" | "plugin" | "system-agent"; context: GatewayRequestContext; record: ExecApprovalRecord; event: ResolvedApprovalEvent; }): void { const eventName = params.approvalKind === "system-agent" ? "openclaw.approval.resolved" : `${params.approvalKind}.approval.resolved`; const recipientConnIds = resolveApprovalRequestRecipientConnIds({ approvalKind: params.approvalKind, context: params.context, record: params.record, }); if (recipientConnIds) { params.context.broadcastToConnIds(eventName, params.event, recipientConnIds, { dropIfSlow: true, }); return; } params.context.broadcast(eventName, params.event, { dropIfSlow: true }); } export async function handleApprovalWaitDecision(params: { manager: ExecApprovalManager; inputId: unknown; client?: GatewayClient | null; cfg?: OpenClawConfig; respond: RespondFn; resolveTerminalReason?: WaitReasonResolver; }): Promise { const id = normalizeOptionalString(params.inputId) ?? ""; if (!id) { params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required")); return; } const snapshot = params.manager.getSnapshot(id); if ( !snapshot || !isApprovalRecordVisibleToClient({ record: snapshot, client: params.client ?? null, ...(params.cfg ? { cfg: params.cfg } : {}), }) ) { params.respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"), ); return; } const decisionPromise = params.manager.awaitDecision(id); if (!decisionPromise) { params.respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"), ); return; } const decision = params.manager.projectDecisionIfActive(id, await decisionPromise); const terminalSnapshot = params.manager.getSnapshot(id) ?? snapshot; const terminalReason = params.resolveTerminalReason?.(terminalSnapshot); params.respond( true, buildWaitResponse(id, decision, terminalSnapshot, terminalReason), undefined, ); } /** Broadcasts or routes a pending approval request, then responds after acceptance/decision. */ export async function handlePendingApprovalRequest< TPayload extends ApprovalTurnSourceFields, >(params: { manager: ExecApprovalManager; record: ExecApprovalRecord; respond: RespondFn; context: GatewayRequestContext; clientConnId?: string; requestEventName: string; requestEvent: RequestedApprovalEvent; twoPhase: boolean; approvalKind?: ChannelApprovalKind; deliverRequest: () => boolean | Promise; afterDecision?: ( decision: ExecApprovalDecision | null, requestEvent: RequestedApprovalEvent, ) => Promise | void; afterDecisionErrorLabel?: string; keepPendingWithoutRoute?: boolean; requireDeliveryRoute?: boolean; suppressDelivery?: boolean; deliverToApprovalClientsOnly?: boolean; }): Promise { const deliveryReady = createDeferredCore(); let noRouteWon = false; const handoff = params.manager.registerDecisionHandoff(params.record.id, async (decision) => { if (!(await deliveryReady.promise)) { return; } let projectedDecision = params.manager.projectDecisionIfActive(params.record.id, decision); if (!noRouteWon && params.afterDecision) { try { await params.afterDecision(projectedDecision, params.requestEvent); } catch (err) { params.context.logGateway?.error?.( `${params.afterDecisionErrorLabel ?? "approval follow-up failed"}: ${String(err)}`, ); } } projectedDecision = params.manager.projectDecisionIfActive(params.record.id, projectedDecision); params.respond( true, { id: params.record.id, decision: projectedDecision, createdAtMs: params.record.createdAtMs, expiresAtMs: params.record.expiresAtMs, }, undefined, ); }); // Observation can close while delivery is preparing; the registered genuine // handoff remains manager-owned and is joined independently of this observer. void handoff.observation.catch(() => {}); try { const suppressDelivery = params.suppressDelivery === true; // Cron/automation cards go only to connected approval surfaces (Control // UI, TUI): chat runtimes and turn-source routes would recreate the // per-occurrence spam #128031 removed, while an approval client can end // the recurrence with one allow-always (standing grant). const approvalClientsOnly = !suppressDelivery && params.deliverToApprovalClientsOnly === true; const approvalClientConnIds = suppressDelivery ? null : resolveApprovalRequestRecipientConnIds({ approvalKind: params.approvalKind ?? "exec", context: params.context, record: params.record, excludeConnId: params.clientConnId, }); if (!suppressDelivery) { if (approvalClientConnIds) { params.context.broadcastToConnIds( params.requestEventName, params.requestEvent, approvalClientConnIds, { dropIfSlow: true, }, ); } else { params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true, }); } } const internalApprovalSubscriberCount = suppressDelivery || approvalClientsOnly ? 0 : (params.context.approvalEvents?.publishRequested( params.approvalKind ?? "exec", params.requestEvent, ) ?? 0); const hasApprovalClients = suppressDelivery ? false : approvalClientConnIds !== null ? approvalClientConnIds.size > 0 || internalApprovalSubscriberCount > 0 : (params.context.hasExecApprovalClients?.(params.clientConnId) ?? false) || internalApprovalSubscriberCount > 0; const delivered = suppressDelivery || approvalClientsOnly ? false : await params.manager.trackActiveWork(params.deliverRequest); // A turn-source route can approve without an active approval client, so keep // the record alive when the originating channel/account can still receive it. const hasTurnSourceRoute = !suppressDelivery && !approvalClientsOnly && !hasApprovalClients && !delivered && hasApprovalTurnSourceRoute({ turnSourceChannel: params.record.request.turnSourceChannel, turnSourceAccountId: params.record.request.turnSourceAccountId, approvalKind: params.approvalKind ?? "exec", }); const deliveryRoute: ApprovalRequestDeliveryRoute = delivered ? "forwarder" : hasApprovalClients ? "approval-client" : hasTurnSourceRoute ? "turn-source" : "none"; if ( params.requireDeliveryRoute !== false && !params.keepPendingWithoutRoute && !hasApprovalClients && !hasTurnSourceRoute && !delivered ) { try { noRouteWon = params.manager.expire(params.record.id, "no-approval-route"); } catch (err) { deliveryReady.resolve(false); handoff.abandon(); respondApprovalStorageUnavailable({ ...params, operation: "request", error: err }); return; } } else if (params.twoPhase) { params.respond( true, { status: "accepted", id: params.record.id, // Agent-side timeouts use this to distinguish delivered prompts from // requests kept pending only because manual /approve routing may work. deliveryRoute, createdAtMs: params.record.createdAtMs, expiresAtMs: params.record.expiresAtMs, }, undefined, ); } } catch (error) { deliveryReady.resolve(false); handoff.abandon(); throw error; } deliveryReady.resolve(true); await handoff.observation; } function respondRepeatedApprovalResolution( record: ExecApprovalRecord, decision: ExecApprovalDecision, respond: RespondFn, ): void { // Identical retries are idempotent; a conflicting retry must never replace // or obscure the first durable operator decision. if (resolveRecordedApprovalDecision(record) === decision) { respond(true, { ok: true }, undefined); return; } respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "approval already resolved", { details: APPROVAL_ALREADY_RESOLVED_DETAILS, }), ); } /** Resolves a pending approval and broadcasts the final decision exactly once. */ export async function handleApprovalResolve< TPayload extends ExecApprovalRequestPayload | PluginApprovalRequestPayload, >(params: { approvalKind: ChannelApprovalKind; manager: ExecApprovalManager; inputId: string; decision: ExecApprovalDecision; respond: RespondFn; context: GatewayRequestContext; client: GatewayClient | null; reviewer?: ApprovalChannelReviewer; exposeAmbiguousPrefixError?: boolean; validateDecision?: (snapshot: ExecApprovalRecord) => | { message: string; details?: Record; } | null | undefined; resolveRecord?: (params: { approvalId: string; decision: ExecApprovalDecision; resolvedBy: string | null; snapshot: ExecApprovalRecord; resolver?: { kind: "channel"; id: string }; }) => boolean; forwardResolved?: (event: ResolvedApprovalEvent) => Promise | void; forwardResolvedErrorLabel?: string; extraResolvedHandlers?: Array<{ run: (event: ResolvedApprovalEvent) => Promise | void; errorLabel: string; }>; }): Promise { const custody = params.reviewer ? prepareApprovalChannelCustody({ cfg: params.context.getRuntimeConfig(), approvalKind: params.approvalKind, reviewer: params.reviewer, }) : null; if (params.reviewer && !custody) { respondUnknownOrExpiredApproval(params.respond); return; } const recordFilter = custody ? (record: ExecApprovalRecord) => custody.authorizes(record) : undefined; let resolved: ApprovalRecordLookupResult; try { resolved = resolvePendingApprovalRecord({ manager: params.manager, inputId: params.inputId, client: params.client, exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError, recordFilter, }); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); return; } if (!resolved.ok) { let resolvedRepeat: ApprovalRecordLookupResult; try { resolvedRepeat = resolveResolvedApprovalRecord({ manager: params.manager, inputId: params.inputId, client: params.client, exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError, recordFilter, }); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); return; } if (resolvedRepeat.ok) { respondRepeatedApprovalResolution(resolvedRepeat.snapshot, params.decision, params.respond); return; } respondPendingApprovalLookupError({ respond: params.respond, response: resolved.response }); return; } const validationError = params.validateDecision?.(resolved.snapshot); if (validationError) { params.respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, validationError.message, validationError.details ? { details: validationError.details } : undefined, ), ); return; } const resolvedBy = params.client?.connect?.client?.displayName ?? params.client?.connect?.client?.id ?? null; const resolver = custody ? ({ kind: "channel", id: custody.resolverId } as const) : undefined; let ok: boolean; try { ok = params.resolveRecord ? params.resolveRecord({ approvalId: resolved.approvalId, decision: params.decision, resolvedBy, snapshot: resolved.snapshot, resolver, }) : resolver ? params.manager.resolveDetailed(resolved.approvalId, params.decision, resolver, resolvedBy) .outcome === "resolved" : params.manager.resolve(resolved.approvalId, params.decision, resolvedBy); } catch (err) { respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err }); return; } if (!ok) { // A concurrent surface can win between the pending lookup and this // resolve; report the recorded conflict, not a missing approval. const raced = params.manager.getSnapshot(resolved.approvalId); if (raced && raced.resolvedAtMs !== undefined) { respondRepeatedApprovalResolution(raced, params.decision, params.respond); return; } respondUnknownOrExpiredApproval(params.respond); return; } const resolvedEvent: ResolvedApprovalEvent = { id: resolved.approvalId, decision: params.decision, resolvedBy, ts: Date.now(), request: resolved.snapshot.request, }; broadcastApprovalResolvedEvent({ approvalKind: params.approvalKind, context: params.context, record: resolved.snapshot, event: resolvedEvent, }); if (params.approvalKind !== "system-agent") { params.context.approvalEvents?.publishResolved(params.approvalKind, resolvedEvent as never); } const followUps = [ params.forwardResolved ? { run: params.forwardResolved, errorLabel: params.forwardResolvedErrorLabel ?? "approval resolve follow-up failed", } : null, ...(params.extraResolvedHandlers ?? []), params.context.approvalWebPushDelivery ? { run: params.context.approvalWebPushDelivery.handleResolved, errorLabel: `${params.approvalKind} approvals: Web Push resolve failed`, } : null, ].filter( ( entry, ): entry is { run: (event: ResolvedApprovalEvent) => Promise | void; errorLabel: string; } => Boolean(entry), ); // Resolution has already been recorded and broadcast; follow-up hooks are // best-effort so a plugin/channel forwarding failure cannot reopen it. for (const followUp of followUps) { try { await followUp.run(resolvedEvent); } catch (err) { params.context.logGateway?.error?.(`${followUp.errorLabel}: ${String(err)}`); } } params.respond(true, { ok: true }, undefined); }