From bdcf6bdfdd84cc168b5ca51a3abf3e91c020dc84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 22:12:49 -0700 Subject: [PATCH] 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 --- src/gateway/exec-approval-ios-push.test.ts | 136 ++++++++++- src/gateway/exec-approval-ios-push.ts | 227 +++++++++++++----- src/gateway/node-invoke-plugin-policy.test.ts | 67 ++++++ src/gateway/node-invoke-plugin-policy.ts | 59 ++++- src/gateway/server-aux-handlers.ts | 9 +- .../server-methods/approval-publication.ts | 13 + src/gateway/server-methods/approval.test.ts | 6 + src/gateway/server-methods/approval.ts | 3 + .../server-methods/plugin-approval.test.ts | 113 +++++++++ src/gateway/server-methods/plugin-approval.ts | 104 ++++++-- src/gateway/server-methods/shared-types.ts | 9 + src/gateway/server-request-context.ts | 2 + src/gateway/server.impl.ts | 2 + src/infra/push-apns.test.ts | 100 ++++++++ src/infra/push-apns.ts | 177 ++++++++------ 15 files changed, 870 insertions(+), 157 deletions(-) diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index 43c01f7199e6..b596cfc42439 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -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); + }); + }); }); diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index 2bd56e76fd74..f72f486e6101 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -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; +type ApprovalRequestLike = { id: string }; +type ApprovalResolvedLike = { id: string }; + +type ApprovalPushDriver = { + approvalKind: "exec" | "plugin"; + sendRequested: (params: { + request: TRequest; + target: DeliveryTarget; + plan: DeliveryPlan; + gatewayDeviceId: string; + }) => Promise; + sendResolved: (params: { + approvalId: string; + target: DeliveryTarget; + plan: DeliveryPlan; + gatewayDeviceId: string; + }) => Promise; +}; + 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(params: { + request: TRequest; plan: DeliveryPlan; log: GatewayLikeLogger; + driver: ApprovalPushDriver; }): 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(params: { approvalId: string; plan: DeliveryPlan; log: GatewayLikeLogger; + driver: ApprovalPushDriver; }): Promise { 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(params: { + log: GatewayLikeLogger; + driver: ApprovalPushDriver; +}) { const approvalDeliveriesById = new Map(); const pendingDeliveryStateById = new Map>(); @@ -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 { const deliveryStatePromise = (async (): Promise => { 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 { + async handleResolved(resolved: ApprovalResolvedLike): Promise { await sendCleanupPushForApproval(resolved.id); }, /** Sends cleanup wakes for expired approval requests. */ - async handleExpired(request: ExecApprovalRequest): Promise { + async handleExpired(request: TRequest): Promise { await sendCleanupPushForApproval(request.id); }, }; } + +/** Creates iOS push delivery for exec approval requests. */ +export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) { + return createApprovalIosPushDelivery({ + 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({ + 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!, + }), + }, + }); +} diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 97c849bfb8f6..ea6df751ca2b 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -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(); + 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()), + 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(); + const handleExpired = vi.fn(async () => {}); + setDangerousDemoCommandRegistry([createApprovalRequestPolicy()]); + const { context } = createContext({ + pluginApprovalManager: manager, + getApprovalClientConnIds: vi.fn(() => new Set()), + 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(); const forwardPluginApprovalRequest = vi.fn(async () => false); diff --git a/src/gateway/node-invoke-plugin-policy.ts b/src/gateway/node-invoke-plugin-policy.ts index 5184f82fbfeb..8c470fc8ea73 100644 --- a/src/gateway/node-invoke-plugin-policy.ts +++ b/src/gateway/node-invoke-plugin-policy.ts @@ -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> = []; 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 diff --git a/src/gateway/server-aux-handlers.ts b/src/gateway/server-aux-handlers.ts index e4ff4064a29e..f2267e50ca9d 100644 --- a/src/gateway/server-aux-handlers.ts +++ b/src/gateway/server-aux-handlers.ts @@ -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( "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: { diff --git a/src/gateway/server-methods/approval-publication.ts b/src/gateway/server-methods/approval-publication.ts index 61fb6d0b3719..62aecf38954e 100644 --- a/src/gateway/server-methods/approval-publication.ts +++ b/src/gateway/server-methods/approval-publication.ts @@ -32,6 +32,10 @@ export type ExecApprovalIosPushDelivery = { handleResolved?: (resolved: ExecApprovalResolved) => Promise; }; +export type PluginApprovalIosPushDelivery = { + handleResolved?: (resolved: PluginApprovalResolved) => Promise; +}; + 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 { 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), + }); + } } diff --git a/src/gateway/server-methods/approval.test.ts b/src/gateway/server-methods/approval.test.ts index c8c0ceb5e738..2b14533d012e 100644 --- a/src/gateway/server-methods/approval.test.ts +++ b/src/gateway/server-methods/approval.test.ts @@ -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; const recipientOptions = recipientLookup.mock.calls[0]?.[0] as | { diff --git a/src/gateway/server-methods/approval.ts b/src/gateway/server-methods/approval.ts index c5a7086148c0..3b9d354ba4a3 100644 --- a/src/gateway/server-methods/approval.ts +++ b/src/gateway/server-methods/approval.ts @@ -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; 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)}`, diff --git a/src/gateway/server-methods/plugin-approval.test.ts b/src/gateway/server-methods/plugin-approval.test.ts index 4c80fcd8dd68..683497a348f6 100644 --- a/src/gateway/server-methods/plugin-approval.test.ts +++ b/src/gateway/server-methods/plugin-approval.test.ts @@ -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, { diff --git a/src/gateway/server-methods/plugin-approval.ts b/src/gateway/server-methods/plugin-approval.ts index 4ae1755f6996..cf81bb69021c 100644 --- a/src/gateway/server-methods/plugin-approval.ts +++ b/src/gateway/server-methods/plugin-approval.ts @@ -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; + 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 }, + 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> = []; + 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, }); }, }; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index a1b48ba2b745..f1d9e0eb0ad9 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -124,6 +124,15 @@ export type GatewayRequestContext = { pluginApprovalManager?: ExecApprovalManager; systemAgentApprovalManager?: ExecApprovalManager; forwardPluginApprovalRequest?: (request: PluginApprovalRequest) => Promise; + pluginApprovalIosPushDelivery?: { + handleRequested?: ( + request: PluginApprovalRequest, + opts?: { + isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean; + }, + ) => Promise; + handleExpired?: (request: PluginApprovalRequest) => Promise; + }; listSessionPendingApprovals?: ( sessionKey: string, client: GatewayClient | null, diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index bee2840627c0..de424d811845 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 8d5eb70d2834..ef8082ebe4fb 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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, diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index dc65c0ebbbde..620ff100f7d6 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -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", diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index b237c1929c16..51f780d3c4be 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -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; - 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 { - 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 { + 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 { + 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 { - 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 { + 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 { + 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 { + 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 { + return await sendApnsApprovalResolvedWake({ transport: params, kind: "plugin" }); +} + export { type ApnsRelayConfig, resolveApnsRelayConfigFromEnv }; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */