From 6942ceb7a9bc55dde716fcb7d91cf406a890b40d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 10:39:29 +0000 Subject: [PATCH] test: update gateway node/e2e tests --- src/gateway/server.health.test.ts | 35 +++- src/gateway/server.nodes.allowlist.test.ts | 146 ++++++++------- test/gateway.multi.e2e.test.ts | 203 ++++++++------------- 3 files changed, 199 insertions(+), 185 deletions(-) diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 6ea099ba3..92f71e0e4 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -1,7 +1,14 @@ import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -13,6 +20,7 @@ import { startGatewayServer, startServerWithClient, } from "./test-helpers.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; installGatewayTestHooks(); @@ -201,8 +209,24 @@ describe("gateway server health/presence", () => { }); test("presence includes client fingerprint", async () => { + const identityPath = path.join(os.tmpdir(), `clawdbot-device-${randomUUID()}.json`); + const identity = loadOrCreateDeviceIdentity(identityPath); + const role = "operator"; + const scopes: string[] = []; + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.FINGERPRINT, + clientMode: GATEWAY_CLIENT_MODES.UI, + role, + scopes, + signedAtMs, + token: null, + }); const { server, ws } = await startServerWithClient(); await connectOk(ws, { + role, + scopes, client: { id: GATEWAY_CLIENT_NAMES.FINGERPRINT, version: "9.9.9", @@ -212,6 +236,12 @@ describe("gateway server health/presence", () => { mode: GATEWAY_CLIENT_MODES.UI, instanceId: "abc", }, + device: { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }, }); const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "fingerprint", 4000); @@ -224,9 +254,10 @@ describe("gateway server health/presence", () => { ); const presenceRes = await presenceP; - const identity = loadOrCreateDeviceIdentity(); const entries = presenceRes.payload as Array>; - const clientEntry = entries.find((e) => e.instanceId === identity.deviceId); + const clientEntry = entries.find( + (e) => e.host === GATEWAY_CLIENT_NAMES.FINGERPRINT && e.version === "9.9.9", + ); expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); diff --git a/src/gateway/server.nodes.allowlist.test.ts b/src/gateway/server.nodes.allowlist.test.ts index 02c46f444..c2b04ab25 100644 --- a/src/gateway/server.nodes.allowlist.test.ts +++ b/src/gateway/server.nodes.allowlist.test.ts @@ -1,32 +1,77 @@ import { describe, expect, test } from "vitest"; -import { WebSocket } from "ws"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { connectOk, installGatewayTestHooks, - onceMessage, rpcReq, startServerWithClient, } from "./test-helpers.js"; +import { GatewayClient } from "./client.js"; installGatewayTestHooks(); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const connectNodeClient = async (params: { + port: number; + commands: string[]; + instanceId?: string; + displayName?: string; + onEvent?: (evt: { event?: string; payload?: unknown }) => void; +}) => { + let settled = false; + let resolveReady: (() => void) | null = null; + let rejectReady: ((err: Error) => void) | null = null; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const client = new GatewayClient({ + url: `ws://127.0.0.1:${params.port}`, + role: "node", + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientVersion: "1.0.0", + clientDisplayName: params.displayName, + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + instanceId: params.instanceId, + scopes: [], + commands: params.commands, + onEvent: params.onEvent, + onHelloOk: () => { + if (settled) return; + settled = true; + resolveReady?.(); + }, + onConnectError: (err) => { + if (settled) return; + settled = true; + rejectReady?.(err); + }, + onClose: (code, reason) => { + if (settled) return; + settled = true; + rejectReady?.(new Error(`gateway closed (${code}): ${reason}`)); + }, + }); + client.start(); + await Promise.race([ + ready, + sleep(10_000).then(() => { + throw new Error("timeout waiting for node to connect"); + }), + ]); + return client; +}; + describe("gateway node command allowlist", () => { test("rejects commands outside platform allowlist", async () => { const { server, ws, port } = await startServerWithClient(); await connectOk(ws); - const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => nodeWs.once("open", resolve)); - await connectOk(nodeWs, { - role: "node", - client: { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - version: "1.0.0", - platform: "ios", - mode: GATEWAY_CLIENT_MODES.NODE, - }, + const nodeClient = await connectNodeClient({ + port, commands: ["system.run"], }); @@ -43,7 +88,7 @@ describe("gateway node command allowlist", () => { expect(res.ok).toBe(false); expect(res.error?.message).toContain("node command not allowed"); - nodeWs.close(); + nodeClient.stop(); ws.close(); await server.close(); }); @@ -52,19 +97,11 @@ describe("gateway node command allowlist", () => { const { server, ws, port } = await startServerWithClient(); await connectOk(ws); - const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => nodeWs.once("open", resolve)); - await connectOk(nodeWs, { - role: "node", - client: { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - displayName: "node-empty", - version: "1.0.0", - platform: "ios", - mode: GATEWAY_CLIENT_MODES.NODE, - instanceId: "node-empty", - }, + const nodeClient = await connectNodeClient({ + port, commands: [], + instanceId: "node-empty", + displayName: "node-empty", }); const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); @@ -80,7 +117,7 @@ describe("gateway node command allowlist", () => { expect(res.ok).toBe(false); expect(res.error?.message).toContain("node command not allowed"); - nodeWs.close(); + nodeClient.stop(); ws.close(); await server.close(); }); @@ -89,30 +126,27 @@ describe("gateway node command allowlist", () => { const { server, ws, port } = await startServerWithClient(); await connectOk(ws); - const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => nodeWs.once("open", resolve)); - await connectOk(nodeWs, { - role: "node", - client: { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - displayName: "node-allowed", - version: "1.0.0", - platform: "ios", - mode: GATEWAY_CLIENT_MODES.NODE, - instanceId: "node-allowed", - }, + let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null; + const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => { + resolveInvoke = resolve; + }); + const nodeClient = await connectNodeClient({ + port, commands: ["canvas.snapshot"], + instanceId: "node-allowed", + displayName: "node-allowed", + onEvent: (evt) => { + if (evt.event === "node.invoke.request") { + const payload = evt.payload as { id?: string; nodeId?: string }; + resolveInvoke?.(payload); + } + }, }); const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; expect(nodeId).toBeTruthy(); - const invokeReqP = onceMessage<{ type: "event"; event: string; payload?: unknown }>( - nodeWs, - (o) => o.type === "event" && o.event === "node.invoke.request", - ); - const invokeResP = rpcReq(ws, "node.invoke", { nodeId, command: "canvas.snapshot", @@ -120,31 +154,21 @@ describe("gateway node command allowlist", () => { idempotencyKey: "allowlist-3", }); - const invokeReq = await invokeReqP; - const payload = invokeReq.payload as { id?: string; nodeId?: string }; + const payload = await invokeReqP; const requestId = payload?.id ?? ""; const nodeIdFromReq = payload?.nodeId ?? "node-allowed"; - nodeWs.send( - JSON.stringify({ - type: "req", - id: "node-result", - method: "node.invoke.result", - params: { - id: requestId, - nodeId: nodeIdFromReq, - ok: true, - payloadJSON: JSON.stringify({ ok: true }), - }, - }), - ); - - await onceMessage(nodeWs, (o) => o.type === "res" && o.id === "node-result"); + await nodeClient.request("node.invoke.result", { + id: requestId, + nodeId: nodeIdFromReq, + ok: true, + payloadJSON: JSON.stringify({ ok: true }), + }); const invokeRes = await invokeResP; expect(invokeRes.ok).toBe(true); - nodeWs.close(); + nodeClient.stop(); ws.close(); await server.close(); }); diff --git a/test/gateway.multi.e2e.test.ts b/test/gateway.multi.e2e.test.ts index b3b33bf85..eb9dd1327 100644 --- a/test/gateway.multi.e2e.test.ts +++ b/test/gateway.multi.e2e.test.ts @@ -6,12 +6,13 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; -import { approveNodePairing, listNodePairing } from "../src/infra/node-pairing.js"; +import { loadOrCreateDeviceIdentity } from "../src/infra/device-identity.js"; +import { GatewayClient } from "../src/gateway/client.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../src/utils/message-channel.js"; type GatewayInstance = { name: string; port: number; - bridgePort: number; hookToken: string; gatewayToken: string; homeDir: string; @@ -28,10 +29,6 @@ type NodeListPayload = { type HealthPayload = { ok?: boolean }; -type PairingList = { - pending: Array<{ requestId: string; nodeId: string }>; -}; - const GATEWAY_START_TIMEOUT_MS = 45_000; const E2E_TIMEOUT_MS = 120_000; @@ -96,7 +93,6 @@ const waitForPortOpen = async ( const spawnGatewayInstance = async (name: string): Promise => { const port = await getFreePort(); - const bridgePort = await getFreePort(); const hookToken = `token-${name}-${randomUUID()}`; const gatewayToken = `gateway-${name}-${randomUUID()}`; const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-e2e-${name}-`)); @@ -107,7 +103,6 @@ const spawnGatewayInstance = async (name: string): Promise => { const config = { gateway: { port, auth: { mode: "token", token: gatewayToken } }, hooks: { enabled: true, token: hookToken, path: "/hooks" }, - bridge: { bind: "loopback", port: bridgePort }, }; await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); @@ -139,9 +134,6 @@ const spawnGatewayInstance = async (name: string): Promise => { CLAWDBOT_SKIP_CHANNELS: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1", - CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1", - CLAWDBOT_BRIDGE_HOST: "127.0.0.1", - CLAWDBOT_BRIDGE_PORT: String(bridgePort), }, stdio: ["ignore", "pipe", "pipe"], }, @@ -157,7 +149,6 @@ const spawnGatewayInstance = async (name: string): Promise => { return { name, port, - bridgePort, hookToken, gatewayToken, homeDir, @@ -278,105 +269,91 @@ const postJson = async (url: string, body: unknown) => { }); }; -const createLineReader = (socket: net.Socket) => { - let buffer = ""; - const pending: Array<(line: string) => void> = []; - - const flush = () => { - while (pending.length > 0) { - const idx = buffer.indexOf("\n"); - if (idx === -1) return; - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const resolve = pending.shift(); - resolve?.(line); - } - }; - - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - flush(); - }); - - const readLine = async () => { - flush(); - const idx = buffer.indexOf("\n"); - if (idx !== -1) { - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - return line; - } - return await new Promise((resolve) => pending.push(resolve)); - }; - - return readLine; -}; - -const sendLine = (socket: net.Socket, obj: unknown) => { - socket.write(`${JSON.stringify(obj)}\n`); -}; - -const readLineWithTimeout = async ( - readLine: () => Promise, +const connectNode = async ( + inst: GatewayInstance, label: string, - timeoutMs = 10_000, -) => { - const timer = sleep(timeoutMs).then(() => { - throw new Error(`timeout waiting for ${label}`); +): Promise<{ client: GatewayClient; nodeId: string }> => { + const identityPath = path.join(inst.homeDir, `${label}-device.json`); + const deviceIdentity = loadOrCreateDeviceIdentity(identityPath); + const nodeId = deviceIdentity.deviceId; + let settled = false; + let resolveReady: (() => void) | null = null; + let rejectReady: ((err: Error) => void) | null = null; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; }); - return await Promise.race([readLine(), timer]); + + const client = new GatewayClient({ + url: `ws://127.0.0.1:${inst.port}`, + token: inst.gatewayToken, + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: label, + clientVersion: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + role: "node", + scopes: [], + caps: ["system"], + commands: ["system.run"], + deviceIdentity, + onHelloOk: () => { + if (settled) return; + settled = true; + resolveReady?.(); + }, + onConnectError: (err) => { + if (settled) return; + settled = true; + rejectReady?.(err); + }, + onClose: (code, reason) => { + if (settled) return; + settled = true; + rejectReady?.(new Error(`gateway closed (${code}): ${reason}`)); + }, + }); + + client.start(); + try { + await Promise.race([ + ready, + sleep(10_000).then(() => { + throw new Error(`timeout waiting for ${label} to connect`); + }), + ]); + } catch (err) { + client.stop(); + throw err; + } + return { client, nodeId }; }; -const waitForPairRequest = async (baseDir: string, nodeId: string, timeoutMs = 10_000) => { +const waitForNodeStatus = async (inst: GatewayInstance, nodeId: string, timeoutMs = 10_000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const list = (await listNodePairing(baseDir)) as PairingList; - const match = list.pending.find((p) => p.nodeId === nodeId); - if (match?.requestId) return match.requestId; + const list = (await runCliJson( + ["nodes", "status", "--json", "--url", `ws://127.0.0.1:${inst.port}`], + { + CLAWDBOT_GATEWAY_TOKEN: inst.gatewayToken, + CLAWDBOT_GATEWAY_PASSWORD: "", + }, + )) as NodeListPayload; + const match = list.nodes?.find((n) => n.nodeId === nodeId); + if (match?.connected && match?.paired) return; await sleep(50); } - throw new Error(`timeout waiting for pairing request for ${nodeId}`); -}; - -const pairNode = async (inst: GatewayInstance, nodeId: string) => { - const socket = net.connect({ host: "127.0.0.1", port: inst.bridgePort }); - await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); - }); - - const readLine = createLineReader(socket); - sendLine(socket, { - type: "pair-request", - nodeId, - platform: "ios", - version: "1.0.0", - }); - - const baseDir = inst.stateDir; - const requestId = await waitForPairRequest(baseDir, nodeId); - const approved = await approveNodePairing(requestId, baseDir); - expect(approved).toBeTruthy(); - - const pairLine = JSON.parse(await readLineWithTimeout(readLine, `pair-ok (${nodeId})`)) as { - type?: string; - token?: string; - }; - expect(pairLine.type).toBe("pair-ok"); - expect(pairLine.token).toBeTruthy(); - - const helloLine = JSON.parse(await readLineWithTimeout(readLine, `hello-ok (${nodeId})`)) as { - type?: string; - }; - expect(helloLine.type).toBe("hello-ok"); - - return socket; + throw new Error(`timeout waiting for node status for ${nodeId}`); }; describe("gateway multi-instance e2e", () => { const instances: GatewayInstance[] = []; + const nodeClients: GatewayClient[] = []; afterAll(async () => { + for (const client of nodeClients) { + client.stop(); + } for (const inst of instances) { await stopGatewayInstance(inst); } @@ -421,32 +398,14 @@ describe("gateway multi-instance e2e", () => { expect(hookResB.status).toBe(200); expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true); - const nodeASocket = await pairNode(gwA, "node-a"); - const nodeBSocket = await pairNode(gwB, "node-b"); + const nodeA = await connectNode(gwA, "node-a"); + const nodeB = await connectNode(gwB, "node-b"); + nodeClients.push(nodeA.client, nodeB.client); - const [nodeListA, nodeListB] = (await Promise.all([ - runCliJson(["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwA.port}`], { - CLAWDBOT_GATEWAY_TOKEN: gwA.gatewayToken, - CLAWDBOT_GATEWAY_PASSWORD: "", - }), - runCliJson(["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwB.port}`], { - CLAWDBOT_GATEWAY_TOKEN: gwB.gatewayToken, - CLAWDBOT_GATEWAY_PASSWORD: "", - }), - ])) as [NodeListPayload, NodeListPayload]; - expect( - nodeListA.nodes?.some( - (n) => n.nodeId === "node-a" && n.connected === true && n.paired === true, - ), - ).toBe(true); - expect( - nodeListB.nodes?.some( - (n) => n.nodeId === "node-b" && n.connected === true && n.paired === true, - ), - ).toBe(true); - - nodeASocket.destroy(); - nodeBSocket.destroy(); + await Promise.all([ + waitForNodeStatus(gwA, nodeA.nodeId), + waitForNodeStatus(gwB, nodeB.nodeId), + ]); }, ); });