Files
openclaw/src/gateway/server-methods/nodes.invoke-wake.test.ts
2026-03-12 16:22:53 +02:00

573 lines
17 KiB
TypeScript

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,
})),
clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(),
resolveApnsRelayConfigFromEnv: vi.fn(),
sendApnsBackgroundWake: vi.fn(),
sendApnsAlert: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(() => false),
}));
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", () => ({
clearApnsRegistrationIfCurrent: mocks.clearApnsRegistrationIfCurrent,
loadApnsRegistration: mocks.loadApnsRegistration,
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
sendApnsBackgroundWake: mocks.sendApnsBackgroundWake,
sendApnsAlert: mocks.sendApnsAlert,
shouldClearStoredApnsRegistration: mocks.shouldClearStoredApnsRegistration,
}));
type RespondCall = [
boolean,
unknown?,
{
code?: number;
message?: string;
details?: unknown;
}?,
];
type TestNodeSession = {
nodeId: string;
commands: string[];
platform?: string;
};
const WAKE_WAIT_TIMEOUT_MS = 3_001;
function makeNodeInvokeParams(overrides?: Partial<Record<string, unknown>>) {
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<Record<string, unknown>>;
}) {
const respond = vi.fn();
const logGateway = {
info: vi.fn(),
warn: vi.fn(),
};
await nodeHandlers["node.invoke"]({
params: makeNodeInvokeParams(params.requestParams),
respond: respond as never,
context: {
nodeRegistry: params.nodeRegistry,
execApprovalManager: undefined,
logGateway,
} as never,
client: null,
req: { type: "req", id: "req-node-invoke", method: "node.invoke" },
isWebchatConnect: () => false,
});
return respond;
}
async function pullPending(nodeId: string) {
const respond = vi.fn();
await nodeHandlers["node.pending.pull"]({
params: {},
respond: respond as never,
context: {} as never,
client: {
connect: {
role: "node",
client: {
id: nodeId,
mode: "node",
name: "ios-test",
platform: "iOS 26.4.0",
version: "test",
},
},
} as never,
req: { type: "req", id: "req-node-pending", method: "node.pending.pull" },
isWebchatConnect: () => false,
});
return respond;
}
async function ackPending(nodeId: string, ids: string[]) {
const respond = vi.fn();
await nodeHandlers["node.pending.ack"]({
params: { ids },
respond: respond as never,
context: {} as never,
client: {
connect: {
role: "node",
client: {
id: nodeId,
mode: "node",
name: "ios-test",
platform: "iOS 26.4.0",
version: "test",
},
},
} as never,
req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" },
isWebchatConnect: () => false,
});
return respond;
}
function mockSuccessfulWakeConfig(nodeId: string) {
mocks.loadApnsRegistration.mockResolvedValue({
nodeId,
transport: "direct",
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-----", // pragma: allowlist secret
},
});
mocks.sendApnsBackgroundWake.mockResolvedValue({
ok: true,
status: 200,
tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
transport: "direct",
});
}
describe("node.invoke APNs wake path", () => {
beforeEach(() => {
mocks.loadConfig.mockClear();
mocks.loadConfig.mockReturnValue({});
mocks.resolveNodeCommandAllowlist.mockClear();
mocks.resolveNodeCommandAllowlist.mockReturnValue([]);
mocks.isNodeCommandAllowed.mockClear();
mocks.isNodeCommandAllowed.mockReturnValue({ ok: true });
mocks.sanitizeNodeInvokeParamsForForwarding.mockClear();
mocks.sanitizeNodeInvokeParamsForForwarding.mockImplementation(
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
);
mocks.loadApnsRegistration.mockClear();
mocks.clearApnsRegistrationIfCurrent.mockClear();
mocks.resolveApnsAuthConfigFromEnv.mockClear();
mocks.resolveApnsRelayConfigFromEnv.mockClear();
mocks.sendApnsBackgroundWake.mockClear();
mocks.sendApnsAlert.mockClear();
mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
});
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();
mockSuccessfulWakeConfig("ios-node-reconnect");
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(WAKE_WAIT_TIMEOUT_MS);
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("clears stale registrations after an invalid device token wake failure", async () => {
mocks.loadApnsRegistration.mockResolvedValue({
nodeId: "ios-node-stale",
transport: "direct",
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-----", // pragma: allowlist secret
},
});
mocks.sendApnsBackgroundWake.mockResolvedValue({
ok: false,
status: 400,
reason: "BadDeviceToken",
tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
transport: "direct",
});
mocks.shouldClearStoredApnsRegistration.mockReturnValue(true);
const nodeRegistry = {
get: vi.fn(() => undefined),
invoke: vi.fn().mockResolvedValue({ ok: true }),
};
const respond = await invokeNode({
nodeRegistry,
requestParams: { nodeId: "ios-node-stale", idempotencyKey: "idem-stale" },
});
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.message).toBe("node not connected");
expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
nodeId: "ios-node-stale",
registration: {
nodeId: "ios-node-stale",
transport: "direct",
token: "abcd1234abcd1234abcd1234abcd1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
});
});
it("does not clear relay registrations from wake failures", async () => {
mocks.loadConfig.mockReturnValue({
gateway: {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
},
});
mocks.loadApnsRegistration.mockResolvedValue({
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
updatedAtMs: 1,
tokenDebugSuffix: "abcd1234",
});
mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({
ok: true,
value: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
});
mocks.sendApnsBackgroundWake.mockResolvedValue({
ok: false,
status: 410,
reason: "Unregistered",
tokenSuffix: "abcd1234",
topic: "ai.openclaw.ios",
environment: "production",
transport: "relay",
});
mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
const nodeRegistry = {
get: vi.fn(() => undefined),
invoke: vi.fn().mockResolvedValue({ ok: true }),
};
const respond = await invokeNode({
nodeRegistry,
requestParams: { nodeId: "ios-node-relay", idempotencyKey: "idem-relay" },
});
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.message).toBe("node not connected");
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
});
expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
registration: {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
distribution: "official",
updatedAtMs: 1,
tokenDebugSuffix: "abcd1234",
},
result: {
ok: false,
status: 410,
reason: "Unregistered",
tokenSuffix: "abcd1234",
topic: "ai.openclaw.ios",
environment: "production",
transport: "relay",
},
});
expect(mocks.clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
});
it("forces one retry wake when the first wake still fails to reconnect", async () => {
vi.useFakeTimers();
mockSuccessfulWakeConfig("ios-node-throttle");
const nodeRegistry = {
get: vi.fn(() => undefined),
invoke: vi.fn().mockResolvedValue({ ok: true }),
};
const invokePromise = invokeNode({
nodeRegistry,
requestParams: { nodeId: "ios-node-throttle", idempotencyKey: "idem-throttle-1" },
});
await vi.advanceTimersByTimeAsync(20_000);
await invokePromise;
expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(2);
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
it("queues iOS foreground-only command failures and keeps them until acked", async () => {
mocks.loadApnsRegistration.mockResolvedValue(null);
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "ios-node-queued",
commands: ["canvas.navigate"],
platform: "iOS 26.4.0",
})),
invoke: vi.fn().mockResolvedValue({
ok: false,
error: {
code: "NODE_BACKGROUND_UNAVAILABLE",
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
},
}),
};
const respond = await invokeNode({
nodeRegistry,
requestParams: {
nodeId: "ios-node-queued",
command: "canvas.navigate",
params: { url: "http://example.com/" },
idempotencyKey: "idem-queued",
},
});
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 command queued until iOS returns to foreground");
expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled();
const pullRespond = await pullPending("ios-node-queued");
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
expect(pullCall?.[0]).toBe(true);
expect(pullCall?.[1]).toMatchObject({
nodeId: "ios-node-queued",
actions: [
expect.objectContaining({
command: "canvas.navigate",
paramsJSON: JSON.stringify({ url: "http://example.com/" }),
}),
],
});
const repeatedPullRespond = await pullPending("ios-node-queued");
const repeatedPullCall = repeatedPullRespond.mock.calls[0] as RespondCall | undefined;
expect(repeatedPullCall?.[0]).toBe(true);
expect(repeatedPullCall?.[1]).toMatchObject({
nodeId: "ios-node-queued",
actions: [
expect.objectContaining({
command: "canvas.navigate",
paramsJSON: JSON.stringify({ url: "http://example.com/" }),
}),
],
});
const queuedActionId = (pullCall?.[1] as { actions?: Array<{ id?: string }> } | undefined)
?.actions?.[0]?.id;
expect(queuedActionId).toBeTruthy();
const ackRespond = await ackPending("ios-node-queued", [queuedActionId!]);
const ackCall = ackRespond.mock.calls[0] as RespondCall | undefined;
expect(ackCall?.[0]).toBe(true);
expect(ackCall?.[1]).toMatchObject({
nodeId: "ios-node-queued",
ackedIds: [queuedActionId],
remainingCount: 0,
});
const emptyPullRespond = await pullPending("ios-node-queued");
const emptyPullCall = emptyPullRespond.mock.calls[0] as RespondCall | undefined;
expect(emptyPullCall?.[0]).toBe(true);
expect(emptyPullCall?.[1]).toMatchObject({
nodeId: "ios-node-queued",
actions: [],
});
});
it("dedupes queued foreground actions by idempotency key", async () => {
mocks.loadApnsRegistration.mockResolvedValue(null);
const nodeRegistry = {
get: vi.fn(() => ({
nodeId: "ios-node-dedupe",
commands: ["canvas.navigate"],
platform: "iPadOS 26.4.0",
})),
invoke: vi.fn().mockResolvedValue({
ok: false,
error: {
code: "NODE_BACKGROUND_UNAVAILABLE",
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
},
}),
};
await invokeNode({
nodeRegistry,
requestParams: {
nodeId: "ios-node-dedupe",
command: "canvas.navigate",
params: { url: "http://example.com/first" },
idempotencyKey: "idem-dedupe",
},
});
await invokeNode({
nodeRegistry,
requestParams: {
nodeId: "ios-node-dedupe",
command: "canvas.navigate",
params: { url: "http://example.com/first" },
idempotencyKey: "idem-dedupe",
},
});
const pullRespond = await pullPending("ios-node-dedupe");
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
expect(pullCall?.[0]).toBe(true);
expect(pullCall?.[1]).toMatchObject({
nodeId: "ios-node-dedupe",
actions: [
expect.objectContaining({
command: "canvas.navigate",
paramsJSON: JSON.stringify({ url: "http://example.com/first" }),
}),
],
});
const actions = (pullCall?.[1] as { actions?: unknown[] } | undefined)?.actions ?? [];
expect(actions).toHaveLength(1);
});
});