diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts new file mode 100644 index 0000000000..b919c94399 --- /dev/null +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../protocol/index.js"; +import { nodeHandlers } from "./nodes.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + resolveNodeCommandAllowlist: vi.fn(() => []), + isNodeCommandAllowed: vi.fn(() => ({ ok: true })), + sanitizeNodeInvokeParamsForForwarding: vi.fn(({ rawParams }: { rawParams: unknown }) => ({ + ok: true, + params: rawParams, + })), + loadApnsRegistration: vi.fn(), + resolveApnsAuthConfigFromEnv: vi.fn(), + sendApnsBackgroundWake: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../node-command-policy.js", () => ({ + resolveNodeCommandAllowlist: mocks.resolveNodeCommandAllowlist, + isNodeCommandAllowed: mocks.isNodeCommandAllowed, +})); + +vi.mock("../node-invoke-sanitize.js", () => ({ + sanitizeNodeInvokeParamsForForwarding: mocks.sanitizeNodeInvokeParamsForForwarding, +})); + +vi.mock("../../infra/push-apns.js", () => ({ + loadApnsRegistration: mocks.loadApnsRegistration, + resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv, + sendApnsBackgroundWake: mocks.sendApnsBackgroundWake, +})); + +type RespondCall = [ + boolean, + unknown?, + { + code?: number; + message?: string; + details?: unknown; + }?, +]; + +type TestNodeSession = { + nodeId: string; + commands: string[]; +}; + +function makeNodeInvokeParams(overrides?: Partial>) { + return { + nodeId: "ios-node-1", + command: "camera.capture", + params: { quality: "high" }, + timeoutMs: 5000, + idempotencyKey: "idem-node-invoke", + ...overrides, + }; +} + +async function invokeNode(params: { + nodeRegistry: { + get: (nodeId: string) => TestNodeSession | undefined; + invoke: (payload: { + nodeId: string; + command: string; + params?: unknown; + timeoutMs?: number; + idempotencyKey?: string; + }) => Promise<{ + ok: boolean; + payload?: unknown; + payloadJSON?: string | null; + error?: { code?: string; message?: string } | null; + }>; + }; + requestParams?: Partial>; +}) { + const respond = vi.fn(); + await nodeHandlers["node.invoke"]({ + params: makeNodeInvokeParams(params.requestParams), + respond: respond as never, + context: { + nodeRegistry: params.nodeRegistry, + execApprovalManager: undefined, + } as never, + client: null, + req: { type: "req", id: "req-node-invoke", method: "node.invoke" }, + isWebchatConnect: () => false, + }); + return respond; +} + +describe("node.invoke APNs wake path", () => { + beforeEach(() => { + mocks.loadConfig.mockReset(); + mocks.loadConfig.mockReturnValue({}); + mocks.resolveNodeCommandAllowlist.mockReset(); + mocks.resolveNodeCommandAllowlist.mockReturnValue([]); + mocks.isNodeCommandAllowed.mockReset(); + mocks.isNodeCommandAllowed.mockReturnValue({ ok: true }); + mocks.sanitizeNodeInvokeParamsForForwarding.mockReset(); + mocks.sanitizeNodeInvokeParamsForForwarding.mockImplementation( + ({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }), + ); + mocks.loadApnsRegistration.mockReset(); + mocks.resolveApnsAuthConfigFromEnv.mockReset(); + mocks.sendApnsBackgroundWake.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the existing not-connected response when wake path is unavailable", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + const respond = await invokeNode({ nodeRegistry }); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("node not connected"); + expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled(); + expect(nodeRegistry.invoke).not.toHaveBeenCalled(); + }); + + it("wakes and retries invoke after the node reconnects", async () => { + vi.useFakeTimers(); + mocks.loadApnsRegistration.mockResolvedValue({ + nodeId: "ios-node-reconnect", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); + + let connected = false; + const session: TestNodeSession = { nodeId: "ios-node-reconnect", commands: ["camera.capture"] }; + const nodeRegistry = { + get: vi.fn((nodeId: string) => { + if (nodeId !== "ios-node-reconnect") { + return undefined; + } + return connected ? session : undefined; + }), + invoke: vi.fn().mockResolvedValue({ + ok: true, + payload: { ok: true }, + payloadJSON: '{"ok":true}', + }), + }; + + const invokePromise = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-reconnect", idempotencyKey: "idem-reconnect" }, + }); + setTimeout(() => { + connected = true; + }, 300); + + await vi.advanceTimersByTimeAsync(4_000); + const respond = await invokePromise; + + expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "ios-node-reconnect", + command: "camera.capture", + }), + ); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]).toMatchObject({ ok: true, nodeId: "ios-node-reconnect" }); + }); + + it("throttles repeated wake attempts for the same disconnected node", async () => { + vi.useFakeTimers(); + mocks.loadApnsRegistration.mockResolvedValue({ + nodeId: "ios-node-throttle", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); + + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + const first = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-throttle", idempotencyKey: "idem-throttle-1" }, + }); + await vi.advanceTimersByTimeAsync(4_000); + await first; + + const second = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-throttle", idempotencyKey: "idem-throttle-2" }, + }); + await vi.advanceTimersByTimeAsync(4_000); + await second; + + expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 9d7df8ea4b..1ea705365e 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -8,6 +8,11 @@ import { requestNodePairing, verifyNodeToken, } from "../../infra/node-pairing.js"; +import { + loadApnsRegistration, + resolveApnsAuthConfigFromEnv, + sendApnsBackgroundWake, +} from "../../infra/push-apns.js"; import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js"; import { @@ -34,6 +39,17 @@ import { } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; +const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; +const NODE_WAKE_RECONNECT_POLL_MS = 150; +const NODE_WAKE_THROTTLE_MS = 15_000; + +type NodeWakeState = { + lastWakeAtMs: number; + inFlight?: Promise; +}; + +const nodeWakeById = new Map(); + function isNodeEntry(entry: { role?: string; roles?: string[] }) { if (entry.role === "node") { return true; @@ -44,6 +60,77 @@ function isNodeEntry(entry: { role?: string; roles?: string[] }) { return false; } +async function delayMs(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function maybeWakeNodeWithApns(nodeId: string): Promise { + const state = nodeWakeById.get(nodeId) ?? { lastWakeAtMs: 0 }; + nodeWakeById.set(nodeId, state); + + if (state.inFlight) { + return await state.inFlight; + } + + const now = Date.now(); + if (state.lastWakeAtMs > 0 && now - state.lastWakeAtMs < NODE_WAKE_THROTTLE_MS) { + return true; + } + + state.inFlight = (async () => { + try { + const registration = await loadApnsRegistration(nodeId); + if (!registration) { + return false; + } + + const auth = await resolveApnsAuthConfigFromEnv(process.env); + if (!auth.ok) { + return false; + } + + state.lastWakeAtMs = Date.now(); + await sendApnsBackgroundWake({ + auth: auth.value, + registration, + nodeId, + wakeReason: "node.invoke", + }); + } catch { + // Best-effort wake only. + if (state.lastWakeAtMs === 0) { + return false; + } + } + return true; + })(); + + try { + return await state.inFlight; + } finally { + state.inFlight = undefined; + } +} + +async function waitForNodeReconnect(params: { + nodeId: string; + context: { nodeRegistry: { get: (nodeId: string) => unknown } }; + timeoutMs?: number; + pollMs?: number; +}): Promise { + const timeoutMs = Math.max(250, params.timeoutMs ?? NODE_WAKE_RECONNECT_WAIT_MS); + const pollMs = Math.max(50, params.pollMs ?? NODE_WAKE_RECONNECT_POLL_MS); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (params.context.nodeRegistry.get(params.nodeId)) { + return true; + } + await delayMs(pollMs); + } + return Boolean(params.context.nodeRegistry.get(params.nodeId)); +} + export const nodeHandlers: GatewayRequestHandlers = { "node.pair.request": async ({ params, respond, context }) => { if (!validateNodePairRequestParams(params)) { @@ -383,16 +470,23 @@ export const nodeHandlers: GatewayRequestHandlers = { } await respondUnavailableOnThrow(respond, async () => { - const nodeSession = context.nodeRegistry.get(nodeId); + let nodeSession = context.nodeRegistry.get(nodeId); if (!nodeSession) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { - details: { code: "NOT_CONNECTED" }, - }), - ); - return; + const wakeAvailable = await maybeWakeNodeWithApns(nodeId); + if (wakeAvailable) { + await waitForNodeReconnect({ nodeId, context }); + } + nodeSession = context.nodeRegistry.get(nodeId); + if (!nodeSession) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { + details: { code: "NOT_CONNECTED" }, + }), + ); + return; + } } const cfg = loadConfig(); const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index bdfc017f88..7265a52157 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,15 +1,21 @@ +import { generateKeyPairSync } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadApnsRegistration, normalizeApnsEnvironment, registerApnsToken, resolveApnsAuthConfigFromEnv, + sendApnsAlert, + sendApnsBackgroundWake, } from "./push-apns.js"; const tempDirs: string[] = []; +const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) + .privateKey.export({ format: "pem", type: "pkcs8" }) + .toString(); async function makeTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-")); @@ -92,3 +98,96 @@ describe("push APNs env config", () => { expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); }); }); + +describe("push APNs send semantics", () => { + it("sends alert pushes with alert headers and payload", async () => { + const send = vi.fn().mockResolvedValue({ + status: 200, + apnsId: "apns-alert-id", + body: "", + }); + + const result = await sendApnsAlert({ + auth: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: testAuthPrivateKey, + }, + registration: { + nodeId: "ios-node-alert", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }, + nodeId: "ios-node-alert", + title: "Wake", + body: "Ping", + requestSender: send, + }); + + expect(send).toHaveBeenCalledTimes(1); + const sent = send.mock.calls[0]?.[0]; + expect(sent?.pushType).toBe("alert"); + expect(sent?.priority).toBe("10"); + expect(sent?.payload).toMatchObject({ + aps: { + alert: { title: "Wake", body: "Ping" }, + sound: "default", + }, + openclaw: { + kind: "push.test", + nodeId: "ios-node-alert", + }, + }); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + }); + + it("sends background wake pushes with silent payload semantics", async () => { + const send = vi.fn().mockResolvedValue({ + status: 200, + apnsId: "apns-wake-id", + body: "", + }); + + const result = await sendApnsBackgroundWake({ + auth: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: testAuthPrivateKey, + }, + registration: { + nodeId: "ios-node-wake", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "production", + updatedAtMs: 1, + }, + nodeId: "ios-node-wake", + wakeReason: "node.invoke", + requestSender: send, + }); + + expect(send).toHaveBeenCalledTimes(1); + const sent = send.mock.calls[0]?.[0]; + expect(sent?.pushType).toBe("background"); + expect(sent?.priority).toBe("5"); + expect(sent?.payload).toMatchObject({ + aps: { + "content-available": 1, + }, + openclaw: { + kind: "node.wake", + reason: "node.invoke", + nodeId: "ios-node-wake", + }, + }); + const sentPayload = sent?.payload as { aps?: { alert?: unknown; sound?: unknown } } | undefined; + const aps = sentPayload?.aps; + expect(aps?.alert).toBeUndefined(); + expect(aps?.sound).toBeUndefined(); + expect(result.ok).toBe(true); + expect(result.environment).toBe("production"); + }); +}); diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 45ca5a665d..8b0e672894 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -35,6 +35,33 @@ export type ApnsPushAlertResult = { environment: ApnsEnvironment; }; +export type ApnsPushWakeResult = { + ok: boolean; + status: number; + apnsId?: string; + reason?: string; + tokenSuffix: string; + topic: string; + environment: ApnsEnvironment; +}; + +type ApnsPushType = "alert" | "background"; + +type ApnsRequestParams = { + token: string; + topic: string; + environment: ApnsEnvironment; + bearerToken: string; + payload: object; + timeoutMs: number; + pushType: ApnsPushType; + priority: "10" | "5"; +}; + +type ApnsRequestResponse = { status: number; apnsId?: string; body: string }; + +type ApnsRequestSender = (params: ApnsRequestParams) => Promise; + type ApnsRegistrationState = { registrationsByNodeId: Record; }; @@ -277,7 +304,9 @@ async function sendApnsRequest(params: { bearerToken: string; payload: object; timeoutMs: number; -}): Promise<{ status: number; apnsId?: string; body: string }> { + pushType: ApnsPushType; + priority: "10" | "5"; +}): Promise { const authority = params.environment === "production" ? "https://api.push.apple.com" @@ -313,8 +342,8 @@ async function sendApnsRequest(params: { ":path": requestPath, authorization: `bearer ${params.bearerToken}`, "apns-topic": params.topic, - "apns-push-type": "alert", - "apns-priority": "10", + "apns-push-type": params.pushType, + "apns-priority": params.priority, "apns-expiration": "0", "content-type": "application/json", "content-length": Buffer.byteLength(body).toString(), @@ -351,6 +380,29 @@ async function sendApnsRequest(params: { }); } +function resolveApnsTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) + ? Math.max(1000, Math.trunc(timeoutMs)) + : DEFAULT_APNS_TIMEOUT_MS; +} + +function toApnsPushResult(params: { + response: ApnsRequestResponse; + token: string; + topic: string; + environment: ApnsEnvironment; +}): ApnsPushWakeResult { + return { + ok: params.response.status === 200, + status: params.response.status, + apnsId: params.response.apnsId, + reason: parseReason(params.response.body), + tokenSuffix: params.token.slice(-8), + topic: params.topic, + environment: params.environment, + }; +} + export async function sendApnsAlert(params: { auth: ApnsAuthConfig; registration: ApnsRegistration; @@ -358,6 +410,7 @@ export async function sendApnsAlert(params: { title: string; body: string; timeoutMs?: number; + requestSender?: ApnsRequestSender; }): Promise { const token = normalizeApnsToken(params.registration.token); if (!isLikelyApnsToken(token)) { @@ -385,25 +438,73 @@ export async function sendApnsAlert(params: { }, }; - const response = await sendApnsRequest({ + const sender = params.requestSender ?? sendApnsRequest; + const response = await sender({ token, topic, environment, bearerToken, payload, - timeoutMs: - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? Math.max(1000, Math.trunc(params.timeoutMs)) - : DEFAULT_APNS_TIMEOUT_MS, + timeoutMs: resolveApnsTimeoutMs(params.timeoutMs), + pushType: "alert", + priority: "10", }); - return { - ok: response.status === 200, - status: response.status, - apnsId: response.apnsId, - reason: parseReason(response.body), - tokenSuffix: token.slice(-8), + return toApnsPushResult({ + response, + token, topic, environment, - }; + }); +} + +export async function sendApnsBackgroundWake(params: { + auth: ApnsAuthConfig; + registration: ApnsRegistration; + nodeId: string; + wakeReason?: string; + timeoutMs?: number; + requestSender?: ApnsRequestSender; +}): Promise { + const token = normalizeApnsToken(params.registration.token); + if (!isLikelyApnsToken(token)) { + throw new Error("invalid APNs token"); + } + const topic = normalizeTopic(params.registration.topic); + if (!topic) { + throw new Error("topic required"); + } + const environment = params.registration.environment; + const bearerToken = getApnsBearerToken(params.auth); + + const payload = { + aps: { + "content-available": 1, + }, + openclaw: { + kind: "node.wake", + reason: params.wakeReason ?? "node.invoke", + nodeId: params.nodeId, + ts: Date.now(), + }, + }; + + const sender = params.requestSender ?? sendApnsRequest; + const response = await sender({ + token, + topic, + environment, + bearerToken, + payload, + timeoutMs: resolveApnsTimeoutMs(params.timeoutMs), + pushType: "background", + priority: "5", + }); + + return toApnsPushResult({ + response, + token, + topic, + environment, + }); }