feat(ios): add exec approval notification flow (#60239)

* fix(auth): hand off qr bootstrap to bounded device tokens

* feat(ios): add exec approval notification flow

* fix(gateway): harden approval notification delivery

* docs(changelog): add ios exec approval entry (#60239) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman
2026-04-05 16:33:22 +03:00
committed by GitHub
parent 98bac6a0e4
commit 28955a36e7
26 changed files with 2423 additions and 124 deletions

View File

@@ -0,0 +1,360 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listDevicePairingMock = vi.fn();
const loadApnsRegistrationMock = vi.fn();
const resolveApnsAuthConfigFromEnvMock = vi.fn();
const resolveApnsRelayConfigFromEnvMock = vi.fn();
const sendApnsExecApprovalAlertMock = vi.fn();
const sendApnsExecApprovalResolvedWakeMock = vi.fn();
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
vi.mock("../config/config.js", () => ({
loadConfig: () => ({ gateway: {} }),
}));
vi.mock("../infra/device-pairing.js", async () => {
const actual = await vi.importActual<typeof import("../infra/device-pairing.js")>(
"../infra/device-pairing.js",
);
return {
...actual,
listDevicePairing: listDevicePairingMock,
};
});
vi.mock("../infra/push-apns.js", () => ({
loadApnsRegistration: loadApnsRegistrationMock,
resolveApnsAuthConfigFromEnv: resolveApnsAuthConfigFromEnvMock,
resolveApnsRelayConfigFromEnv: resolveApnsRelayConfigFromEnvMock,
sendApnsExecApprovalAlert: sendApnsExecApprovalAlertMock,
sendApnsExecApprovalResolvedWake: sendApnsExecApprovalResolvedWakeMock,
clearApnsRegistrationIfCurrent: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(() => false),
}));
describe("createExecApprovalIosPushDelivery", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
listDevicePairingMock.mockResolvedValue({ pending: [], paired: [] });
loadApnsRegistrationMock.mockResolvedValue({
nodeId: "ios-device-1",
transport: "direct",
token: "apns-token",
topic: "ai.openclaw.ios.test",
environment: "sandbox",
updatedAtMs: 1,
});
resolveApnsAuthConfigFromEnvMock.mockResolvedValue({
ok: true,
value: { teamId: "team", keyId: "key", privateKey: "private-key" },
});
resolveApnsRelayConfigFromEnvMock.mockReturnValue({ ok: false, error: "unused" });
sendApnsExecApprovalAlertMock.mockResolvedValue({
ok: true,
status: 200,
environment: "sandbox",
topic: "ai.openclaw.ios.test",
tokenSuffix: "token",
transport: "direct",
});
sendApnsExecApprovalResolvedWakeMock.mockResolvedValue({
ok: true,
status: 200,
environment: "sandbox",
topic: "ai.openclaw.ios.test",
tokenSuffix: "token",
transport: "direct",
});
});
it("does not target iOS devices whose active operator token lacks operator.approvals", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{
deviceId: "ios-device-1",
publicKey: "pub",
platform: "iOS 18",
role: "operator",
roles: ["operator"],
approvedScopes: ["operator.approvals"],
createdAtMs: 1,
approvedAtMs: 1,
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.read"],
createdAtMs: 1,
},
},
},
],
});
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested({
id: "approval-1",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
});
expect(accepted).toBe(false);
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
});
it("targets iOS devices when the active operator token includes operator.approvals", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{
deviceId: "ios-device-1",
publicKey: "pub",
platform: "iOS 18",
role: "operator",
roles: ["operator"],
createdAtMs: 1,
approvedAtMs: 1,
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.approvals", "operator.read"],
createdAtMs: 1,
},
},
},
],
});
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested({
id: "approval-2",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
});
expect(accepted).toBe(true);
expect(loadApnsRegistrationMock).toHaveBeenCalledWith("ios-device-1");
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
});
it("does not treat iOS as a live approval route when every push fails", async () => {
const warn = vi.fn();
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{
deviceId: "ios-device-1",
publicKey: "pub",
platform: "iOS 18",
role: "operator",
roles: ["operator"],
createdAtMs: 1,
approvedAtMs: 1,
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.approvals", "operator.read"],
createdAtMs: 1,
},
},
},
],
});
sendApnsExecApprovalAlertMock.mockResolvedValue({
ok: false,
status: 410,
reason: "Unregistered",
environment: "sandbox",
topic: "ai.openclaw.ios.test",
tokenSuffix: "token",
transport: "direct",
});
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: { warn } });
const accepted = await delivery.handleRequested({
id: "approval-dead-route",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
});
expect(accepted).toBe(false);
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
"exec approvals: iOS request push failed node=ios-device-1 status=410 reason=Unregistered",
);
expect(warn).toHaveBeenCalledWith(
"exec approvals: iOS request push reached no devices approvalId=approval-dead-route attempted=1",
);
});
it("waits for request delivery to finish before sending cleanup pushes", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{
deviceId: "ios-device-1",
publicKey: "pub",
platform: "iOS 18",
role: "operator",
roles: ["operator"],
createdAtMs: 1,
approvedAtMs: 1,
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.approvals", "operator.read"],
createdAtMs: 1,
},
},
},
],
});
const requestedPush = createDeferred<{
ok: boolean;
status: number;
environment: string;
topic: string;
tokenSuffix: string;
transport: string;
}>();
sendApnsExecApprovalAlertMock.mockReturnValue(requestedPush.promise);
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: {} });
const requested = delivery.handleRequested({
id: "approval-ordered-cleanup",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
});
const resolved = delivery.handleResolved({
id: "approval-ordered-cleanup",
decision: "allow-once",
ts: 1,
});
await Promise.resolve();
expect(sendApnsExecApprovalResolvedWakeMock).not.toHaveBeenCalled();
requestedPush.resolve({
ok: true,
status: 200,
environment: "sandbox",
topic: "ai.openclaw.ios.test",
tokenSuffix: "token",
transport: "direct",
});
await requested;
await resolved;
expect(sendApnsExecApprovalResolvedWakeMock).toHaveBeenCalledTimes(1);
});
it("skips cleanup pushes when the original request target set is unknown", async () => {
const debug = vi.fn();
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: { debug } });
await delivery.handleResolved({
id: "approval-missing-targets",
decision: "allow-once",
ts: 1,
});
expect(debug).toHaveBeenCalledWith(
"exec approvals: iOS cleanup push skipped approvalId=approval-missing-targets reason=missing-targets",
);
expect(listDevicePairingMock).not.toHaveBeenCalled();
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
expect(sendApnsExecApprovalResolvedWakeMock).not.toHaveBeenCalled();
});
it("sends cleanup pushes only to the original request targets", async () => {
listDevicePairingMock.mockResolvedValue({
pending: [],
paired: [
{
deviceId: "ios-device-1",
publicKey: "pub",
platform: "iOS 18",
role: "operator",
roles: ["operator"],
createdAtMs: 1,
approvedAtMs: 1,
tokens: {
operator: {
token: "operator-token",
role: "operator",
scopes: ["operator.approvals", "operator.read"],
createdAtMs: 1,
},
},
},
],
});
const { createExecApprovalIosPushDelivery } = await import("./exec-approval-ios-push.js");
const delivery = createExecApprovalIosPushDelivery({ log: {} });
await delivery.handleRequested({
id: "approval-cleanup",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
});
vi.clearAllMocks();
loadApnsRegistrationMock.mockResolvedValue({
nodeId: "ios-device-1",
transport: "direct",
token: "apns-token",
topic: "ai.openclaw.ios.test",
environment: "sandbox",
updatedAtMs: 1,
});
resolveApnsAuthConfigFromEnvMock.mockResolvedValue({
ok: true,
value: { teamId: "team", keyId: "key", privateKey: "private-key" },
});
await delivery.handleResolved({
id: "approval-cleanup",
decision: "allow-once",
ts: 1,
});
expect(listDevicePairingMock).not.toHaveBeenCalled();
expect(loadApnsRegistrationMock).toHaveBeenCalledWith("ios-device-1");
expect(sendApnsExecApprovalResolvedWakeMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,367 @@
import { loadConfig } from "../config/config.js";
import {
hasEffectivePairedDeviceRole,
listDevicePairing,
type DeviceAuthToken,
type PairedDevice,
} from "../infra/device-pairing.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js";
import {
clearApnsRegistrationIfCurrent,
loadApnsRegistration,
resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
sendApnsExecApprovalAlert,
sendApnsExecApprovalResolvedWake,
shouldClearStoredApnsRegistration,
type ApnsAuthConfig,
type ApnsRegistration,
type ApnsRelayConfig,
} from "../infra/push-apns.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
const APPROVALS_SCOPE = "operator.approvals";
const OPERATOR_ROLE = "operator";
type GatewayLikeLogger = {
debug?: (message: string) => void;
warn?: (message: string) => void;
error?: (message: string) => void;
};
type DeliveryTarget = {
nodeId: string;
registration: ApnsRegistration;
};
type DeliveryPlan = {
targets: DeliveryTarget[];
directAuth?: ApnsAuthConfig;
relayConfig?: ApnsRelayConfig;
};
type ApprovalDeliveryState = {
nodeIds: string[];
requestPushPromise: Promise<{ attempted: number; delivered: number }>;
};
function isIosPlatform(platform: string | undefined): boolean {
const normalized = platform?.trim().toLowerCase() ?? "";
return normalized.startsWith("ios") || normalized.startsWith("ipados");
}
function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | null {
const operatorToken = device.tokens?.[OPERATOR_ROLE];
if (!operatorToken || operatorToken.revokedAtMs) {
return null;
}
return operatorToken;
}
function canApproveExecRequests(device: PairedDevice): boolean {
const operatorToken = resolveActiveOperatorToken(device);
if (!operatorToken) {
return false;
}
return roleScopesAllow({
role: OPERATOR_ROLE,
requestedScopes: [APPROVALS_SCOPE],
allowedScopes: operatorToken.scopes,
});
}
function shouldTargetDevice(params: {
device: PairedDevice;
requireApprovalScope: boolean;
}): boolean {
if (!isIosPlatform(params.device.platform)) {
return false;
}
if (!hasEffectivePairedDeviceRole(params.device, OPERATOR_ROLE)) {
return false;
}
if (!params.requireApprovalScope) {
return true;
}
return canApproveExecRequests(params.device);
}
async function loadRegisteredTargets(params: {
deviceIds: readonly string[];
}): Promise<DeliveryTarget[]> {
const targets = await Promise.all(
params.deviceIds.map(async (nodeId) => {
const registration = await loadApnsRegistration(nodeId);
return registration ? { nodeId, registration } : null;
}),
);
return targets.filter((target): target is DeliveryTarget => target !== null);
}
async function resolvePairedTargets(params: {
requireApprovalScope: boolean;
}): Promise<DeliveryTarget[]> {
const pairing = await listDevicePairing();
const deviceIds = pairing.paired
.filter((device) =>
shouldTargetDevice({ device, requireApprovalScope: params.requireApprovalScope }),
)
.map((device) => device.deviceId);
return await loadRegisteredTargets({ deviceIds });
}
async function resolveDeliveryPlan(params: {
requireApprovalScope: boolean;
explicitNodeIds?: readonly string[];
log: GatewayLikeLogger;
}): Promise<DeliveryPlan> {
const targets = params.explicitNodeIds?.length
? await loadRegisteredTargets({ deviceIds: params.explicitNodeIds })
: await resolvePairedTargets({ requireApprovalScope: params.requireApprovalScope });
if (targets.length === 0) {
return { targets: [] };
}
const needsDirect = targets.some((target) => target.registration.transport === "direct");
const needsRelay = targets.some((target) => target.registration.transport === "relay");
let directAuth: ApnsAuthConfig | undefined;
if (needsDirect) {
const auth = await resolveApnsAuthConfigFromEnv(process.env);
if (auth.ok) {
directAuth = auth.value;
} else {
params.log.warn?.(`exec approvals: iOS direct APNs auth unavailable: ${auth.error}`);
}
}
let relayConfig: ApnsRelayConfig | undefined;
if (needsRelay) {
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
if (relay.ok) {
relayConfig = relay.value;
} else {
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
}
}
return {
targets: targets.filter((target) =>
target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig),
),
directAuth,
relayConfig,
};
}
async function clearStaleApnsRegistrationIfNeeded(params: {
nodeId: string;
registration: ApnsRegistration;
result: { status: number; reason?: string };
}): Promise<void> {
if (
shouldClearStoredApnsRegistration({
registration: params.registration,
result: params.result,
})
) {
await clearApnsRegistrationIfCurrent({
nodeId: params.nodeId,
registration: params.registration,
});
}
}
async function sendRequestedPushes(params: {
request: ExecApprovalRequest;
plan: DeliveryPlan;
log: GatewayLikeLogger;
}): Promise<{ attempted: number; delivered: number }> {
const results = await Promise.allSettled(
params.plan.targets.map(async (target) => {
const result =
target.registration.transport === "direct"
? await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: params.request.id,
auth: params.plan.directAuth!,
})
: await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId: params.request.id,
relayConfig: params.plan.relayConfig!,
});
await clearStaleApnsRegistrationIfNeeded({
nodeId: target.nodeId,
registration: target.registration,
result,
});
if (!result.ok) {
params.log.warn?.(
`exec approvals: iOS request push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
);
}
return { nodeId: target.nodeId, ok: result.ok };
}),
);
for (const result of results) {
if (result.status === "rejected") {
const message =
result.reason instanceof Error ? result.reason.message : String(result.reason);
params.log.warn?.(`exec approvals: iOS request push threw error: ${message}`);
}
}
return {
attempted: params.plan.targets.length,
delivered: results.filter((result) => result.status === "fulfilled" && result.value.ok).length,
};
}
async function sendResolvedPushes(params: {
approvalId: string;
plan: DeliveryPlan;
log: GatewayLikeLogger;
}): Promise<void> {
await Promise.allSettled(
params.plan.targets.map(async (target) => {
const result =
target.registration.transport === "direct"
? await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId: params.approvalId,
auth: params.plan.directAuth!,
})
: await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId: params.approvalId,
relayConfig: params.plan.relayConfig!,
});
await clearStaleApnsRegistrationIfNeeded({
nodeId: target.nodeId,
registration: target.registration,
result,
});
if (!result.ok) {
params.log.warn?.(
`exec approvals: iOS cleanup push failed node=${target.nodeId} status=${result.status} reason=${result.reason ?? "unknown"}`,
);
}
}),
);
}
export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogger }) {
const approvalDeliveriesById = new Map<string, ApprovalDeliveryState>();
const pendingDeliveryStateById = new Map<string, Promise<ApprovalDeliveryState | null>>();
return {
async handleRequested(request: ExecApprovalRequest): Promise<boolean> {
const deliveryStatePromise = (async (): Promise<ApprovalDeliveryState | null> => {
const plan = await resolveDeliveryPlan({
requireApprovalScope: true,
log: params.log,
});
if (plan.targets.length === 0) {
approvalDeliveriesById.delete(request.id);
return null;
}
const deliveryState: ApprovalDeliveryState = {
nodeIds: plan.targets.map((target) => target.nodeId),
requestPushPromise: sendRequestedPushes({ request, plan, log: params.log }).catch(
(err) => {
const message = err instanceof Error ? err.message : String(err);
params.log.error?.(`exec approvals: iOS request push failed: ${message}`);
return { attempted: plan.targets.length, delivered: 0 };
},
),
};
approvalDeliveriesById.set(request.id, deliveryState);
return deliveryState;
})();
pendingDeliveryStateById.set(request.id, deliveryStatePromise);
const deliveryState = await deliveryStatePromise;
if (pendingDeliveryStateById.get(request.id) === deliveryStatePromise) {
pendingDeliveryStateById.delete(request.id);
}
if (!deliveryState) {
return false;
}
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}`,
);
if (
approvalDeliveriesById.get(request.id)?.requestPushPromise ===
deliveryState.requestPushPromise
) {
approvalDeliveriesById.delete(request.id);
}
return false;
}
return true;
},
async handleResolved(resolved: ExecApprovalResolved): Promise<void> {
const deliveryState =
approvalDeliveriesById.get(resolved.id) ??
(await pendingDeliveryStateById.get(resolved.id));
approvalDeliveriesById.delete(resolved.id);
pendingDeliveryStateById.delete(resolved.id);
if (!deliveryState?.nodeIds.length) {
params.log.debug?.(
`exec approvals: iOS cleanup push skipped approvalId=${resolved.id} reason=missing-targets`,
);
return;
}
await deliveryState.requestPushPromise;
const plan = await resolveDeliveryPlan({
requireApprovalScope: false,
explicitNodeIds: deliveryState.nodeIds,
log: params.log,
});
if (plan.targets.length === 0) {
return;
}
await sendResolvedPushes({
approvalId: resolved.id,
plan,
log: params.log,
});
},
async handleExpired(request: ExecApprovalRequest): Promise<void> {
const deliveryState =
approvalDeliveriesById.get(request.id) ?? (await pendingDeliveryStateById.get(request.id));
approvalDeliveriesById.delete(request.id);
pendingDeliveryStateById.delete(request.id);
if (!deliveryState?.nodeIds.length) {
params.log.debug?.(
`exec approvals: iOS cleanup push skipped approvalId=${request.id} reason=missing-targets`,
);
return;
}
await deliveryState.requestPushPromise;
const plan = await resolveDeliveryPlan({
requireApprovalScope: false,
explicitNodeIds: deliveryState.nodeIds,
log: params.log,
});
if (plan.targets.length === 0) {
return;
}
await sendResolvedPushes({
approvalId: request.id,
plan,
log: params.log,
});
},
};
}

View File

@@ -97,12 +97,18 @@ describe("operator scope authorization", () => {
});
});
it("requires approvals scope for approval methods", () => {
expect(authorizeOperatorScopesForMethod("exec.approval.resolve", ["operator.write"])).toEqual({
allowed: false,
missingScope: "operator.approvals",
});
});
it.each(["exec.approval.get", "exec.approval.resolve"])(
"requires approvals scope for %s",
(method) => {
expect(authorizeOperatorScopesForMethod(method, ["operator.write"])).toEqual({
allowed: false,
missingScope: "operator.approvals",
});
expect(authorizeOperatorScopesForMethod(method, ["operator.approvals"])).toEqual({
allowed: true,
});
},
);
it.each(["plugin.approval.request", "plugin.approval.waitDecision", "plugin.approval.resolve"])(
"requires approvals scope for %s",

View File

@@ -41,6 +41,7 @@ const NODE_ROLE_METHODS = new Set([
const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
[APPROVALS_SCOPE]: [
"exec.approval.get",
"exec.approval.request",
"exec.approval.waitDecision",
"exec.approval.resolve",

View File

@@ -120,6 +120,8 @@ import {
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
type ExecApprovalGetParams,
ExecApprovalGetParamsSchema,
type ExecApprovalRequestParams,
ExecApprovalRequestParamsSchema,
type ExecApprovalResolveParams,
@@ -445,6 +447,9 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema,
);
export const validateExecApprovalGetParams = ajv.compile<ExecApprovalGetParams>(
ExecApprovalGetParamsSchema,
);
export const validateExecApprovalRequestParams = ajv.compile<ExecApprovalRequestParams>(
ExecApprovalRequestParamsSchema,
);
@@ -615,6 +620,11 @@ export {
CronRunsParamsSchema,
LogsTailParamsSchema,
LogsTailResultSchema,
ExecApprovalsGetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalGetParamsSchema,
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
ChatInjectParamsSchema,
@@ -736,6 +746,9 @@ export type {
ExecApprovalsGetParams,
ExecApprovalsSetParams,
ExecApprovalsSnapshot,
ExecApprovalGetParams,
ExecApprovalRequestParams,
ExecApprovalResolveParams,
LogsTailParams,
LogsTailResult,
PollParams,

View File

@@ -86,6 +86,13 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ExecApprovalGetParamsSchema = Type.Object(
{
id: NonEmptyString,
},
{ additionalProperties: false },
);
export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),

View File

@@ -98,6 +98,7 @@ import {
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
ExecApprovalGetParamsSchema,
ExecApprovalRequestParamsSchema,
ExecApprovalResolveParamsSchema,
} from "./exec-approvals.js";
@@ -312,6 +313,7 @@ export const ProtocolSchemas = {
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ExecApprovalGetParams: ExecApprovalGetParamsSchema,
ExecApprovalRequestParams: ExecApprovalRequestParamsSchema,
ExecApprovalResolveParams: ExecApprovalResolveParamsSchema,
PluginApprovalRequestParams: PluginApprovalRequestParamsSchema,

View File

@@ -130,6 +130,7 @@ export type ExecApprovalsSetParams = SchemaType<"ExecApprovalsSetParams">;
export type ExecApprovalsNodeGetParams = SchemaType<"ExecApprovalsNodeGetParams">;
export type ExecApprovalsNodeSetParams = SchemaType<"ExecApprovalsNodeSetParams">;
export type ExecApprovalsSnapshot = SchemaType<"ExecApprovalsSnapshot">;
export type ExecApprovalGetParams = SchemaType<"ExecApprovalGetParams">;
export type ExecApprovalRequestParams = SchemaType<"ExecApprovalRequestParams">;
export type ExecApprovalResolveParams = SchemaType<"ExecApprovalResolveParams">;
export type PluginApprovalRequestParams = SchemaType<"PluginApprovalRequestParams">;

View File

@@ -26,6 +26,7 @@ const BASE_METHODS = [
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"exec.approval.get",
"exec.approval.request",
"exec.approval.waitDecision",
"exec.approval.resolve",

View File

@@ -1,11 +1,16 @@
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
import { sanitizeExecApprovalDisplayText } from "../../infra/exec-approval-command-display.js";
import {
resolveExecApprovalCommandDisplay,
sanitizeExecApprovalDisplayText,
} from "../../infra/exec-approval-command-display.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalRequestAllowedDecisions,
type ExecApprovalDecision,
type ExecApprovalRequest,
type ExecApprovalResolved,
} from "../../infra/exec-approvals.js";
import {
buildSystemRunApprovalBinding,
@@ -17,6 +22,7 @@ import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateExecApprovalGetParams,
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
} from "../protocol/index.js";
@@ -26,11 +32,90 @@ const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
} as const;
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
} as const;
type ExecApprovalIosPushDelivery = {
handleRequested?: (request: ExecApprovalRequest) => Promise<boolean>;
handleResolved?: (resolved: ExecApprovalResolved) => Promise<void>;
handleExpired?: (request: ExecApprovalRequest) => Promise<void>;
};
function resolvePendingApprovalRecord(manager: ExecApprovalManager, inputId: string) {
const resolvedId = manager.lookupPendingId(inputId);
if (resolvedId.kind === "none") {
return { ok: false as const, response: "missing" as const };
}
if (resolvedId.kind === "ambiguous") {
return {
ok: false as const,
response: {
code: ErrorCodes.INVALID_REQUEST,
message: "ambiguous approval id prefix; use the full id",
},
};
}
const snapshot = manager.getSnapshot(resolvedId.id);
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
return { ok: false as const, response: "missing" as const };
}
return { ok: true as const, approvalId: resolvedId.id, snapshot };
}
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery },
): GatewayRequestHandlers {
return {
"exec.approval.get": async ({ params, respond }) => {
if (!validateExecApprovalGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.get params: ${formatValidationErrors(
validateExecApprovalGetParams.errors,
)}`,
),
);
return;
}
const p = params as { id: string };
const resolved = resolvePendingApprovalRecord(manager, p.id);
if (!resolved.ok) {
if (resolved.response === "missing") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
return;
}
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(
resolved.snapshot.request,
);
respond(
true,
{
id: resolved.approvalId,
commandText,
commandPreview,
allowedDecisions: resolveExecApprovalRequestAllowedDecisions(resolved.snapshot.request),
host: resolved.snapshot.request.host ?? null,
nodeId: resolved.snapshot.request.nodeId ?? null,
agentId: resolved.snapshot.request.agentId ?? null,
expiresAtMs: resolved.snapshot.expiresAtMs,
},
undefined,
);
},
"exec.approval.request": async ({ params, respond, context, client }) => {
if (!validateExecApprovalRequestParams(params)) {
respond(
@@ -181,16 +266,13 @@ export function createExecApprovalHandlers(
);
return;
}
context.broadcast(
"exec.approval.requested",
{
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
{ dropIfSlow: true },
);
const requestEvent: ExecApprovalRequest = {
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
};
context.broadcast("exec.approval.requested", requestEvent, { dropIfSlow: true });
const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
turnSourceChannel: record.request.turnSourceChannel,
@@ -199,18 +281,21 @@ export function createExecApprovalHandlers(
let forwarded = false;
if (opts?.forwarder) {
try {
forwarded = await opts.forwarder.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
});
forwarded = await opts.forwarder.handleRequested(requestEvent);
} catch (err) {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
}
}
let deliveredToIosPush = false;
if (opts?.iosPushDelivery?.handleRequested) {
try {
deliveredToIosPush = await opts.iosPushDelivery.handleRequested(requestEvent);
} catch (err) {
context.logGateway?.error?.(`exec approvals: iOS push request failed: ${String(err)}`);
}
}
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute) {
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute && !deliveredToIosPush) {
manager.expire(record.id, "no-approval-route");
respond(
true,
@@ -241,6 +326,11 @@ export function createExecApprovalHandlers(
}
const decision = await decisionPromise;
if (decision === null) {
void opts?.iosPushDelivery?.handleExpired?.(requestEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: iOS push expire failed: ${String(err)}`);
});
}
// Send final response with decision for callers using expectFinal:true.
respond(
true,
@@ -304,32 +394,23 @@ export function createExecApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const resolvedId = manager.lookupPendingId(p.id);
if (resolvedId.kind === "none") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
const resolved = resolvePendingApprovalRecord(manager, p.id);
if (!resolved.ok) {
if (resolved.response === "missing") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
respond(false, undefined, errorShape(resolved.response.code, resolved.response.message));
return;
}
if (resolvedId.kind === "ambiguous") {
const candidates = resolvedId.ids.slice(0, 3).join(", ");
const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : "";
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`,
),
);
return;
}
const approvalId = resolvedId.id;
const snapshot = manager.getSnapshot(approvalId);
const approvalId = resolved.approvalId;
const snapshot = resolved.snapshot;
const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot?.request);
if (snapshot && !allowedDecisions.includes(decision)) {
respond(
@@ -338,6 +419,9 @@ export function createExecApprovalHandlers(
errorShape(
ErrorCodes.INVALID_REQUEST,
"allow-always is unavailable because the effective policy requires approval every time",
{
details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS,
},
),
);
return;
@@ -354,22 +438,20 @@ export function createExecApprovalHandlers(
);
return;
}
context.broadcast(
"exec.approval.resolved",
{ id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({
id: approvalId,
decision,
resolvedBy,
ts: Date.now(),
request: snapshot?.request,
})
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
const resolvedEvent: ExecApprovalResolved = {
id: approvalId,
decision,
resolvedBy,
ts: Date.now(),
request: snapshot?.request,
};
context.broadcast("exec.approval.resolved", resolvedEvent, { dropIfSlow: true });
void opts?.forwarder?.handleResolved(resolvedEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
void opts?.iosPushDelivery?.handleResolved?.(resolvedEvent).catch((err) => {
context.logGateway?.error?.(`exec approvals: iOS push resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
},
};

View File

@@ -331,6 +331,7 @@ describe("gateway chat transcript writes (guardrail)", () => {
describe("exec approval handlers", () => {
const execApprovalNoop = () => false;
type ExecApprovalHandlers = ReturnType<typeof createExecApprovalHandlers>;
type ExecApprovalGetArgs = Parameters<ExecApprovalHandlers["exec.approval.get"]>[0];
type ExecApprovalRequestArgs = Parameters<ExecApprovalHandlers["exec.approval.request"]>[0];
type ExecApprovalResolveArgs = Parameters<ExecApprovalHandlers["exec.approval.resolve"]>[0];
@@ -363,6 +364,21 @@ describe("exec approval handlers", () => {
return context as unknown as ExecApprovalResolveArgs["context"];
}
async function getExecApproval(params: {
handlers: ExecApprovalHandlers;
id: string;
respond: ReturnType<typeof vi.fn>;
}) {
return params.handlers["exec.approval.get"]({
params: { id: params.id } as ExecApprovalGetArgs["params"],
respond: params.respond as unknown as ExecApprovalGetArgs["respond"],
context: {} as ExecApprovalGetArgs["context"],
client: null,
req: { id: "req-get", type: "req", method: "exec.approval.get" },
isWebchatConnect: execApprovalNoop,
});
}
async function requestExecApproval(params: {
handlers: ExecApprovalHandlers;
respond: ReturnType<typeof vi.fn>;
@@ -451,20 +467,36 @@ describe("exec approval handlers", () => {
return { handlers, broadcasts, respond, context };
}
function createForwardingExecApprovalFixture() {
function createForwardingExecApprovalFixture(opts?: {
iosPushDelivery?: {
handleRequested: ReturnType<typeof vi.fn>;
handleResolved: ReturnType<typeof vi.fn>;
handleExpired: ReturnType<typeof vi.fn>;
};
}) {
const manager = new ExecApprovalManager();
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const handlers = createExecApprovalHandlers(manager, {
forwarder,
iosPushDelivery: opts?.iosPushDelivery as never,
});
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
return { manager, handlers, forwarder, respond, context };
return {
manager,
handlers,
forwarder,
iosPushDelivery: opts?.iosPushDelivery,
respond,
context,
};
}
async function drainApprovalRequestTicks() {
@@ -530,6 +562,86 @@ describe("exec approval handlers", () => {
);
});
it("returns pending approval details for exec.approval.get", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: {
twoPhase: true,
host: "gateway",
command: "echo ok",
commandArgv: ["echo", "ok"],
systemRunPlan: undefined,
nodeId: undefined,
},
});
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
const id = (requested?.payload as { id?: string })?.id ?? "";
expect(id).not.toBe("");
const getRespond = vi.fn();
await getExecApproval({ handlers, id, respond: getRespond });
expect(getRespond).toHaveBeenCalledWith(
true,
expect.objectContaining({
id,
commandText: "echo ok",
allowedDecisions: expect.arrayContaining(["allow-once", "allow-always", "deny"]),
host: "gateway",
nodeId: null,
agentId: null,
}),
undefined,
);
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id,
respond: resolveRespond,
context,
});
await requestPromise;
});
it("returns not found for stale exec.approval.get ids", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { twoPhase: true, host: "gateway", systemRunPlan: undefined, nodeId: undefined },
});
const acceptedId = respond.mock.calls.find((call) => call[1]?.status === "accepted")?.[1]?.id;
expect(typeof acceptedId).toBe("string");
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id: acceptedId as string,
respond: resolveRespond,
context,
});
await requestPromise;
const getRespond = vi.fn();
await getExecApproval({ handlers, id: acceptedId as string, respond: getRespond });
expect(getRespond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: "unknown or expired approval id",
}),
);
});
it("broadcasts request + resolve", async () => {
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
@@ -901,7 +1013,7 @@ describe("exec approval handlers", () => {
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
});
it("rejects ambiguous short approval id prefixes", async () => {
it("rejects ambiguous short approval id prefixes without leaking candidate ids", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const respond = vi.fn();
@@ -929,7 +1041,7 @@ describe("exec approval handlers", () => {
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("ambiguous approval id prefix"),
message: "ambiguous approval id prefix; use the full id",
}),
);
});
@@ -1067,6 +1179,116 @@ describe("exec approval handlers", () => {
);
});
it("keeps approvals pending when iOS push delivery accepted the request", async () => {
const iosPushDelivery = {
handleRequested: vi.fn(async () => true),
handleResolved: vi.fn(async () => {}),
handleExpired: vi.fn(async () => {}),
};
const { manager, handlers, forwarder, respond, context } = createForwardingExecApprovalFixture({
iosPushDelivery,
});
const expireSpy = vi.spyOn(manager, "expire");
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: {
twoPhase: true,
timeoutMs: 60_000,
id: "approval-ios-push",
host: "gateway",
},
});
await vi.waitFor(() => {
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ status: "accepted", id: "approval-ios-push" }),
undefined,
);
});
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(iosPushDelivery.handleRequested).toHaveBeenCalledWith(
expect.objectContaining({ id: "approval-ios-push" }),
);
expect(expireSpy).not.toHaveBeenCalled();
manager.resolve("approval-ios-push", "allow-once");
await requestPromise;
});
it("sends iOS cleanup delivery on resolve", async () => {
const iosPushDelivery = {
handleRequested: vi.fn(async () => true),
handleResolved: vi.fn(async () => {}),
handleExpired: vi.fn(async () => {}),
};
const { handlers, respond, context } = createForwardingExecApprovalFixture({ iosPushDelivery });
const resolveRespond = vi.fn();
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: { timeoutMs: 60_000, id: "approval-ios-cleanup", host: "gateway" },
});
await drainApprovalRequestTicks();
await resolveExecApproval({
handlers,
id: "approval-ios-cleanup",
respond: resolveRespond,
context,
});
await requestPromise;
await vi.waitFor(() => {
expect(iosPushDelivery.handleResolved).toHaveBeenCalledWith(
expect.objectContaining({ id: "approval-ios-cleanup", decision: "allow-once" }),
);
});
});
it("sends iOS cleanup delivery on expiration", async () => {
vi.useFakeTimers();
try {
const iosPushDelivery = {
handleRequested: vi.fn(async () => true),
handleResolved: vi.fn(async () => {}),
handleExpired: vi.fn(async () => {}),
};
const { handlers, respond, context } = createForwardingExecApprovalFixture({
iosPushDelivery,
});
const requestPromise = requestExecApproval({
handlers,
respond,
context,
params: {
twoPhase: true,
timeoutMs: 250,
id: "approval-ios-expire",
host: "gateway",
},
});
await drainApprovalRequestTicks();
await vi.advanceTimersByTimeAsync(250);
await requestPromise;
await vi.waitFor(() => {
expect(iosPushDelivery.handleExpired).toHaveBeenCalledWith(
expect.objectContaining({ id: "approval-ios-expire" }),
);
});
} finally {
vi.useRealTimers();
}
});
it("keeps approvals pending when the originating chat can handle /approve directly", async () => {
vi.useFakeTimers();
try {

View File

@@ -821,6 +821,33 @@ export function registerControlUiAndPairingSuite(): void {
wsBootstrap.close();
});
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const replay = await connectReq(wsReplay, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(replay.ok).toBe(false);
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
);
wsReplay.close();
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const reconnect = await connectReq(wsReconnect, {
skipDefaultAuth: true,
deviceToken: issuedDeviceToken,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(reconnect.ok).toBe(true);
wsReconnect.close();
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
@@ -839,6 +866,19 @@ export function registerControlUiAndPairingSuite(): void {
scopes: [],
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedOperatorToken,
role: "operator",
scopes: [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
}),
).resolves.toEqual({ ok: true });
} finally {
await server.close();
restoreGatewayToken(prevToken);

View File

@@ -89,6 +89,7 @@ import {
GATEWAY_EVENT_UPDATE_AVAILABLE,
type GatewayUpdateAvailableEventPayload,
} from "./events.js";
import { createExecApprovalIosPushDelivery } from "./exec-approval-ios-push.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { startMcpLoopbackServer } from "./mcp-http.js";
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
@@ -1203,8 +1204,10 @@ export async function startGatewayServer(
const execApprovalManager = new ExecApprovalManager();
const execApprovalForwarder = createExecApprovalForwarder();
const execApprovalIosPushDelivery = createExecApprovalIosPushDelivery({ log });
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
forwarder: execApprovalForwarder,
iosPushDelivery: execApprovalIosPushDelivery,
});
const pluginApprovalManager = new ExecApprovalManager<
import("../infra/plugin-approvals.js").PluginApprovalRequestPayload

View File

@@ -4,8 +4,9 @@ import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import {
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile,
redeemDeviceBootstrapTokenProfile,
revokeDeviceBootstrapToken,
restoreDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
import {
@@ -718,8 +719,12 @@ export function attachGatewayWsMessageHandler(params: {
rejectUnauthorized(authResult);
return;
}
let bootstrapProfile: DeviceBootstrapProfile | null = null;
let shouldConsumeBootstrapTokenAfterHello = false;
const issuedBootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
let boundBootstrapProfile: DeviceBootstrapProfile | null = null;
let handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
@@ -826,7 +831,7 @@ export function attachGatewayWsMessageHandler(params: {
});
};
if (
bootstrapProfile === null &&
boundBootstrapProfile === null &&
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
@@ -834,7 +839,7 @@ export function attachGatewayWsMessageHandler(params: {
!existingPairedDevice &&
bootstrapTokenCandidate
) {
bootstrapProfile = await getBoundDeviceBootstrapProfile({
boundBootstrapProfile = await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
@@ -847,17 +852,18 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// QR bootstrap onboarding stays single-use, but only consume the bootstrap token
// after the hello-ok path succeeds so reconnects can recover from pre-hello failures.
// QR bootstrap onboarding stays single-use, but the first node bootstrap handshake
// should seed bounded device tokens and only consume the bootstrap token once the
// hello-ok path succeeds so reconnects can recover from pre-hello failures.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
bootstrapProfile !== null;
boundBootstrapProfile !== null;
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
? bootstrapProfile
? boundBootstrapProfile
: null;
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
@@ -900,8 +906,8 @@ export function attachGatewayWsMessageHandler(params: {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing) {
shouldConsumeBootstrapTokenAfterHello = true;
if (bootstrapProfileForSilentApproval) {
handoffBootstrapProfile = bootstrapProfileForSilentApproval;
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
@@ -1072,8 +1078,8 @@ export function attachGatewayWsMessageHandler(params: {
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
});
}
if (device && bootstrapProfile !== null) {
const bootstrapProfileForHello = bootstrapProfile as DeviceBootstrapProfile;
if (device && handoffBootstrapProfile) {
const bootstrapProfileForHello = handoffBootstrapProfile as DeviceBootstrapProfile;
for (const bootstrapRole of bootstrapProfileForHello.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
continue;
@@ -1269,44 +1275,47 @@ export function attachGatewayWsMessageHandler(params: {
);
}
let consumedBootstrapTokenRecord:
| Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
| undefined;
if (shouldConsumeBootstrapTokenAfterHello && bootstrapTokenCandidate && device) {
try {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
consumedBootstrapTokenRecord = revoked.record;
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after bootstrap handoff device=${device.id}`,
);
}
} catch (err) {
logGateway.warn(
`bootstrap token consume failed after device-token handoff device=${device.id}: ${formatForLog(err)}`,
);
}
}
try {
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk });
} catch (err) {
if (consumedBootstrapTokenRecord) {
try {
await restoreDeviceBootstrapToken({
record: consumedBootstrapTokenRecord,
});
} catch (restoreErr) {
logGateway.warn(
`bootstrap token restore failed after hello send error device=${device?.id ?? "unknown"}: ${formatForLog(restoreErr)}`,
);
}
}
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) {
try {
if (handoffBootstrapProfile) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after device-token handoff device=${device.id}`,
);
}
} else if (issuedBootstrapProfile) {
const redemption = await redeemDeviceBootstrapTokenProfile({
token: bootstrapTokenCandidate,
role,
scopes,
});
if (redemption.fullyRedeemed) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
);
}
}
}
} catch (err) {
logGateway.warn(
`bootstrap token post-connect bookkeeping failed device=${device.id}: ${formatForLog(err)}`,
);
}
}
logWs("out", "hello-ok", {
connId,
methods: gatewayMethods.length,

View File

@@ -1,6 +1,11 @@
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sendApnsAlert, sendApnsBackgroundWake } from "./push-apns.js";
import {
sendApnsAlert,
sendApnsBackgroundWake,
sendApnsExecApprovalAlert,
sendApnsExecApprovalResolvedWake,
} from "./push-apns.js";
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" })
@@ -153,6 +158,93 @@ describe("push APNs send semantics", () => {
expect(result.transport).toBe("direct");
});
it("sends exec approval alert pushes with generic modal-only metadata", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-approval-alert",
environment: "sandbox",
sendResult: {
status: 200,
apnsId: "apns-approval-alert-id",
body: "",
},
});
const result = await sendApnsExecApprovalAlert({
registration,
nodeId: "ios-node-approval-alert",
approvalId: "approval-123",
auth,
requestSender: send,
});
expect(send).toHaveBeenCalledTimes(1);
const sent = send.mock.calls[0]?.[0];
expect(sent?.pushType).toBe("alert");
expect(sent?.payload).toMatchObject({
aps: {
alert: {
title: "Exec approval required",
body: "Open OpenClaw to review this request.",
},
sound: "default",
},
openclaw: {
kind: "exec.approval.requested",
approvalId: "approval-123",
},
});
expect(sent?.payload).not.toMatchObject({
aps: {
category: expect.anything(),
},
openclaw: {
host: expect.anything(),
nodeId: expect.anything(),
agentId: expect.anything(),
commandText: expect.anything(),
allowedDecisions: expect.anything(),
expiresAtMs: expect.anything(),
},
});
expect(result.ok).toBe(true);
expect(result.transport).toBe("direct");
});
it("sends exec approval cleanup pushes as silent background notifications", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-approval-cleanup",
environment: "sandbox",
sendResult: {
status: 200,
apnsId: "apns-approval-cleanup-id",
body: "",
},
});
const result = await sendApnsExecApprovalResolvedWake({
registration,
nodeId: "ios-node-approval-cleanup",
approvalId: "approval-123",
auth,
requestSender: send,
});
expect(send).toHaveBeenCalledTimes(1);
const sent = send.mock.calls[0]?.[0];
expect(sent?.pushType).toBe("background");
expect(sent?.payload).toMatchObject({
aps: {
"content-available": 1,
},
openclaw: {
kind: "exec.approval.resolved",
approvalId: "approval-123",
},
});
expect(result.ok).toBe(true);
expect(result.transport).toBe("direct");
});
it("parses direct send failures and clamps sub-second timeouts", async () => {
const { send, registration, auth } = createDirectApnsSendFixture({
nodeId: "ios-node-direct-fail",
@@ -335,4 +427,57 @@ describe("push APNs send semantics", () => {
transport: "relay",
});
});
it("sends relay exec approval alerts with generic modal-only metadata", async () => {
const { send, registration, relayConfig, gatewayIdentity } = createRelayApnsSendFixture({
nodeId: "ios-node-relay-approval-alert",
sendResult: {
ok: true,
status: 202,
apnsId: "relay-approval-alert-id",
environment: "production",
},
});
const result = await sendApnsExecApprovalAlert({
registration,
nodeId: "ios-node-relay-approval-alert",
approvalId: "approval-relay-1",
relayConfig,
relayGatewayIdentity: gatewayIdentity,
relayRequestSender: send,
});
const sent = send.mock.calls[0]?.[0];
expect(sent?.payload).toMatchObject({
aps: {
alert: {
title: "Exec approval required",
body: "Open OpenClaw to review this request.",
},
},
openclaw: {
kind: "exec.approval.requested",
approvalId: "approval-relay-1",
},
});
expect(sent?.payload).not.toMatchObject({
aps: {
category: expect.anything(),
},
openclaw: {
commandText: expect.anything(),
host: expect.anything(),
nodeId: expect.anything(),
allowedDecisions: expect.anything(),
expiresAtMs: expect.anything(),
},
});
expect(result).toMatchObject({
ok: true,
status: 202,
environment: "production",
transport: "relay",
});
});
});

View File

@@ -65,6 +65,8 @@ export type ApnsPushResult = {
export type ApnsPushAlertResult = ApnsPushResult;
export type ApnsPushWakeResult = ApnsPushResult;
const EXEC_APPROVAL_GENERIC_ALERT_BODY = "Open OpenClaw to review this request.";
type ApnsPushType = "alert" | "background";
type ApnsRequestParams = {
@@ -894,6 +896,40 @@ function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }
};
}
function resolveExecApprovalAlertBody(): string {
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
}
function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: string }): object {
return {
aps: {
alert: {
title: "Exec approval required",
body: resolveExecApprovalAlertBody(),
},
sound: "default",
},
openclaw: {
kind: "exec.approval.requested",
approvalId: params.approvalId,
ts: Date.now(),
},
};
}
function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: string }): object {
return {
aps: {
"content-available": 1,
},
openclaw: {
kind: "exec.approval.resolved",
approvalId: params.approvalId,
ts: Date.now(),
},
};
}
type ApnsAlertCommonParams = {
nodeId: string;
title: string;
@@ -941,6 +977,52 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
requestSender?: never;
};
type ApnsExecApprovalAlertCommonParams = {
nodeId: string;
approvalId: string;
timeoutMs?: number;
};
type DirectApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
registration: DirectApnsRegistration;
auth: ApnsAuthConfig;
requestSender?: ApnsRequestSender;
relayConfig?: never;
relayRequestSender?: never;
};
type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
registration: RelayApnsRegistration;
relayConfig: ApnsRelayConfig;
relayRequestSender?: ApnsRelayRequestSender;
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
auth?: never;
requestSender?: never;
};
type ApnsExecApprovalResolvedCommonParams = {
nodeId: string;
approvalId: string;
timeoutMs?: number;
};
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;
};
export async function sendApnsAlert(
params: DirectApnsAlertParams | RelayApnsAlertParams,
): Promise<ApnsPushAlertResult> {
@@ -1006,4 +1088,68 @@ export async function sendApnsBackgroundWake(
});
}
export async function sendApnsExecApprovalAlert(
params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams,
): Promise<ApnsPushAlertResult> {
const payload = createExecApprovalAlertPayload({
nodeId: params.nodeId,
approvalId: params.approvalId,
});
if (params.registration.transport === "relay") {
const relayParams = params as RelayApnsExecApprovalAlertParams;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload,
pushType: "alert",
priority: "10",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
});
}
const directParams = params as DirectApnsExecApprovalAlertParams;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
pushType: "alert",
priority: "10",
});
}
export async function sendApnsExecApprovalResolvedWake(
params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams,
): Promise<ApnsPushWakeResult> {
const payload = createExecApprovalResolvedPayload({
nodeId: params.nodeId,
approvalId: params.approvalId,
});
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,
pushType: "background",
priority: "5",
});
}
export { type ApnsRelayConfig, type ApnsRelayConfigResolution, resolveApnsRelayConfigFromEnv };