Files
openclaw/src/gateway/server-methods/plugin-approval.ts
Peter Steinberger bdcf6bdfdd feat(gateway): deliver plugin approvals to paired iOS devices via APNs (#108505)
* feat(gateway): deliver plugin approvals to paired iOS devices via APNs

Generalize the exec-approval iOS push delivery into a shared driver so plugin
approvals also raise an APNs notification on paired operator devices (which iOS
mirrors to a paired Apple Watch). Reuses the operator.approvals scope gate,
direct/relay transport, and stale-registration cleanup. Wires push into both
plugin-approval origination points (the plugin.approval.request RPC and the
node.invoke policy path) alongside the existing chat forwarder; adds a
plugin.approval.* APNs payload/category with a description body truncated to
256 UTF-16-safe chars and no secret-bearing fields.

* fix(gateway): drop unused export on plugin approval push category
2026-07-15 22:12:49 -07:00

276 lines
9.7 KiB
TypeScript

// 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<boolean>;
handleResolved?: (resolved: PluginApprovalResolved) => Promise<void>;
handleExpired?: (request: PluginApprovalRequest) => Promise<void>;
};
/** Create plugin approval handlers backed by the shared approval manager. */
export function createPluginApprovalHandlers(
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
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<Promise<boolean>> = [];
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,
});
},
};
}