import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { approveNodePairing, listNodePairing } from "../node-pairing.js"; import { startNodeBridgeServer } from "./server.js"; function 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; } function sendLine(socket: net.Socket, obj: unknown) { socket.write(`${JSON.stringify(obj)}\n`); } describe("node bridge server", () => { let baseDir = ""; beforeAll(async () => { process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1"; baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-")); }); afterAll(async () => { await fs.rm(baseDir, { recursive: true, force: true }); delete process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS; }); it("rejects hello when not paired", async () => { const server = await startNodeBridgeServer({ host: "127.0.0.1", port: 0, pairingBaseDir: baseDir, }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); const readLine = createLineReader(socket); sendLine(socket, { type: "hello", nodeId: "n1" }); const line = await readLine(); const msg = JSON.parse(line) as { type: string; code?: string }; expect(msg.type).toBe("error"); expect(msg.code).toBe("NOT_PAIRED"); socket.destroy(); await server.close(); }); it("pairs after approval and then accepts hello", async () => { const server = await startNodeBridgeServer({ host: "127.0.0.1", port: 0, pairingBaseDir: baseDir, }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); const readLine = createLineReader(socket); sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" }); // Approve the pending request from the gateway side. let reqId: string | undefined; for (let i = 0; i < 40; i += 1) { const list = await listNodePairing(baseDir); const req = list.pending.find((p) => p.nodeId === "n2"); if (req) { reqId = req.requestId; break; } await new Promise((r) => setTimeout(r, 25)); } expect(reqId).toBeTruthy(); if (!reqId) throw new Error("expected a pending requestId"); await approveNodePairing(reqId, baseDir); const line1 = JSON.parse(await readLine()) as { type: string; token?: string; }; expect(line1.type).toBe("pair-ok"); expect(typeof line1.token).toBe("string"); if (!line1.token) throw new Error("expected pair-ok token"); const token = line1.token; const line2 = JSON.parse(await readLine()) as { type: string }; expect(line2.type).toBe("hello-ok"); socket.destroy(); const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); const readLine2 = createLineReader(socket2); sendLine(socket2, { type: "hello", nodeId: "n2", token }); const line3 = JSON.parse(await readLine2()) as { type: string }; expect(line3.type).toBe("hello-ok"); socket2.destroy(); await server.close(); }); it("calls onPairRequested for newly created pending requests", async () => { let requested: { nodeId?: string; requestId?: string } | null = null; const server = await startNodeBridgeServer({ host: "127.0.0.1", port: 0, pairingBaseDir: baseDir, onPairRequested: async (req) => { requested = req; }, }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" }); for (let i = 0; i < 40; i += 1) { if (requested) break; await new Promise((r) => setTimeout(r, 25)); } expect(requested?.nodeId).toBe("n3"); expect(typeof requested?.requestId).toBe("string"); socket.destroy(); await server.close(); }); it("passes node metadata to onAuthenticated and onDisconnected", async () => { let lastAuthed: { nodeId?: string; displayName?: string; platform?: string; version?: string; remoteIp?: string; } | null = null; let disconnected: { nodeId?: string; displayName?: string; platform?: string; version?: string; remoteIp?: string; } | null = null; let resolveDisconnected: (() => void) | null = null; const disconnectedP = new Promise((resolve) => { resolveDisconnected = resolve; }); const server = await startNodeBridgeServer({ host: "127.0.0.1", port: 0, pairingBaseDir: baseDir, onAuthenticated: async (node) => { lastAuthed = node; }, onDisconnected: async (node) => { disconnected = node; resolveDisconnected?.(); }, }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); const readLine = createLineReader(socket); sendLine(socket, { type: "pair-request", nodeId: "n4", displayName: "Iris", platform: "ios", version: "1.0", }); // Approve the pending request from the gateway side. let reqId: string | undefined; for (let i = 0; i < 40; i += 1) { const list = await listNodePairing(baseDir); const req = list.pending.find((p) => p.nodeId === "n4"); if (req) { reqId = req.requestId; break; } await new Promise((r) => setTimeout(r, 25)); } expect(reqId).toBeTruthy(); if (!reqId) throw new Error("expected a pending requestId"); const approved = await approveNodePairing(reqId, baseDir); const token = approved?.node?.token ?? ""; expect(token.length).toBeGreaterThan(0); const line1 = JSON.parse(await readLine()) as { type: string }; expect(line1.type).toBe("pair-ok"); const line2 = JSON.parse(await readLine()) as { type: string }; expect(line2.type).toBe("hello-ok"); socket.destroy(); const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); const readLine2 = createLineReader(socket2); sendLine(socket2, { type: "hello", nodeId: "n4", token, displayName: "Different name", platform: "ios", version: "2.0", }); const line3 = JSON.parse(await readLine2()) as { type: string }; expect(line3.type).toBe("hello-ok"); for (let i = 0; i < 40; i += 1) { if (lastAuthed?.nodeId === "n4") break; await new Promise((r) => setTimeout(r, 25)); } expect(lastAuthed?.nodeId).toBe("n4"); // Prefer paired metadata over hello payload (token verifies the stored node record). expect(lastAuthed?.displayName).toBe("Iris"); expect(lastAuthed?.platform).toBe("ios"); expect(lastAuthed?.version).toBe("1.0"); expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true); socket2.destroy(); await disconnectedP; expect(disconnected?.nodeId).toBe("n4"); expect(disconnected?.remoteIp?.includes("127.0.0.1")).toBe(true); await server.close(); }); it("supports invoke roundtrip to a connected node", async () => { const server = await startNodeBridgeServer({ host: "127.0.0.1", port: 0, pairingBaseDir: baseDir, }); const socket = net.connect({ host: "127.0.0.1", port: server.port }); const readLine = createLineReader(socket); sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" }); // Approve the pending request from the gateway side. let reqId: string | undefined; for (let i = 0; i < 40; i += 1) { const list = await listNodePairing(baseDir); const req = list.pending.find((p) => p.nodeId === "n5"); if (req) { reqId = req.requestId; break; } await new Promise((r) => setTimeout(r, 25)); } expect(reqId).toBeTruthy(); if (!reqId) throw new Error("expected a pending requestId"); await approveNodePairing(reqId, baseDir); const pairOk = JSON.parse(await readLine()) as { type: string; token?: string; }; expect(pairOk.type).toBe("pair-ok"); expect(typeof pairOk.token).toBe("string"); if (!pairOk.token) throw new Error("expected pair-ok token"); const token = pairOk.token; const helloOk = JSON.parse(await readLine()) as { type: string }; expect(helloOk.type).toBe("hello-ok"); const responder = (async () => { while (true) { const frame = JSON.parse(await readLine()) as { type: string; id?: string; command?: string; }; if (frame.type !== "invoke") continue; sendLine(socket, { type: "invoke-res", id: frame.id, ok: true, payloadJSON: JSON.stringify({ echo: frame.command }), }); break; } })(); const res = await server.invoke({ nodeId: "n5", command: "screen.eval", paramsJSON: JSON.stringify({ javaScript: "1+1" }), timeoutMs: 3000, }); expect(res.ok).toBe(true); const payload = JSON.parse(String(res.payloadJSON ?? "null")) as { echo?: string; }; expect(payload.echo).toBe("screen.eval"); await responder; socket.destroy(); // Ensure invoke works only for connected nodes (hello with token on a new socket). const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); const readLine2 = createLineReader(socket2); sendLine(socket2, { type: "hello", nodeId: "n5", token }); const hello2 = JSON.parse(await readLine2()) as { type: string }; expect(hello2.type).toBe("hello-ok"); socket2.destroy(); await server.close(); }); });