// Gateway RPC handlers for plugin approval requests and decisions. import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js"; import { ErrorCodes, errorShape, formatValidationErrors, validatePluginApprovalRequestParams, validatePluginApprovalResolveParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import { resolveCanonicalPluginApprovalRequestAllowedDecisions } from "../../infra/plugin-approval-canonical-decisions.js"; import type { PluginApprovalRequest, PluginApprovalRequestPayload, PluginApprovalResolved, } from "../../infra/plugin-approvals.js"; import { resolvePluginApprovalTimeoutMs } from "../../infra/plugin-approvals.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { bindApprovalRequesterMetadata, bindApprovalReviewerDeviceIds, buildRequestedApprovalEvent, handleApprovalResolve, handleApprovalWaitDecision, handlePendingApprovalRequest, isApprovalRecordVisibleToClient, listVisiblePendingApprovalRequests, registerPendingApprovalRecord, resolveApprovalDecisionParams, } from "./approval-shared.js"; import type { GatewayClient, GatewayRequestHandlers } from "./types.js"; type PluginApprovalIosPushDelivery = { handleRequested?: ( request: PluginApprovalRequest, opts?: { isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean; }, ) => Promise; handleResolved?: (resolved: PluginApprovalResolved) => Promise; handleExpired?: (request: PluginApprovalRequest) => Promise; }; /** Create plugin approval handlers backed by the shared approval manager. */ export function createPluginApprovalHandlers( manager: ExecApprovalManager, opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: PluginApprovalIosPushDelivery }, ): GatewayRequestHandlers { return { "plugin.approval.list": async ({ respond, client }) => { respond(true, listVisiblePendingApprovalRequests({ manager, client }), undefined); }, "plugin.approval.request": async ({ params, client, respond, context }) => { if (!validatePluginApprovalRequestParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid plugin.approval.request params: ${formatValidationErrors( validatePluginApprovalRequestParams.errors, )}`, ), ); return; } const p = params as { pluginId?: string | null; title: string; description: string; severity?: string | null; toolName?: string | null; toolCallId?: string | null; allowedDecisions?: string[] | null; agentId?: string | null; sessionKey?: string | null; approvalReviewerDeviceIds?: string[]; turnSourceChannel?: string | null; turnSourceTo?: string | null; turnSourceAccountId?: string | null; turnSourceThreadId?: string | number | null; timeoutMs?: number; twoPhase?: boolean; }; const twoPhase = p.twoPhase === true; const timeoutMs = resolvePluginApprovalTimeoutMs(p.timeoutMs); const normalizeTrimmedString = (value?: string | null): string | null => normalizeOptionalString(value) || null; const request: PluginApprovalRequestPayload = { pluginId: p.pluginId ?? null, title: p.title, description: p.description, severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null, toolName: p.toolName ?? null, toolCallId: p.toolCallId ?? null, ...(Array.isArray(p.allowedDecisions) ? { allowedDecisions: resolveCanonicalPluginApprovalRequestAllowedDecisions({ allowedDecisions: p.allowedDecisions, }), } : {}), agentId: p.agentId ?? null, sessionKey: p.sessionKey ?? null, turnSourceChannel: normalizeTrimmedString(p.turnSourceChannel), turnSourceTo: normalizeTrimmedString(p.turnSourceTo), turnSourceAccountId: normalizeTrimmedString(p.turnSourceAccountId), turnSourceThreadId: p.turnSourceThreadId ?? null, }; // Always server-generate the ID — never accept plugin-provided IDs. // Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically. const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`); bindApprovalRequesterMetadata({ record, client }); if (client?.internal?.approvalRuntime === true) { bindApprovalReviewerDeviceIds({ record, deviceIds: p.approvalReviewerDeviceIds, }); } const decisionPromise = registerPendingApprovalRecord({ manager, record, timeoutMs, respond, context, }); if (!decisionPromise) { return; } const requestEvent = buildRequestedApprovalEvent(record); await handlePendingApprovalRequest({ manager, record, decisionPromise, respond, context, clientConnId: client?.connId, requestEventName: "plugin.approval.requested", requestEvent, twoPhase, approvalKind: "plugin", deliverRequest: () => { const deliveryTasks: Array> = []; if (opts?.forwarder?.handlePluginApprovalRequested) { deliveryTasks.push( opts.forwarder.handlePluginApprovalRequested(requestEvent).catch((err: unknown) => { context.logGateway?.error?.( `plugin approvals: forward request failed: ${String(err)}`, ); return false; }), ); } if (opts?.iosPushDelivery?.handleRequested) { deliveryTasks.push( opts.iosPushDelivery .handleRequested(requestEvent, { isTargetVisible: (target) => isApprovalRecordVisibleToClient({ record, client: { connect: { client: { id: GATEWAY_CLIENT_IDS.IOS_APP }, device: { id: target.deviceId }, scopes: [...target.scopes], }, } as GatewayClient, }), }) .catch((err: unknown) => { context.logGateway?.error?.( `plugin approvals: iOS push request failed: ${String(err)}`, ); return false; }), ); } if (deliveryTasks.length === 0) { return false; } return (async () => { let delivered = false; for (const task of deliveryTasks) { delivered = (await task) || delivered; } return delivered; })(); }, afterDecision: async (decision) => { if (decision === null) { await opts?.iosPushDelivery?.handleExpired?.(requestEvent); } }, afterDecisionErrorLabel: "plugin approvals: iOS push expire failed", }); }, "plugin.approval.waitDecision": async ({ params, respond, client }) => { await handleApprovalWaitDecision({ manager, inputId: (params as { id?: string }).id, client, respond, }); }, "plugin.approval.resolve": async ({ params, respond, client, context }) => { const resolveParams = resolveApprovalDecisionParams({ rawParams: params, validate: validatePluginApprovalResolveParams, methodName: "plugin.approval.resolve", respond, }); if (!resolveParams) { return; } const { inputId, decision } = resolveParams; await handleApprovalResolve({ manager, inputId, decision, respond, context, client, exposeAmbiguousPrefixError: false, validateDecision: (snapshot) => resolveCanonicalPluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision) ? null : { message: `${decision} is unavailable for this plugin approval`, details: { allowedDecisions: resolveCanonicalPluginApprovalRequestAllowedDecisions( snapshot.request, ), }, }, resolvedEventName: "plugin.approval.resolved", approvalKind: "plugin", buildResolvedEvent: ({ approvalId, decision: decisionLocal, resolvedBy, snapshot, nowMs, }) => ({ id: approvalId, decision: decisionLocal, resolvedBy, ts: nowMs, request: snapshot.request, }) satisfies PluginApprovalResolved, forwardResolved: (resolvedEvent) => opts?.forwarder?.handlePluginApprovalResolved?.(resolvedEvent), forwardResolvedErrorLabel: "plugin approvals: forward resolve failed", extraResolvedHandlers: opts?.iosPushDelivery?.handleResolved ? [ { run: (resolvedEvent) => opts.iosPushDelivery!.handleResolved!(resolvedEvent), errorLabel: "plugin approvals: iOS push resolve failed", }, ] : undefined, }); }, }; }