mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 22:41:13 +00:00
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:
committed by
GitHub
parent
83a4208aed
commit
bdcf6bdfdd
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
| {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user