mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 11:51:13 +00:00
* 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
276 lines
9.7 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
}
|