Files
openclaw/src/gateway/watch-node-http.test.ts
Peter Steinberger 54f45a950b feat(watchos): connect directly to Gateway as a node (#102893)
* feat(watchos): add direct gateway node

* docs: refresh watch node docs map

* chore: leave release notes to release workflow

* chore(ios): refresh native localization inventory

* fix(watchos): keep direct node policy bounded
2026-07-09 15:53:02 +01:00

580 lines
21 KiB
TypeScript

import { createServer, type Server } from "node:http";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { PROTOCOL_VERSION, type ConnectParams } from "../../packages/gateway-protocol/src/index.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { revokeDeviceToken } from "../infra/device-pairing.js";
import { listNodePairing } from "../infra/node-pairing.js";
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
import { createWatchNodeHttpRuntime, testing } from "./watch-node-http.js";
const tempDirs = createTrackedTempDirs();
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
await tempDirs.cleanup();
});
function makeConnectParams(params: {
identity: ReturnType<typeof loadOrCreateDeviceIdentity>;
nonce: string;
bootstrapToken?: string;
deviceToken?: string;
}): ConnectParams {
const publicKey = publicKeyRawBase64UrlFromPem(params.identity.publicKeyPem);
const auth = params.deviceToken
? { deviceToken: params.deviceToken }
: { bootstrapToken: params.bootstrapToken };
const signedAt = Date.now();
const client = {
id: GATEWAY_CLIENT_IDS.WATCHOS_APP,
displayName: "Test Watch",
version: "1.0.0",
platform: "watchOS 11.5.0",
deviceFamily: "Apple Watch",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: "watch-test",
} as const;
const scopes: string[] = [];
const signaturePayload = buildDeviceAuthPayloadV3({
deviceId: params.identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role: "node",
scopes,
signedAtMs: signedAt,
token: params.deviceToken ?? params.bootstrapToken ?? null,
nonce: params.nonce,
platform: client.platform,
deviceFamily: client.deviceFamily,
});
return {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client,
caps: [],
commands: ["device.info", "device.status", "system.notify"],
permissions: { notifications: true },
role: "node",
scopes,
auth,
device: {
id: params.identity.deviceId,
publicKey,
signature: signDevicePayload(params.identity.privateKeyPem, signaturePayload),
signedAt,
nonce: params.nonce,
},
} as ConnectParams;
}
async function startRuntime(
baseDir: string,
options?: { rateLimiter?: AuthRateLimiter; abortConnectResponse?: boolean },
) {
const nodeRegistry = new NodeRegistry();
const broadcasts: Array<{ event: string; payload: unknown }> = [];
const connectedNodes: string[] = [];
const disconnectedNodes: Array<{ nodeId: string; reason: string }> = [];
const runtime = createWatchNodeHttpRuntime({
nodeRegistry,
getConfig: () => ({}),
pairingBaseDir: baseDir,
broadcast: (event, payload) => broadcasts.push({ event, payload }),
onNodeConnected: (session) => connectedNodes.push(session.nodeId),
onNodeDisconnected: (nodeId, reason) => disconnectedNodes.push({ nodeId, reason }),
...(options?.rateLimiter ? { rateLimiter: options.rateLimiter } : {}),
});
let resolveConnectHandled: () => void = () => undefined;
const connectHandled = new Promise<void>((resolve) => {
resolveConnectHandled = resolve;
});
const server = createServer((req, res) => {
const isConnect = req.url === "/api/nodes/watch/connect";
if (isConnect && options?.abortConnectResponse) {
res.end = (() => {
res.destroy();
return res;
}) as typeof res.end;
}
void runtime
.handleRequest(req, res)
.then((handled) => {
if (!handled && !res.writableEnded) {
res.statusCode = 404;
res.end();
}
})
.finally(() => {
if (isConnect) {
resolveConnectHandled();
}
});
});
servers.push(server);
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected TCP server address");
}
return {
nodeRegistry,
broadcasts,
connectedNodes,
disconnectedNodes,
runtime,
connectHandled,
baseUrl: `http://127.0.0.1:${address.port}/api/nodes/watch`,
};
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json()) as Record<string, unknown>;
}
async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Promise<void> {
await vi.waitFor(async () => {
const paired = (await listNodePairing(baseDir)).paired.find((entry) => entry.nodeId === nodeId);
expect(paired?.lastConnectedAtMs).toEqual(expect.any(Number));
});
}
describe("watch node HTTP transport", () => {
it("accepts only the canonical bounded watch surface", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-surface-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const bounded = makeConnectParams({ identity, nonce: "nonce", bootstrapToken: "token" });
expect(testing.isCanonicalWatchNode(bounded)).toBe(true);
expect(
testing.isCanonicalWatchNode({
...bounded,
permissions: { notifications: false },
}),
).toBe(true);
expect(
testing.isCanonicalWatchNode({
...bounded,
commands: [...(bounded.commands ?? []), "system.run"],
}),
).toBe(false);
expect(testing.isCanonicalWatchNode({ ...bounded, caps: ["canvas"] })).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
commands: [...(bounded.commands ?? []), "canvas.present"],
}),
).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
permissions: { ...bounded.permissions, canvas: true },
}),
).toBe(false);
expect(
testing.isCanonicalWatchNode({
...bounded,
client: { ...bounded.client, deviceFamily: "iPhone" },
}),
).toBe(false);
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 3, maxProtocol: 3 })).toBe(
false,
);
expect(testing.isCanonicalWatchNode({ ...bounded, minProtocol: 4, maxProtocol: 3 })).toBe(
false,
);
const challenges = testing.createChallengeStore();
const legitimate = challenges.issue("legitimate-client", 1_000);
for (let index = 0; index < 32; index += 1) {
challenges.issue("attacker", 1_000 + index);
}
expect(challenges.consume(legitimate.nonce, "legitimate-client", 2_000)).toBe(true);
});
it("requires an authenticated disconnect and emits one lifecycle teardown", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
const sessionToken = String(connected.sessionToken);
expect(connectedNodes).toEqual([identity.deviceId]);
const unauthenticated = await fetch(`${baseUrl}/disconnect`, { method: "POST" });
expect(unauthenticated.status).toBe(401);
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
expect(disconnectedNodes).toEqual([]);
const wrongMethod = await fetch(`${baseUrl}/disconnect`, {
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(wrongMethod.status).toBe(405);
expect(nodeRegistry.get(identity.deviceId)).toBeDefined();
const disconnectResponse = await fetch(`${baseUrl}/disconnect`, {
method: "POST",
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(disconnectResponse.status).toBe(200);
await expect(readJson(disconnectResponse)).resolves.toEqual({ ok: true });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toEqual([
{ nodeId: identity.deviceId, reason: "watch disconnected" },
]);
const repeatedDisconnect = await fetch(`${baseUrl}/disconnect`, {
method: "POST",
headers: { authorization: `Bearer ${sessionToken}` },
});
expect(repeatedDisconnect.status).toBe(401);
runtime.close();
expect(disconnectedNodes).toHaveLength(1);
});
it("rejects empty shadow credentials without consuming the challenge", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { baseUrl, runtime } = await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connect = makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
});
const shadowedResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...connect,
auth: { ...connect.auth, token: "" },
}),
});
expect(shadowedResponse.status).toBe(401);
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(connect),
});
expect(connectResponse.status).toBe(200);
await readJson(connectResponse);
await waitForLastConnectedMetadata(baseDir, identity.deviceId);
runtime.close();
});
it("keeps challenge throttling after abort and resets it after completion", async () => {
const limiterConfig = {
maxAttempts: 1,
windowMs: 60_000,
lockoutMs: 60_000,
exemptLoopback: false,
pruneIntervalMs: 0,
};
const abortedBaseDir = await tempDirs.make("openclaw-watch-node-aborted-connect-");
const abortedIdentity = loadOrCreateDeviceIdentity(
path.join(abortedBaseDir, "watch-identity.json"),
);
const abortedBootstrap = await issueDeviceBootstrapToken({
baseDir: abortedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const abortedLimiter = createAuthRateLimiter(limiterConfig);
try {
const abortedRuntime = await startRuntime(abortedBaseDir, {
rateLimiter: abortedLimiter,
abortConnectResponse: true,
});
const challenge = await readJson(await fetch(`${abortedRuntime.baseUrl}/challenge`));
await expect(
fetch(`${abortedRuntime.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: abortedIdentity,
nonce: String(challenge.nonce),
bootstrapToken: abortedBootstrap.token,
}),
),
}),
).rejects.toThrow();
await abortedRuntime.connectHandled;
const stillLimited = await fetch(`${abortedRuntime.baseUrl}/challenge`);
expect(stillLimited.status).toBe(429);
abortedRuntime.runtime.close();
} finally {
abortedLimiter.dispose();
}
const completedBaseDir = await tempDirs.make("openclaw-watch-node-completed-connect-");
const completedIdentity = loadOrCreateDeviceIdentity(
path.join(completedBaseDir, "watch-identity.json"),
);
const completedBootstrap = await issueDeviceBootstrapToken({
baseDir: completedBaseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const completedLimiter = createAuthRateLimiter(limiterConfig);
try {
const completedRuntime = await startRuntime(completedBaseDir, {
rateLimiter: completedLimiter,
});
const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`));
const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity: completedIdentity,
nonce: String(challenge.nonce),
bootstrapToken: completedBootstrap.token,
}),
),
});
expect(connectResponse.status).toBe(200);
await readJson(connectResponse);
await completedRuntime.connectHandled;
await waitForLastConnectedMetadata(completedBaseDir, completedIdentity.deviceId);
const resetAfterCompletion = await fetch(`${completedRuntime.baseUrl}/challenge`);
expect(resetAfterCompletion.status).toBe(200);
completedRuntime.runtime.close();
} finally {
completedLimiter.dispose();
}
});
it("bootstraps, registers, polls an invoke, and accepts its result", async () => {
const baseDir = await tempDirs.make("openclaw-watch-node-http-");
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "watch-identity.json"));
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } =
await startRuntime(baseDir);
const challenge = await readJson(await fetch(`${baseUrl}/challenge`));
const connectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(challenge.nonce),
bootstrapToken: issued.token,
}),
),
});
expect(connectResponse.status).toBe(200);
const connected = await readJson(connectResponse);
expect(connected.sessionToken).toEqual(expect.any(String));
expect(connected.deviceToken).toEqual(expect.any(String));
expect(nodeRegistry.get(identity.deviceId)?.commands).toEqual([
"device.info",
"device.status",
"system.notify",
]);
expect(broadcasts.map((entry) => entry.event)).toContain("device.pair.resolved");
expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved");
expect(connectedNodes).toEqual([identity.deviceId]);
const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const reconnectResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(reconnectChallenge.nonce),
deviceToken: String(connected.deviceToken),
}),
),
});
expect(reconnectResponse.status).toBe(200);
const reconnected = await readJson(reconnectResponse);
expect(reconnected.deviceToken).toBe(connected.deviceToken);
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId]);
expect(disconnectedNodes).toEqual([]);
const stalePollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(connected.sessionToken)}` },
});
expect(stalePollResponse.status).toBe(401);
const invoke = nodeRegistry.invoke({
nodeId: identity.deviceId,
command: "device.info",
timeoutMs: 2_000,
});
const pollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
});
expect(pollResponse.status).toBe(200);
const polled = await readJson(pollResponse);
const event = polled.event as { event: string; payload: { id: string } };
expect(event.event).toBe("node.invoke.request");
const resultResponse = await fetch(`${baseUrl}/result`, {
method: "POST",
headers: {
authorization: `Bearer ${String(reconnected.sessionToken)}`,
"content-type": "application/json",
},
body: JSON.stringify({ id: event.payload.id, ok: true, payloadJSON: '{"model":"Watch"}' }),
});
expect(resultResponse.status).toBe(200);
await expect(invoke).resolves.toMatchObject({
ok: true,
payloadJSON: '{"model":"Watch"}',
});
const lateResultResponse = await fetch(`${baseUrl}/result`, {
method: "POST",
headers: {
authorization: `Bearer ${String(reconnected.sessionToken)}`,
"content-type": "application/json",
},
body: JSON.stringify({ id: event.payload.id, ok: true }),
});
expect(lateResultResponse.status).toBe(200);
await expect(readJson(lateResultResponse)).resolves.toEqual({ ok: true, ignored: true });
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({ ok: true });
runtime.invalidateSessionsForDevice(identity.deviceId, {
role: "node",
reason: "device-token-revoked",
});
await expect(nodeRegistry.checkConnectivity(identity.deviceId)).resolves.toEqual({
ok: false,
error: { code: "NOT_CONNECTED", message: "device-token-revoked" },
});
runtime.disconnectSessionsForDevice(identity.deviceId, { role: "node" });
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({
nodeId: identity.deviceId,
reason: "device-token-revoked",
});
const invalidatedPollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(reconnected.sessionToken)}` },
});
expect(invalidatedPollResponse.status).toBe(401);
const revoked = await revokeDeviceToken({
deviceId: identity.deviceId,
role: "node",
baseDir,
});
expect(revoked.ok).toBe(true);
const replacementBootstrap = await issueDeviceBootstrapToken({
baseDir,
profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replacementResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replacementChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
});
expect(replacementResponse.status).toBe(200);
const replacement = await readJson(replacementResponse);
expect(replacement.deviceToken).toEqual(expect.any(String));
expect(replacement.deviceToken).not.toBe(connected.deviceToken);
expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]);
const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`));
const replayResponse = await fetch(`${baseUrl}/connect`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(
makeConnectParams({
identity,
nonce: String(replayChallenge.nonce),
bootstrapToken: replacementBootstrap.token,
}),
),
});
expect(replayResponse.status).toBe(401);
const rawPayload = serializeEventPayload({ sequence: 1 });
expect(rawPayload).not.toBeNull();
expect(nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", rawPayload)).toBe(
true,
);
const rawPollResponse = await fetch(`${baseUrl}/poll`, {
method: "POST",
headers: { authorization: `Bearer ${String(replacement.sessionToken)}` },
});
expect(rawPollResponse.status).toBe(200);
await expect(readJson(rawPollResponse)).resolves.toMatchObject({
ok: true,
event: { event: "node.invoke.request", payload: { sequence: 1 } },
});
const oversizedPayload = serializeEventPayload({ value: "x".repeat(70 * 1024) });
expect(oversizedPayload).not.toBeNull();
expect(
nodeRegistry.sendEventRaw(identity.deviceId, "node.invoke.request", oversizedPayload),
).toBe(false);
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
expect(disconnectedNodes).toContainEqual({
nodeId: identity.deviceId,
reason: "event payload too large",
});
runtime.close();
expect(nodeRegistry.get(identity.deviceId)).toBeUndefined();
});
});