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
This commit is contained in:
Peter Steinberger
2026-07-15 22:12:49 -07:00
committed by GitHub
parent 83a4208aed
commit bdcf6bdfdd
15 changed files with 870 additions and 157 deletions

View File

@@ -3,6 +3,7 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "../infra/plugin-approvals.js";
import { createDeferred } from "../test-utils/deferred.js";
const listDevicePairingMock = vi.fn();
@@ -12,7 +13,10 @@ const resolveApnsAuthConfigFromEnvMock = vi.fn();
const resolveApnsRelayConfigFromEnvMock = vi.fn();
const sendApnsExecApprovalAlertMock = vi.fn();
const sendApnsExecApprovalResolvedWakeMock = vi.fn();
const sendApnsPluginApprovalAlertMock = vi.fn();
const sendApnsPluginApprovalResolvedWakeMock = vi.fn();
let createExecApprovalIosPushDelivery: typeof import("./exec-approval-ios-push.js").createExecApprovalIosPushDelivery;
let createPluginApprovalIosPushDelivery: typeof import("./exec-approval-ios-push.js").createPluginApprovalIosPushDelivery;
function apnsRegistration(nodeId = "ios-device-1") {
return {
@@ -25,6 +29,21 @@ function apnsRegistration(nodeId = "ios-device-1") {
};
}
function relayApnsRegistration(nodeId = "ios-device-1") {
return {
nodeId,
transport: "relay",
relayHandle: `relay-${nodeId}`,
sendGrant: `grant-${nodeId}`,
installationId: `installation-${nodeId}`,
topic: "ai.openclaw.ios.test",
environment: "sandbox",
distribution: "official",
relayOrigin: "https://relay.example.test",
updatedAtMs: 1,
};
}
function successfulApnsPushResult() {
return {
ok: true,
@@ -60,6 +79,28 @@ function approvalResolved(id: string): ExecApprovalResolved {
};
}
function pluginApprovalRequest(id: string): PluginApprovalRequest {
return {
id,
request: {
title: "Install plugin update",
description: "Allow the plugin to update its managed package.",
severity: "warning",
toolName: "plugins.update",
},
createdAtMs: 1,
expiresAtMs: 2,
};
}
function pluginApprovalResolved(id: string): PluginApprovalResolved {
return {
id,
decision: "allow-once",
ts: 1,
};
}
function pairedIosOperator(options: {
deviceId?: string;
publicKey?: string;
@@ -126,13 +167,16 @@ vi.mock("../infra/push-apns.js", () => ({
resolveApnsRelayConfigFromEnv: resolveApnsRelayConfigFromEnvMock,
sendApnsExecApprovalAlert: sendApnsExecApprovalAlertMock,
sendApnsExecApprovalResolvedWake: sendApnsExecApprovalResolvedWakeMock,
sendApnsPluginApprovalAlert: sendApnsPluginApprovalAlertMock,
sendApnsPluginApprovalResolvedWake: sendApnsPluginApprovalResolvedWakeMock,
clearApnsRegistrationIfCurrent: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(() => false),
}));
describe("createExecApprovalIosPushDelivery", () => {
beforeAll(async () => {
({ createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js"));
({ createExecApprovalIosPushDelivery, createPluginApprovalIosPushDelivery } =
await import("./exec-approval-ios-push.js"));
});
beforeEach(() => {
@@ -153,6 +197,8 @@ describe("createExecApprovalIosPushDelivery", () => {
resolveApnsRelayConfigFromEnvMock.mockReturnValue({ ok: false, error: "unused" });
sendApnsExecApprovalAlertMock.mockResolvedValue(successfulApnsPushResult());
sendApnsExecApprovalResolvedWakeMock.mockResolvedValue(successfulApnsPushResult());
sendApnsPluginApprovalAlertMock.mockResolvedValue(successfulApnsPushResult());
sendApnsPluginApprovalResolvedWakeMock.mockResolvedValue(successfulApnsPushResult());
});
it("does not target iOS devices whose active operator token lacks operator.approvals", async () => {
@@ -328,4 +374,92 @@ describe("createExecApprovalIosPushDelivery", () => {
expect(loadApnsRegistrationsMock).toHaveBeenCalledWith(["ios-device-1"]);
expect(sendApnsExecApprovalResolvedWakeMock).toHaveBeenCalledTimes(1);
});
describe("createPluginApprovalIosPushDelivery", () => {
it("targets only paired iOS operators with approval and read scopes", async () => {
mockPairedIosOperators(
pairedIosOperator({
deviceId: "ios-approved",
scopes: ["operator.approvals", "operator.read"],
}),
pairedIosOperator({
deviceId: "ios-read-only",
scopes: ["operator.read"],
}),
);
const delivery = createPluginApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested(pluginApprovalRequest("plugin:direct"));
expect(accepted).toBe(true);
expect(loadApnsRegistrationsMock).toHaveBeenCalledWith(["ios-approved"]);
expect(sendApnsPluginApprovalAlertMock).toHaveBeenCalledTimes(1);
expect(sendApnsPluginApprovalAlertMock).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-approved",
approvalId: "plugin:direct",
gatewayDeviceId: "gateway-device-1",
title: "Install plugin update",
description: "Allow the plugin to update its managed package.",
}),
);
});
it("uses the shared relay delivery plan for plugin alerts", async () => {
const relayConfig = { baseUrl: "https://relay.example.test", timeoutMs: 10_000 };
mockPairedIosOperator(["operator.approvals", "operator.read"]);
loadApnsRegistrationMock.mockResolvedValue(relayApnsRegistration());
resolveApnsRelayConfigFromEnvMock.mockReturnValue({ ok: true, value: relayConfig });
const delivery = createPluginApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested(pluginApprovalRequest("plugin:relay"));
expect(accepted).toBe(true);
expect(sendApnsPluginApprovalAlertMock).toHaveBeenCalledWith(
expect.objectContaining({
registration: expect.objectContaining({ transport: "relay" }),
relayConfig,
approvalId: "plugin:relay",
}),
);
expect(resolveApnsAuthConfigFromEnvMock).not.toHaveBeenCalled();
});
it("sends plugin cleanup wakes for resolved and expired requests", async () => {
mockPairedIosOperator(["operator.approvals", "operator.read"]);
const delivery = createPluginApprovalIosPushDelivery({ log: {} });
const resolvedRequest = pluginApprovalRequest("plugin:resolved");
const expiredRequest = pluginApprovalRequest("plugin:expired");
await delivery.handleRequested(resolvedRequest);
await delivery.handleRequested(expiredRequest);
await delivery.handleResolved(pluginApprovalResolved(resolvedRequest.id));
await delivery.handleExpired(expiredRequest);
expect(sendApnsPluginApprovalResolvedWakeMock).toHaveBeenCalledTimes(2);
expect(sendApnsPluginApprovalResolvedWakeMock).toHaveBeenCalledWith(
expect.objectContaining({ approvalId: "plugin:resolved" }),
);
expect(sendApnsPluginApprovalResolvedWakeMock).toHaveBeenCalledWith(
expect.objectContaining({ approvalId: "plugin:expired" }),
);
});
it("routes exec and plugin factories through the same paired-target resolver", async () => {
mockPairedIosOperator(["operator.approvals", "operator.read"]);
await createExecApprovalIosPushDelivery({ log: {} }).handleRequested(
approvalRequest("exec-shared-target"),
);
await createPluginApprovalIosPushDelivery({ log: {} }).handleRequested(
pluginApprovalRequest("plugin:shared-target"),
);
expect(listDevicePairingMock).toHaveBeenCalledTimes(2);
expect(loadApnsRegistrationsMock).toHaveBeenNthCalledWith(1, ["ios-device-1"]);
expect(loadApnsRegistrationsMock).toHaveBeenNthCalledWith(2, ["ios-device-1"]);
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
expect(sendApnsPluginApprovalAlertMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,4 +1,4 @@
// Gateway iOS exec-approval push delivery.
// Gateway iOS approval push delivery.
// Sends APNs request/resolution wakes to paired operator devices.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { getRuntimeConfig } from "../config/io.js";
@@ -10,7 +10,8 @@ import {
type PairedDevice,
} from "../infra/device-pairing.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import type { ExecApprovalRequest } from "../infra/exec-approvals.js";
import type { PluginApprovalRequest } from "../infra/plugin-approvals.js";
import {
clearApnsRegistrationIfCurrent,
loadApnsRegistrations,
@@ -18,6 +19,8 @@ import {
resolveApnsRelayConfigFromEnv,
sendApnsExecApprovalAlert,
sendApnsExecApprovalResolvedWake,
sendApnsPluginApprovalAlert,
sendApnsPluginApprovalResolvedWake,
shouldClearStoredApnsRegistration,
type ApnsAuthConfig,
type ApnsRegistration,
@@ -25,7 +28,7 @@ import {
} from "../infra/push-apns.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
// iOS exec-approval push delivery targets paired operator devices with APNs
// iOS approval push delivery targets paired operator devices with APNs
// registrations. Request pushes require approval scope plus identity-read access
// so the client can validate gateway ownership before presenting or resolving.
// Cleanup pushes reuse original targets so badges can clear after scope changes.
@@ -72,6 +75,25 @@ type ApprovalPushSender = (params: {
plan: DeliveryPlan;
}) => Promise<ApprovalPushSendResult>;
type ApprovalRequestLike = { id: string };
type ApprovalResolvedLike = { id: string };
type ApprovalPushDriver<TRequest extends ApprovalRequestLike> = {
approvalKind: "exec" | "plugin";
sendRequested: (params: {
request: TRequest;
target: DeliveryTarget;
plan: DeliveryPlan;
gatewayDeviceId: string;
}) => Promise<ApprovalPushSendResult>;
sendResolved: (params: {
approvalId: string;
target: DeliveryTarget;
plan: DeliveryPlan;
gatewayDeviceId: string;
}) => Promise<ApprovalPushSendResult>;
};
function isIosPlatform(platform: string | undefined): boolean {
const normalized = normalizeOptionalLowercaseString(platform) ?? "";
return normalized.startsWith("ios") || normalized.startsWith("ipados");
@@ -85,7 +107,7 @@ function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | nul
return operatorToken;
}
function canReceiveExecApprovalRequests(device: PairedDevice): boolean {
function canReceiveApprovalRequests(device: PairedDevice): boolean {
const operatorToken = resolveActiveOperatorToken(device);
if (!operatorToken) {
return false;
@@ -110,7 +132,7 @@ function shouldTargetDevice(params: {
if (!params.requireApprovalScope) {
return true;
}
return canReceiveExecApprovalRequests(params.device);
return canReceiveApprovalRequests(params.device);
}
async function loadRegisteredTargets(params: {
@@ -149,6 +171,7 @@ async function resolvePairedTargets(params: {
}
async function resolveDeliveryPlan(params: {
approvalKind: "exec" | "plugin";
requireApprovalScope: boolean;
explicitNodeIds?: readonly string[];
isTargetVisible?: (target: ApprovalPushTarget) => boolean;
@@ -175,7 +198,9 @@ async function resolveDeliveryPlan(params: {
if (auth.ok) {
directAuth = auth.value;
} else {
params.log.warn?.(`exec approvals: iOS direct APNs auth unavailable: ${auth.error}`);
params.log.warn?.(
`${params.approvalKind} approvals: iOS direct APNs auth unavailable: ${auth.error}`,
);
}
}
@@ -191,7 +216,9 @@ async function resolveDeliveryPlan(params: {
if (relay.ok) {
relayConfigByNodeId.set(target.nodeId, relay.value);
} else {
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
params.log.warn?.(
`${params.approvalKind} approvals: iOS relay APNs config unavailable: ${relay.error}`,
);
}
}
}
@@ -229,34 +256,27 @@ async function clearStaleApnsRegistrationIfNeeded(params: {
}
}
async function sendRequestedPushes(params: {
request: ExecApprovalRequest;
async function sendRequestedPushes<TRequest extends ApprovalRequestLike>(params: {
request: TRequest;
plan: DeliveryPlan;
log: GatewayLikeLogger;
driver: ApprovalPushDriver<TRequest>;
}): Promise<{ attempted: number; delivered: number }> {
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
return await sendApprovalPushes({
approvalId: params.request.id,
plan: params.plan,
log: params.log,
approvalKind: params.driver.approvalKind,
label: "request",
logThrown: true,
send: async ({ target, approvalId, plan }) =>
target.registration.transport === "direct"
? await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
send: async ({ target, plan }) =>
await params.driver.sendRequested({
request: params.request,
target,
plan,
gatewayDeviceId,
}),
});
}
@@ -264,6 +284,7 @@ async function sendApprovalPushes(params: {
approvalId: string;
plan: DeliveryPlan;
log: GatewayLikeLogger;
approvalKind: "exec" | "plugin";
label: "request" | "cleanup";
logThrown: boolean;
send: ApprovalPushSender;
@@ -284,7 +305,7 @@ async function sendApprovalPushes(params: {
});
if (!result.ok) {
params.log.warn?.(
`exec approvals: iOS ${params.label} push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
`${params.approvalKind} approvals: iOS ${params.label} push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
);
}
return { nodeId: target.nodeId, ok: result.ok };
@@ -293,7 +314,9 @@ async function sendApprovalPushes(params: {
for (const result of results) {
if (params.logThrown && result.status === "rejected") {
const message = formatErrorMessage(result.reason);
params.log.warn?.(`exec approvals: iOS ${params.label} push threw error: ${message}`);
params.log.warn?.(
`${params.approvalKind} approvals: iOS ${params.label} push threw error: ${message}`,
);
}
}
return {
@@ -302,38 +325,34 @@ async function sendApprovalPushes(params: {
};
}
async function sendResolvedPushes(params: {
async function sendResolvedPushes<TRequest extends ApprovalRequestLike>(params: {
approvalId: string;
plan: DeliveryPlan;
log: GatewayLikeLogger;
driver: ApprovalPushDriver<TRequest>;
}): Promise<void> {
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
await sendApprovalPushes({
approvalId: params.approvalId,
plan: params.plan,
log: params.log,
approvalKind: params.driver.approvalKind,
label: "cleanup",
logThrown: false,
send: async ({ target, approvalId, plan }) =>
target.registration.transport === "direct"
? await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
await params.driver.sendResolved({
approvalId,
target,
plan,
gatewayDeviceId,
}),
});
}
export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
function createApprovalIosPushDelivery<TRequest extends ApprovalRequestLike>(params: {
log: GatewayLikeLogger;
driver: ApprovalPushDriver<TRequest>;
}) {
const approvalDeliveriesById = new Map<string, ApprovalDeliveryState>();
const pendingDeliveryStateById = new Map<string, Promise<ApprovalDeliveryState | null>>();
@@ -346,12 +365,13 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
pendingDeliveryStateById.delete(approvalId);
if (!deliveryState?.nodeIds.length) {
params.log.debug?.(
`exec approvals: iOS cleanup push skipped approvalId=${approvalId} reason=missing-targets`,
`${params.driver.approvalKind} approvals: iOS cleanup push skipped approvalId=${approvalId} reason=missing-targets`,
);
return;
}
await deliveryState.requestPushPromise;
const plan = await resolveDeliveryPlan({
approvalKind: params.driver.approvalKind,
requireApprovalScope: false,
explicitNodeIds: deliveryState.nodeIds,
log: params.log,
@@ -363,17 +383,19 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
approvalId,
plan,
log: params.log,
driver: params.driver,
});
};
return {
/** Sends the initial approval notification to visible iOS operator devices. */
async handleRequested(
request: ExecApprovalRequest,
request: TRequest,
opts?: { isTargetVisible?: (target: ApprovalPushTarget) => boolean },
): Promise<boolean> {
const deliveryStatePromise = (async (): Promise<ApprovalDeliveryState | null> => {
const plan = await resolveDeliveryPlan({
approvalKind: params.driver.approvalKind,
requireApprovalScope: true,
isTargetVisible: opts?.isTargetVisible,
log: params.log,
@@ -385,13 +407,18 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
const deliveryState: ApprovalDeliveryState = {
nodeIds: plan.targets.map((target) => target.nodeId),
requestPushPromise: sendRequestedPushes({ request, plan, log: params.log }).catch(
(err: unknown) => {
const message = formatErrorMessage(err);
params.log.error?.(`exec approvals: iOS request push failed: ${message}`);
return { attempted: plan.targets.length, delivered: 0 };
},
),
requestPushPromise: sendRequestedPushes({
request,
plan,
log: params.log,
driver: params.driver,
}).catch((err: unknown) => {
const message = formatErrorMessage(err);
params.log.error?.(
`${params.driver.approvalKind} approvals: iOS request push failed: ${message}`,
);
return { attempted: plan.targets.length, delivered: 0 };
}),
};
approvalDeliveriesById.set(request.id, deliveryState);
return deliveryState;
@@ -409,7 +436,7 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
const { attempted, delivered } = await deliveryState.requestPushPromise;
if (attempted > 0 && delivered === 0) {
params.log.warn?.(
`exec approvals: iOS request push reached no devices approvalId=${request.id} attempted=${attempted}`,
`${params.driver.approvalKind} approvals: iOS request push reached no devices approvalId=${request.id} attempted=${attempted}`,
);
if (
approvalDeliveriesById.get(request.id)?.requestPushPromise ===
@@ -423,13 +450,101 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
},
/** Sends cleanup wakes for resolved approval requests. */
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
async handleResolved(resolved: ApprovalResolvedLike): Promise<void> {
await sendCleanupPushForApproval(resolved.id);
},
/** Sends cleanup wakes for expired approval requests. */
async handleExpired(request: ExecApprovalRequest): Promise<void> {
async handleExpired(request: TRequest): Promise<void> {
await sendCleanupPushForApproval(request.id);
},
};
}
/** Creates iOS push delivery for exec approval requests. */
export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
return createApprovalIosPushDelivery<ExecApprovalRequest>({
log: params.log,
driver: {
approvalKind: "exec",
sendRequested: async ({ request, target, plan, gatewayDeviceId }) =>
target.registration.transport === "direct"
? await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: request.id,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: request.id,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
sendResolved: async ({ approvalId, target, plan, gatewayDeviceId }) =>
target.registration.transport === "direct"
? await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
},
});
}
/** Creates iOS push delivery for plugin approval requests. */
export function createPluginApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
return createApprovalIosPushDelivery<PluginApprovalRequest>({
log: params.log,
driver: {
approvalKind: "plugin",
sendRequested: async ({ request, target, plan, gatewayDeviceId }) =>
target.registration.transport === "direct"
? await sendApnsPluginApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: request.id,
gatewayDeviceId,
title: request.request.title,
description: request.request.description,
auth: plan.directAuth!,
})
: await sendApnsPluginApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: request.id,
gatewayDeviceId,
title: request.request.title,
description: request.request.description,
relayConfig: plan.relayConfig!,
}),
sendResolved: async ({ approvalId, target, plan, gatewayDeviceId }) =>
target.registration.transport === "direct"
? await sendApnsPluginApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsPluginApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
},
});
}

View File

@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveCanonicalPluginApprovalRequestAllowedDecisions } from "../infra/plugin-approval-canonical-decisions.js";
import {
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
type PluginApprovalRequest,
type PluginApprovalRequestPayload,
} from "../infra/plugin-approvals.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
@@ -63,6 +64,7 @@ function createContext(opts?: {
nodeSession?: NodeSession;
hasExecApprovalClients?: GatewayRequestContext["hasExecApprovalClients"];
forwardPluginApprovalRequest?: GatewayRequestContext["forwardPluginApprovalRequest"];
pluginApprovalIosPushDelivery?: GatewayRequestContext["pluginApprovalIosPushDelivery"];
}) {
const nodeSession = opts?.nodeSession ?? createNodeSession();
const invoke = vi.fn(async () => ({
@@ -83,6 +85,7 @@ function createContext(opts?: {
getApprovalClientConnIds: opts?.getApprovalClientConnIds,
hasExecApprovalClients: opts?.hasExecApprovalClients,
forwardPluginApprovalRequest: opts?.forwardPluginApprovalRequest,
pluginApprovalIosPushDelivery: opts?.pluginApprovalIosPushDelivery,
} as unknown as GatewayRequestContext,
invoke,
};
@@ -465,6 +468,70 @@ describe("applyPluginNodeInvokePolicy", () => {
await expectApprovalResolution(resultPromise, manager, record);
});
it("delivers plugin policy approvals to visible iOS reviewers", async () => {
const manager = new ExecApprovalManager<PluginApprovalRequestPayload>();
const handleRequested = vi.fn(
async (
_request: PluginApprovalRequest,
_opts?: {
isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean;
},
) => true,
);
setDangerousDemoCommandRegistry([createApprovalRequestPolicy()]);
const { context } = createContext({
pluginApprovalManager: manager,
getApprovalClientConnIds: vi.fn(() => new Set<string>()),
hasExecApprovalClients: vi.fn(() => false),
pluginApprovalIosPushDelivery: { handleRequested },
});
const resultPromise = invokeDemoPolicy(context, createOperatorClient());
const record = await expectSinglePendingApproval(manager);
expect(handleRequested).toHaveBeenCalledTimes(1);
const deliveryOptions = handleRequested.mock.calls[0]?.[1];
expect(
deliveryOptions?.isTargetVisible?.({
deviceId: "device-owner",
scopes: ["operator.approvals", "operator.read"],
}),
).toBe(true);
expect(
deliveryOptions?.isTargetVisible?.({
deviceId: "device-other",
scopes: ["operator.approvals", "operator.read"],
}),
).toBe(false);
await expectApprovalResolution(resultPromise, manager, record);
});
it("sends an iOS cleanup wake when a plugin policy approval expires", async () => {
const manager = new ExecApprovalManager<PluginApprovalRequestPayload>();
const handleExpired = vi.fn(async () => {});
setDangerousDemoCommandRegistry([createApprovalRequestPolicy()]);
const { context } = createContext({
pluginApprovalManager: manager,
getApprovalClientConnIds: vi.fn(() => new Set<string>()),
hasExecApprovalClients: vi.fn(() => false),
pluginApprovalIosPushDelivery: {
handleRequested: vi.fn(async () => true),
handleExpired,
},
});
const resultPromise = invokeDemoPolicy(context, createOperatorClient());
const record = await expectSinglePendingApproval(manager);
manager.expire(record.id, "timeout");
await expect(resultPromise).resolves.toStrictEqual({
ok: true,
payload: { id: record.id, decision: null },
});
expect(handleExpired).toHaveBeenCalledWith(expect.objectContaining({ id: record.id }));
});
it("ignores approval routes from unsigned node.invoke clients", async () => {
const manager = new ExecApprovalManager<PluginApprovalRequestPayload>();
const forwardPluginApprovalRequest = vi.fn(async () => false);

View File

@@ -3,6 +3,7 @@
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";
@@ -18,6 +19,7 @@ import {
bindApprovalRequesterMetadata,
buildRequestedApprovalEvent,
handlePendingApprovalRequest,
isApprovalRecordVisibleToClient,
} from "./server-methods/approval-shared.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./server-methods/types.js";
@@ -133,17 +135,60 @@ function createApprovalRuntime(params: {
twoPhase: false,
approvalKind: "plugin",
deliverRequest: () => {
const deliveryTasks: Array<Promise<boolean>> = [];
const forward = params.context.forwardPluginApprovalRequest;
if (!forward) {
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 forward(requestEvent).catch((err: unknown) => {
params.context.logGateway?.error?.(
`plugin approvals: forward node policy request failed: ${String(err)}`,
);
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

View File

@@ -32,7 +32,10 @@ import {
type ChannelKind,
type GatewayReloadPlan,
} from "./config-reload-plan.js";
import { createExecApprovalIosPushDelivery } from "./exec-approval-ios-push.js";
import {
createExecApprovalIosPushDelivery,
createPluginApprovalIosPushDelivery,
} from "./exec-approval-ios-push.js";
import {
ExecApprovalManager,
type OperatorApprovalLifecycleEvent,
@@ -159,6 +162,7 @@ export function createGatewayAuxHandlers(params: {
"plugin",
resolveCanonicalPluginApprovalRequestAllowedDecisions,
);
const pluginApprovalIosPushDelivery = createPluginApprovalIosPushDelivery({ log: params.log });
const systemAgentApprovalManager = createApprovalManager<SystemAgentApprovalRequestPayload>(
"system-agent",
() => SYSTEM_AGENT_APPROVAL_DECISIONS,
@@ -168,6 +172,7 @@ export function createGatewayAuxHandlers(params: {
import("./server-methods/plugin-approval.js").then(({ createPluginApprovalHandlers }) =>
createPluginApprovalHandlers(pluginApprovalManager, {
forwarder: execApprovalForwarder,
iosPushDelivery: pluginApprovalIosPushDelivery,
}),
),
{ cacheRejections: true },
@@ -181,6 +186,7 @@ export function createGatewayAuxHandlers(params: {
systemAgentApprovalManager,
forwarder: execApprovalForwarder,
iosPushDelivery: execApprovalIosPushDelivery,
pluginIosPushDelivery: pluginApprovalIosPushDelivery,
}),
),
{ cacheRejections: true },
@@ -505,6 +511,7 @@ export function createGatewayAuxHandlers(params: {
return {
execApprovalManager,
forwardPluginApprovalRequest: execApprovalForwarder.handlePluginApprovalRequested,
pluginApprovalIosPushDelivery,
pluginApprovalManager,
systemAgentApprovalManager,
extraHandlers: {

View File

@@ -32,6 +32,10 @@ export type ExecApprovalIosPushDelivery = {
handleResolved?: (resolved: ExecApprovalResolved) => Promise<void>;
};
export type PluginApprovalIosPushDelivery = {
handleResolved?: (resolved: PluginApprovalResolved) => Promise<void>;
};
function broadcastResolvedEvent(params: {
context: GatewayRequestContext;
eventName: "exec.approval.resolved" | "plugin.approval.resolved" | "openclaw.approval.resolved";
@@ -96,6 +100,7 @@ export async function publishAppliedApprovalResolution(params: {
context: GatewayRequestContext;
forwarder?: ExecApprovalForwarder;
iosPushDelivery?: ExecApprovalIosPushDelivery;
pluginIosPushDelivery?: PluginApprovalIosPushDelivery;
}): Promise<void> {
const decision = params.record.decision ?? "deny";
const resolvedBy = params.liveRecord.resolvedBy ?? null;
@@ -159,4 +164,12 @@ export async function publishAppliedApprovalResolution(params: {
run: () => params.forwarder!.handlePluginApprovalResolved!(event as PluginApprovalResolved),
});
}
if (params.record.kind === "plugin" && params.pluginIosPushDelivery?.handleResolved) {
await runSideEffect({
context: params.context,
approvalKind: "plugin",
effect: "ios-push",
run: () => params.pluginIosPushDelivery!.handleResolved!(event as PluginApprovalResolved),
});
}
}

View File

@@ -830,6 +830,7 @@ describe("unified approval handlers", () => {
});
const context = createContext();
const handlePluginApprovalResolved = vi.fn(async () => {});
const handlePluginIosPushResolved = vi.fn(async () => {});
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
@@ -841,6 +842,7 @@ describe("unified approval handlers", () => {
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
forwarder,
pluginIosPushDelivery: { handleResolved: handlePluginIosPushResolved },
databaseOptions,
});
@@ -891,6 +893,10 @@ describe("unified approval handlers", () => {
id: "phone-device",
});
expect(handlePluginApprovalResolved).toHaveBeenCalledTimes(1);
expect(handlePluginIosPushResolved).toHaveBeenCalledTimes(1);
expect(handlePluginIosPushResolved).toHaveBeenCalledWith(
expect.objectContaining({ id: pending.record.id, decision: "deny" }),
);
const recipientLookup = context.getApprovalClientConnIds as ReturnType<typeof vi.fn>;
const recipientOptions = recipientLookup.mock.calls[0]?.[0] as
| {

View File

@@ -34,6 +34,7 @@ import {
import {
publishAppliedApprovalResolution,
type ExecApprovalIosPushDelivery,
type PluginApprovalIosPushDelivery,
} from "./approval-publication.js";
import type {
GatewayClient,
@@ -48,6 +49,7 @@ type CreateApprovalHandlersParams = {
systemAgentApprovalManager?: ExecApprovalManager<SystemAgentApprovalRequestPayload>;
forwarder?: ExecApprovalForwarder;
iosPushDelivery?: ExecApprovalIosPushDelivery;
pluginIosPushDelivery?: PluginApprovalIosPushDelivery;
databaseOptions?: OpenClawStateDatabaseOptions;
};
@@ -484,6 +486,7 @@ export function createApprovalHandlers(
context,
forwarder: params.forwarder,
iosPushDelivery: params.iosPushDelivery,
pluginIosPushDelivery: params.pluginIosPushDelivery,
}).catch((error: unknown) => {
context.logGateway?.error?.(
`${terminalRecord.kind} approvals: unified resolve publication failed: ${String(error)}`,

View File

@@ -324,6 +324,92 @@ describe("createPluginApprovalHandlers", () => {
expect(finalResult.decision).toBe("allow-once");
});
it("delivers requests to iOS push with the exec-equivalent visibility gate", async () => {
const handleRequested = vi.fn(async () => true);
const handlers = createPluginApprovalHandlers(manager, {
iosPushDelivery: { handleRequested },
});
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{
title: "Sensitive action",
description: "Review on the paired iPhone",
approvalReviewerDeviceIds: ["ios-reviewer"],
twoPhase: true,
},
{
client: createClient({
clientId: "gateway-client",
approvalRuntime: true,
}),
context: createNoExecApprovalContext(),
respond,
},
);
const requestPromise = expectDefined(
handlers["plugin.approval.request"],
'handlers["plugin.approval.request"] test invariant',
)(opts);
const approvalId = await waitForAcceptedApproval(respond);
expect(handleRequested).toHaveBeenCalledTimes(1);
const requestCall = mockCall(handleRequested, 0, "iOS request delivery");
expect(requireRecord(requestCall[0], "iOS request").id).toBe(approvalId);
const deliveryOptions = requireRecord(requestCall[1], "iOS delivery options");
const isTargetVisible = deliveryOptions.isTargetVisible;
expect(isTargetVisible).toBeTypeOf("function");
const visibility = isTargetVisible as (target: {
deviceId: string;
scopes: readonly string[];
}) => boolean;
expect(
visibility({
deviceId: "ios-reviewer",
scopes: ["operator.approvals", "operator.read"],
}),
).toBe(true);
expect(
visibility({
deviceId: "other-device",
scopes: ["operator.approvals", "operator.read"],
}),
).toBe(false);
manager.resolve(approvalId, "allow-once");
await requestPromise;
});
it("sends an iOS cleanup wake when a plugin request expires", async () => {
const handleExpired = vi.fn(async () => {});
const handlers = createPluginApprovalHandlers(manager, {
iosPushDelivery: {
handleRequested: vi.fn(async () => true),
handleExpired,
},
});
const respond = vi.fn();
const opts = createMockOptions(
"plugin.approval.request",
{ title: "Sensitive action", description: "Desc", twoPhase: true },
{ context: createNoExecApprovalContext(), respond },
);
const requestPromise = expectDefined(
handlers["plugin.approval.request"],
'handlers["plugin.approval.request"] test invariant',
)(opts);
const approvalId = await waitForAcceptedApproval(respond);
manager.expire(approvalId, "timeout");
await requestPromise;
expect(handleExpired).toHaveBeenCalledTimes(1);
expect(
requireRecord(mockCall(handleExpired, 0, "expired push")[0], "expired request").id,
).toBe(approvalId);
});
it("expires immediately when no approval route", async () => {
const handlers = createPluginApprovalHandlers(manager);
const opts = createMockOptions(
@@ -784,6 +870,33 @@ describe("createPluginApprovalHandlers", () => {
expect(resolvedBroadcast.options).toEqual({ dropIfSlow: true });
});
it("sends an iOS cleanup wake when a plugin approval resolves", async () => {
const handleResolved = vi.fn(async () => {});
const handlers = createPluginApprovalHandlers(manager, {
iosPushDelivery: { handleResolved },
});
const record = registerApproval(manager);
await expectDefined(
handlers["plugin.approval.resolve"],
'handlers["plugin.approval.resolve"] test invariant',
)(
createMockOptions("plugin.approval.resolve", {
id: record.id,
decision: "deny",
}),
);
expect(handleResolved).toHaveBeenCalledTimes(1);
expect(
requireRecord(mockCall(handleResolved, 0, "resolved push")[0], "resolved event"),
).toMatchObject({
id: record.id,
decision: "deny",
request: record.request,
});
});
it("resolves only plugin approvals owned by the caller", async () => {
const handlers = createPluginApprovalHandlers(manager);
const visible = registerOwnedApproval(manager, {

View File

@@ -1,6 +1,7 @@
// 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,
@@ -10,7 +11,11 @@ import {
} 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 { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.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 {
@@ -20,16 +25,28 @@ import {
handleApprovalResolve,
handleApprovalWaitDecision,
handlePendingApprovalRequest,
isApprovalRecordVisibleToClient,
listVisiblePendingApprovalRequests,
registerPendingApprovalRecord,
resolveApprovalDecisionParams,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.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 },
opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: PluginApprovalIosPushDelivery },
): GatewayRequestHandlers {
return {
"plugin.approval.list": async ({ respond, client }) => {
@@ -131,18 +148,58 @@ export function createPluginApprovalHandlers(
twoPhase,
approvalKind: "plugin",
deliverRequest: () => {
if (!opts?.forwarder?.handlePluginApprovalRequested) {
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 opts.forwarder
.handlePluginApprovalRequested(requestEvent)
.catch((err: unknown) => {
context.logGateway?.error?.(
`plugin approvals: forward request failed: ${String(err)}`,
);
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",
});
},
@@ -193,16 +250,25 @@ export function createPluginApprovalHandlers(
resolvedBy,
snapshot,
nowMs,
}) => ({
id: approvalId,
decision: decisionLocal,
resolvedBy,
ts: nowMs,
request: snapshot.request,
}),
}) =>
({
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,
});
},
};

View File

@@ -124,6 +124,15 @@ export type GatewayRequestContext = {
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
systemAgentApprovalManager?: ExecApprovalManager<SystemAgentApprovalRequestPayload>;
forwardPluginApprovalRequest?: (request: PluginApprovalRequest) => Promise<boolean>;
pluginApprovalIosPushDelivery?: {
handleRequested?: (
request: PluginApprovalRequest,
opts?: {
isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean;
},
) => Promise<boolean>;
handleExpired?: (request: PluginApprovalRequest) => Promise<void>;
};
listSessionPendingApprovals?: (
sessionKey: string,
client: GatewayClient | null,

View File

@@ -21,6 +21,7 @@ type GatewayRequestContextParams = {
isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"];
execApprovalManager: GatewayRequestContext["execApprovalManager"];
forwardPluginApprovalRequest?: GatewayRequestContext["forwardPluginApprovalRequest"];
pluginApprovalIosPushDelivery?: GatewayRequestContext["pluginApprovalIosPushDelivery"];
pluginApprovalManager: GatewayRequestContext["pluginApprovalManager"];
systemAgentApprovalManager?: GatewayRequestContext["systemAgentApprovalManager"];
listSessionPendingApprovals: GatewayRequestContext["listSessionPendingApprovals"];
@@ -113,6 +114,7 @@ export function createGatewayRequestContext(
isTerminalEnabled: params.isTerminalEnabled,
execApprovalManager: params.execApprovalManager,
forwardPluginApprovalRequest: params.forwardPluginApprovalRequest,
pluginApprovalIosPushDelivery: params.pluginApprovalIosPushDelivery,
pluginApprovalManager: params.pluginApprovalManager,
systemAgentApprovalManager: params.systemAgentApprovalManager,
listSessionPendingApprovals: params.listSessionPendingApprovals,

View File

@@ -1568,6 +1568,7 @@ export async function startGatewayServer(
const {
execApprovalManager,
forwardPluginApprovalRequest,
pluginApprovalIosPushDelivery,
pluginApprovalManager,
systemAgentApprovalManager,
extraHandlers,
@@ -1849,6 +1850,7 @@ export async function startGatewayServer(
isTerminalEnabled: terminalLaunchPolicy.isEnabled,
execApprovalManager,
forwardPluginApprovalRequest,
pluginApprovalIosPushDelivery,
pluginApprovalManager,
systemAgentApprovalManager,
listSessionPendingApprovals: approvalSessionEvents.replay,

View File

@@ -12,6 +12,8 @@ import {
sendApnsBackgroundWake,
sendApnsExecApprovalAlert,
sendApnsExecApprovalResolvedWake,
sendApnsPluginApprovalAlert,
sendApnsPluginApprovalResolvedWake,
} from "./push-apns.js";
const testAuthPrivateKey = generateKeyPairSync("ec", {
@@ -573,6 +575,104 @@ describe("push APNs send semantics", () => {
expect(result.transport).toBe("direct");
});
it("builds plugin approval alerts with request copy and a bounded body", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-plugin-approval-alert",
environment: "sandbox",
sendResult: {
status: 200,
apnsId: "apns-plugin-approval-alert-id",
body: "",
},
});
const description = `${"x".repeat(255)}😀${"y".repeat(300)}`;
await sendApnsPluginApprovalAlert({
registration,
nodeId: "ios-node-plugin-approval-alert",
approvalId: "plugin:approval-123",
gatewayDeviceId: "gateway-device-123",
title: "Install plugin update",
description,
auth,
requestSender: send,
});
const payload = requirePayload(requireSendRequest(send));
expect(payload.aps).toEqual({
alert: {
title: "Install plugin update",
body: `${"x".repeat(255)}`,
},
sound: "default",
category: "openclaw.plugin-approval",
"content-available": 1,
});
const openclawPayload = requireRecord(payload.openclaw, "openclaw payload");
expectRecordFields(openclawPayload, {
kind: "plugin.approval.requested",
approvalId: "plugin:approval-123",
gatewayDeviceId: "gateway-device-123",
});
expect(typeof openclawPayload.ts).toBe("number");
expectNoProperties(openclawPayload, [
"title",
"description",
"toolName",
"agentId",
"sessionKey",
]);
});
it("falls back to the generic plugin approval title", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-plugin-approval-fallback",
environment: "sandbox",
sendResult: { status: 200, apnsId: "apns-plugin-approval-fallback-id", body: "" },
});
await sendApnsPluginApprovalAlert({
registration,
nodeId: "ios-node-plugin-approval-fallback",
approvalId: "plugin:fallback",
gatewayDeviceId: "gateway-device-123",
title: " ",
description: "Review this request.",
auth,
requestSender: send,
});
const aps = requireRecord(requirePayload(requireSendRequest(send)).aps, "APNs aps payload");
expect(requireRecord(aps.alert, "APNs alert").title).toBe("Approval required");
});
it("builds plugin approval cleanup pushes as silent background notifications", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-plugin-approval-cleanup",
environment: "sandbox",
sendResult: { status: 200, apnsId: "apns-plugin-approval-cleanup-id", body: "" },
});
await sendApnsPluginApprovalResolvedWake({
registration,
nodeId: "ios-node-plugin-approval-cleanup",
approvalId: "plugin:approval-123",
gatewayDeviceId: "gateway-device-123",
auth,
requestSender: send,
});
const payload = requirePayload(requireSendRequest(send));
expect(payload.aps).toEqual({ "content-available": 1 });
const openclawPayload = requireRecord(payload.openclaw, "openclaw payload");
expectRecordFields(openclawPayload, {
kind: "plugin.approval.resolved",
approvalId: "plugin:approval-123",
gatewayDeviceId: "gateway-device-123",
});
expect(typeof openclawPayload.ts).toBe("number");
});
it("parses direct send failures and clamps sub-second timeouts", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-direct-fail",

View File

@@ -82,6 +82,8 @@ type ApnsPushWakeResult = ApnsPushResult;
const EXEC_APPROVAL_GENERIC_ALERT_BODY = "Open OpenClaw to review this request.";
const EXEC_APPROVAL_NOTIFICATION_CATEGORY = "openclaw.exec-approval";
const PLUGIN_APPROVAL_ALERT_BODY_MAX_LENGTH = 256;
const PLUGIN_APPROVAL_NOTIFICATION_CATEGORY = "openclaw.plugin-approval";
type ApnsPushType = "alert" | "background";
@@ -928,22 +930,26 @@ function resolveExecApprovalAlertBody(): string {
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
}
function createExecApprovalAlertPayload(params: {
function createApprovalAlertPayload(params: {
kind: "exec" | "plugin";
approvalId: string;
gatewayDeviceId: string;
title: string;
body: string;
category: string;
}): object {
return {
aps: {
alert: {
title: "Exec approval required",
body: resolveExecApprovalAlertBody(),
title: params.title,
body: params.body,
},
sound: "default",
category: EXEC_APPROVAL_NOTIFICATION_CATEGORY,
category: params.category,
"content-available": 1,
},
openclaw: {
kind: "exec.approval.requested",
kind: `${params.kind}.approval.requested`,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now(),
@@ -951,7 +957,16 @@ function createExecApprovalAlertPayload(params: {
};
}
function createExecApprovalResolvedPayload(params: {
function resolvePluginApprovalAlertBody(description: string): string {
const body = normalizeOptionalString(description) ?? "";
if (body.length <= PLUGIN_APPROVAL_ALERT_BODY_MAX_LENGTH) {
return body;
}
return `${truncateUtf16Safe(body, PLUGIN_APPROVAL_ALERT_BODY_MAX_LENGTH - 1).trimEnd()}`;
}
function createApprovalResolvedPayload(params: {
kind: "exec" | "plugin";
approvalId: string;
gatewayDeviceId: string;
}): object {
@@ -960,7 +975,7 @@ function createExecApprovalResolvedPayload(params: {
"content-available": 1,
},
openclaw: {
kind: "exec.approval.resolved",
kind: `${params.kind}.approval.resolved`,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now(),
@@ -1015,14 +1030,14 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
requestSender?: never;
};
type ApnsExecApprovalAlertCommonParams = {
type ApnsApprovalCommonParams = {
nodeId: string;
approvalId: string;
gatewayDeviceId: string;
timeoutMs?: number;
};
type DirectApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
type DirectApnsApprovalParams = ApnsApprovalCommonParams & {
registration: DirectApnsRegistration;
auth: ApnsAuthConfig;
requestSender?: ApnsRequestSender;
@@ -1030,7 +1045,7 @@ type DirectApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
relayRequestSender?: never;
};
type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
type RelayApnsApprovalParams = ApnsApprovalCommonParams & {
registration: RelayApnsRegistration;
relayConfig: ApnsRelayConfig;
relayRequestSender?: ApnsRelayRequestSender;
@@ -1039,28 +1054,11 @@ type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
requestSender?: never;
};
type ApnsExecApprovalResolvedCommonParams = {
nodeId: string;
approvalId: string;
gatewayDeviceId: string;
timeoutMs?: number;
};
type ApnsApprovalParams = DirectApnsApprovalParams | RelayApnsApprovalParams;
type DirectApnsExecApprovalResolvedParams = ApnsExecApprovalResolvedCommonParams & {
registration: DirectApnsRegistration;
auth: ApnsAuthConfig;
requestSender?: ApnsRequestSender;
relayConfig?: never;
relayRequestSender?: never;
};
type RelayApnsExecApprovalResolvedParams = ApnsExecApprovalResolvedCommonParams & {
registration: RelayApnsRegistration;
relayConfig: ApnsRelayConfig;
relayRequestSender?: ApnsRelayRequestSender;
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
auth?: never;
requestSender?: never;
type ApnsPluginApprovalAlertParams = ApnsApprovalParams & {
title?: string | null;
description: string;
};
/** Sends a visible APNs alert via direct APNs token or relay registration. */
@@ -1130,71 +1128,104 @@ export async function sendApnsBackgroundWake(
});
}
/** Sends an exec-approval alert notification via direct APNs or relay. */
export async function sendApnsExecApprovalAlert(
params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams,
): Promise<ApnsPushAlertResult> {
const payload = createExecApprovalAlertPayload({
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
});
if (params.registration.transport === "relay") {
const relayParams = params as RelayApnsExecApprovalAlertParams;
async function sendApnsApprovalPush(params: {
transport: ApnsApprovalParams;
payload: object;
pushType: ApnsPushType;
priority: "10" | "5";
}): Promise<ApnsPushResult> {
const transport = params.transport;
if (transport.registration.transport === "relay") {
const relayParams = transport as RelayApnsApprovalParams;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload,
pushType: "alert",
priority: "10",
payload: params.payload,
pushType: params.pushType,
priority: params.priority,
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
});
}
const directParams = params as DirectApnsExecApprovalAlertParams;
const directParams = transport as DirectApnsApprovalParams;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload,
payload: params.payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
pushType: params.pushType,
priority: params.priority,
});
}
/** Sends an exec-approval alert notification via direct APNs or relay. */
export async function sendApnsExecApprovalAlert(
params: ApnsApprovalParams,
): Promise<ApnsPushAlertResult> {
return await sendApnsApprovalPush({
transport: params,
payload: createApprovalAlertPayload({
kind: "exec",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
title: "Exec approval required",
body: resolveExecApprovalAlertBody(),
category: EXEC_APPROVAL_NOTIFICATION_CATEGORY,
}),
pushType: "alert",
priority: "10",
});
}
/** Sends a silent wake telling the app an exec approval changed state. */
export async function sendApnsExecApprovalResolvedWake(
params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams,
): Promise<ApnsPushWakeResult> {
const payload = createExecApprovalResolvedPayload({
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
/** Sends a plugin-approval alert notification via direct APNs or relay. */
export async function sendApnsPluginApprovalAlert(
params: ApnsPluginApprovalAlertParams,
): Promise<ApnsPushAlertResult> {
return await sendApnsApprovalPush({
transport: params,
payload: createApprovalAlertPayload({
kind: "plugin",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
title: normalizeOptionalString(params.title) ?? "Approval required",
body: resolvePluginApprovalAlertBody(params.description),
category: PLUGIN_APPROVAL_NOTIFICATION_CATEGORY,
}),
pushType: "alert",
priority: "10",
});
}
if (params.registration.transport === "relay") {
const relayParams = params as RelayApnsExecApprovalResolvedParams;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload,
pushType: "background",
priority: "5",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
});
}
const directParams = params as DirectApnsExecApprovalResolvedParams;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
async function sendApnsApprovalResolvedWake(params: {
transport: ApnsApprovalParams;
kind: "exec" | "plugin";
}): Promise<ApnsPushWakeResult> {
return await sendApnsApprovalPush({
transport: params.transport,
payload: createApprovalResolvedPayload({
kind: params.kind,
approvalId: params.transport.approvalId,
gatewayDeviceId: params.transport.gatewayDeviceId,
}),
pushType: "background",
priority: "5",
});
}
/** Sends a silent wake telling the app an exec approval changed state. */
export async function sendApnsExecApprovalResolvedWake(
params: ApnsApprovalParams,
): Promise<ApnsPushWakeResult> {
return await sendApnsApprovalResolvedWake({ transport: params, kind: "exec" });
}
/** Sends a silent wake telling the app a plugin approval changed state. */
export async function sendApnsPluginApprovalResolvedWake(
params: ApnsApprovalParams,
): Promise<ApnsPushWakeResult> {
return await sendApnsApprovalResolvedWake({ transport: params, kind: "plugin" });
}
export { type ApnsRelayConfig, resolveApnsRelayConfigFromEnv };
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */