Files
openclaw/src/gateway/node-invoke-plugin-policy.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

341 lines
13 KiB
TypeScript

// Plugin-provided node.invoke policy adapter.
// Lets plugin policies gate dangerous node commands before transport dispatch.
import { randomUUID } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js";
import type { PluginApprovalRequestPayload } from "../infra/plugin-approvals.js";
import { resolvePluginApprovalTimeoutMs } from "../infra/plugin-approvals.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import { getActivePluginGatewayNodePolicyRegistry } from "../plugins/runtime.js";
import type {
OpenClawPluginNodeInvokePolicyContext,
OpenClawPluginNodeInvokePolicyResult,
OpenClawPluginNodeInvokeTransportResult,
} from "../plugins/types.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "./node-command-policy.js";
import type { NodeSession } from "./node-registry.js";
import {
bindApprovalRequesterMetadata,
buildRequestedApprovalEvent,
handlePendingApprovalRequest,
isApprovalRecordVisibleToClient,
} from "./server-methods/approval-shared.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./server-methods/types.js";
// Plugin node.invoke policies are the last gateway-side guard before a
// plugin-declared dangerous node command reaches the node transport.
function parseScopes(client: GatewayClient | null): string[] {
return Array.isArray(client?.connect?.scopes)
? client.connect.scopes.filter((scope): scope is string => typeof scope === "string")
: [];
}
function parsePayload(payloadJSON: string | null | undefined, payload: unknown): unknown {
if (!payloadJSON) {
return payload;
}
try {
return JSON.parse(payloadJSON) as unknown;
} catch {
return payload;
}
}
function normalizeRouteThreadId(value: unknown): string | number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
return normalizeOptionalString(value) ?? null;
}
function resolveNodeInvokeTurnSourceFields(
turnSource:
| {
channel?: unknown;
to?: unknown;
accountId?: unknown;
threadId?: unknown;
}
| undefined,
): Pick<
PluginApprovalRequestPayload,
"turnSourceChannel" | "turnSourceTo" | "turnSourceAccountId" | "turnSourceThreadId"
> {
return {
turnSourceChannel: normalizeOptionalString(turnSource?.channel) ?? null,
turnSourceTo: normalizeOptionalString(turnSource?.to) ?? null,
turnSourceAccountId: normalizeOptionalString(turnSource?.accountId) ?? null,
turnSourceThreadId: normalizeRouteThreadId(turnSource?.threadId),
};
}
// Dangerous commands must have an explicit policy. Without this check, a plugin
// could mark a command dangerous but rely on the gateway default allow path.
function findDangerousPluginNodeCommand(registry: PluginRegistry | null, command: string) {
const normalizedCommand = command.trim();
if (!normalizedCommand) {
return null;
}
return (
registry?.nodeHostCommands?.find(
(entry) =>
entry.command.dangerous === true && entry.command.command.trim() === normalizedCommand,
) ?? null
);
}
function createApprovalRuntime(params: {
context: GatewayRequestContext;
client: GatewayClient | null;
pluginId: string;
turnSource: Parameters<typeof resolveNodeInvokeTurnSourceFields>[0];
}): OpenClawPluginNodeInvokePolicyContext["approvals"] | undefined {
const manager = params.context.pluginApprovalManager;
if (!manager) {
return undefined;
}
return {
async request(input) {
const timeoutMs = resolvePluginApprovalTimeoutMs(input.timeoutMs);
const turnSource = resolveNodeInvokeTurnSourceFields(params.turnSource);
const callerIdentity = params.client?.internal?.agentRuntimeIdentity;
const request: PluginApprovalRequestPayload = {
pluginId: params.pluginId,
title: truncateUtf16Safe(input.title, 80),
description: truncateUtf16Safe(input.description, 256),
severity: input.severity ?? "warning",
toolName: normalizeOptionalString(input.toolName) ?? null,
toolCallId: normalizeOptionalString(input.toolCallId) ?? null,
agentId: callerIdentity?.agentId ?? normalizeOptionalString(input.agentId) ?? null,
sessionKey: callerIdentity?.sessionKey ?? normalizeOptionalString(input.sessionKey) ?? null,
turnSourceChannel: turnSource.turnSourceChannel,
turnSourceTo: turnSource.turnSourceTo,
turnSourceAccountId: turnSource.turnSourceAccountId,
turnSourceThreadId: turnSource.turnSourceThreadId,
};
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
bindApprovalRequesterMetadata({ record, client: params.client });
const respond: RespondFn = () => {};
// Register directly: persistence and presentation-validation failures
// must throw so the plugin policy fails closed before any request
// routing. The RPC storage-unavailable respond path does not apply to
// this runtime-internal caller.
const decisionPromise = manager.register(record, timeoutMs);
const requestEvent = buildRequestedApprovalEvent(record);
await handlePendingApprovalRequest({
manager,
record,
decisionPromise,
respond,
context: params.context,
clientConnId: params.client?.connId,
requestEventName: "plugin.approval.requested",
requestEvent,
twoPhase: false,
approvalKind: "plugin",
deliverRequest: () => {
const deliveryTasks: Array<Promise<boolean>> = [];
const forward = params.context.forwardPluginApprovalRequest;
if (forward) {
deliveryTasks.push(
forward(requestEvent).catch((err: unknown) => {
params.context.logGateway?.error?.(
`plugin approvals: forward node policy request failed: ${String(err)}`,
);
return false;
}),
);
}
const iosPushDelivery = params.context.pluginApprovalIosPushDelivery;
if (iosPushDelivery?.handleRequested) {
deliveryTasks.push(
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) => {
params.context.logGateway?.error?.(
`plugin approvals: iOS push node policy 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 params.context.pluginApprovalIosPushDelivery?.handleExpired?.(requestEvent);
}
},
afterDecisionErrorLabel: "plugin approvals: iOS push node policy expire failed",
});
const decision = await decisionPromise;
// This return hands execution authority to the plugin policy. Claim a
// one-shot decision here so observation or retry cannot replay it.
if (
decision === "allow-once" &&
!manager.consumeAllowOnce(record.id, `plugin.node.invoke:${record.id}`)
) {
return { id: record.id, decision: null };
}
return { id: record.id, decision };
},
};
}
/** Applies the registered plugin policy for a node.invoke command, if one exists. */
export async function applyPluginNodeInvokePolicy(params: {
context: GatewayRequestContext;
client: GatewayClient | null;
nodeSession: NodeSession;
command: string;
params: unknown;
turnSource?: {
channel?: unknown;
to?: unknown;
accountId?: unknown;
threadId?: unknown;
};
timeoutMs?: number;
idempotencyKey?: string;
}): Promise<OpenClawPluginNodeInvokePolicyResult | null> {
const registry = getActivePluginGatewayNodePolicyRegistry();
// Route metadata is authority-bearing: only a signed agent-runtime caller may nominate it.
const trustedTurnSource = params.client?.internal?.agentRuntimeIdentity
? params.turnSource
: undefined;
const entry = registry?.nodeInvokePolicies?.find((candidate) =>
candidate.policy.commands.includes(params.command),
);
if (!entry) {
const dangerousCommand = findDangerousPluginNodeCommand(registry, params.command);
if (dangerousCommand) {
return {
ok: false,
code: "PLUGIN_POLICY_MISSING",
message: `node.invoke ${params.command} is registered as dangerous by plugin ${dangerousCommand.pluginId} but has no plugin node.invoke policy`,
details: { nodeCommandDispatched: false },
};
}
return null;
}
let nodeCommandDispatched = false;
const invokeNode: OpenClawPluginNodeInvokePolicyContext["invokeNode"] = async (
override = {},
): Promise<OpenClawPluginNodeInvokeTransportResult> => {
// Policies invoke the real node through this narrowed transport wrapper so
// they can retry/override params without getting direct registry access.
const currentNode = params.context.nodeRegistry.get(params.nodeSession.nodeId);
if (!currentNode || currentNode.connId !== params.nodeSession.connId) {
return {
ok: false,
code: "ROUTE_CHANGED",
message: "node connection changed before dispatch",
};
}
const currentConfig = params.context.getRuntimeConfig();
const allowlist = resolveNodeCommandAllowlist(currentConfig, {
...currentNode,
approvedCommands: currentNode.commands,
});
const allowed = isNodeCommandAllowed({
command: params.command,
declaredCommands: currentNode.commands,
allowlist,
});
if (!allowed.ok) {
return {
ok: false,
code: "NODE_COMMAND_REVOKED",
message: `node command not allowed at dispatch: ${allowed.reason}`,
details: { command: params.command, reason: allowed.reason },
};
}
// Once the registry owns the request, any failure is ambiguous to callers:
// the node may have acted before the response was lost or rejected.
nodeCommandDispatched = true;
const res = await params.context.nodeRegistry.invoke({
nodeId: params.nodeSession.nodeId,
expectedConnId: params.nodeSession.connId,
command: params.command,
params: override.params ?? params.params,
timeoutMs: override.timeoutMs ?? params.timeoutMs,
idempotencyKey: override.idempotencyKey ?? params.idempotencyKey,
});
if (!res.ok) {
return {
ok: false,
code: res.error?.code,
message: res.error?.message ?? "node command failed",
details: { nodeError: res.error ?? null },
};
}
return {
ok: true,
payload: parsePayload(res.payloadJSON, res.payload),
payloadJSON: res.payloadJSON ?? null,
};
};
const result = await entry.policy.handle({
nodeId: params.nodeSession.nodeId,
command: params.command,
params: params.params,
timeoutMs: params.timeoutMs,
idempotencyKey: params.idempotencyKey,
config: params.context.getRuntimeConfig(),
pluginConfig: entry.pluginConfig,
node: {
nodeId: params.nodeSession.nodeId,
displayName: params.nodeSession.displayName,
platform: params.nodeSession.platform,
deviceFamily: params.nodeSession.deviceFamily,
commands: params.nodeSession.commands,
},
client: params.client
? {
connId: params.client.connId,
scopes: parseScopes(params.client),
}
: null,
approvals: createApprovalRuntime({
context: params.context,
client: params.client,
pluginId: entry.pluginId,
turnSource: trustedTurnSource,
}),
invokeNode,
});
if (result.ok) {
return result;
}
return {
...result,
// Core owns dispatch and must override a plugin-supplied claim. Callers may
// clear speculative state only when this value is definitively false.
details: { ...result.details, nodeCommandDispatched },
};
}