refactor(src): split oversized modules
This commit is contained in:
BIN
src/infra/.DS_Store
vendored
Normal file
BIN
src/infra/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -411,152 +411,4 @@ describe("node bridge server", () => {
|
||||
|
||||
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 });
|
||||
await waitForSocketConnect(socket);
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" });
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
const pending = await pollUntil(
|
||||
async () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n5");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, 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: "canvas.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("canvas.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 });
|
||||
await waitForSocketConnect(socket2);
|
||||
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();
|
||||
});
|
||||
|
||||
it("tracks connected node caps and hardware identifiers", 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 });
|
||||
await waitForSocketConnect(socket);
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, {
|
||||
type: "pair-request",
|
||||
nodeId: "n-caps",
|
||||
displayName: "Node",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad14,5",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
permissions: { accessibility: true },
|
||||
});
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
const pending = await pollUntil(
|
||||
async () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n-caps");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, baseDir);
|
||||
|
||||
const pairOk = JSON.parse(await readLine()) as { type: string };
|
||||
expect(pairOk.type).toBe("pair-ok");
|
||||
const helloOk = JSON.parse(await readLine()) as { type: string };
|
||||
expect(helloOk.type).toBe("hello-ok");
|
||||
|
||||
const connected = server.listConnected();
|
||||
const node = connected.find((n) => n.nodeId === "n-caps");
|
||||
expect(node?.deviceFamily).toBe("iPad");
|
||||
expect(node?.modelIdentifier).toBe("iPad14,5");
|
||||
expect(node?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(node?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(node?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
const after = await listNodePairing(baseDir);
|
||||
const paired = after.paired.find((p) => p.nodeId === "n-caps");
|
||||
expect(paired?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(paired?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(paired?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
socket.destroy();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
229
src/infra/bridge/server.part-2.test.ts
Normal file
229
src/infra/bridge/server.part-2.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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 { pollUntil } from "../../../test/helpers/poll.js";
|
||||
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<string>((resolve) => pending.push(resolve));
|
||||
};
|
||||
|
||||
return readLine;
|
||||
}
|
||||
|
||||
function sendLine(socket: net.Socket, obj: unknown) {
|
||||
socket.write(`${JSON.stringify(obj)}\n`);
|
||||
}
|
||||
|
||||
async function waitForSocketConnect(socket: net.Socket) {
|
||||
if (!socket.connecting) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("node bridge server", () => {
|
||||
let baseDir = "";
|
||||
|
||||
const _pickNonLoopbackIPv4 = () => {
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const entries of Object.values(ifaces)) {
|
||||
for (const info of entries ?? []) {
|
||||
if (info.family === "IPv4" && info.internal === false)
|
||||
return info.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS = "1";
|
||||
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bridge-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
delete process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS;
|
||||
});
|
||||
|
||||
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 });
|
||||
await waitForSocketConnect(socket);
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" });
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
const pending = await pollUntil(
|
||||
async () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n5");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, 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: "canvas.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("canvas.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 });
|
||||
await waitForSocketConnect(socket2);
|
||||
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();
|
||||
});
|
||||
|
||||
it("tracks connected node caps and hardware identifiers", 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 });
|
||||
await waitForSocketConnect(socket);
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, {
|
||||
type: "pair-request",
|
||||
nodeId: "n-caps",
|
||||
displayName: "Node",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad14,5",
|
||||
caps: ["canvas", "camera"],
|
||||
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
|
||||
permissions: { accessibility: true },
|
||||
});
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
const pending = await pollUntil(
|
||||
async () => {
|
||||
const list = await listNodePairing(baseDir);
|
||||
return list.pending.find((p) => p.nodeId === "n-caps");
|
||||
},
|
||||
{ timeoutMs: 3000 },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, baseDir);
|
||||
|
||||
const pairOk = JSON.parse(await readLine()) as { type: string };
|
||||
expect(pairOk.type).toBe("pair-ok");
|
||||
const helloOk = JSON.parse(await readLine()) as { type: string };
|
||||
expect(helloOk.type).toBe("hello-ok");
|
||||
|
||||
const connected = server.listConnected();
|
||||
const node = connected.find((n) => n.nodeId === "n-caps");
|
||||
expect(node?.deviceFamily).toBe("iPad");
|
||||
expect(node?.modelIdentifier).toBe("iPad14,5");
|
||||
expect(node?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(node?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(node?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
const after = await listNodePairing(baseDir);
|
||||
const paired = after.paired.find((p) => p.nodeId === "n-caps");
|
||||
expect(paired?.caps).toEqual(["canvas", "camera"]);
|
||||
expect(paired?.commands).toEqual([
|
||||
"canvas.eval",
|
||||
"canvas.snapshot",
|
||||
"camera.snap",
|
||||
]);
|
||||
expect(paired?.permissions).toEqual({ accessibility: true });
|
||||
|
||||
socket.destroy();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -1,814 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
|
||||
import { resolveCanvasHostUrl } from "../canvas-host-url.js";
|
||||
import {
|
||||
getPairedNode,
|
||||
listNodePairing,
|
||||
type NodePairingPendingRequest,
|
||||
requestNodePairing,
|
||||
updatePairedNodeMetadata,
|
||||
verifyNodeToken,
|
||||
} from "../node-pairing.js";
|
||||
|
||||
type BridgeHelloFrame = {
|
||||
type: "hello";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
token?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
type BridgePairRequestFrame = {
|
||||
type: "pair-request";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
remoteAddress?: string;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
type BridgeEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
};
|
||||
|
||||
type BridgeRPCRequestFrame = {
|
||||
type: "req";
|
||||
id: string;
|
||||
method: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
type BridgeRPCResponseFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code: string; message: string; details?: unknown } | null;
|
||||
};
|
||||
|
||||
type BridgePingFrame = { type: "ping"; id: string };
|
||||
type BridgePongFrame = { type: "pong"; id: string };
|
||||
|
||||
type BridgeInvokeRequestFrame = {
|
||||
type: "invoke";
|
||||
id: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
type BridgeInvokeResponseFrame = {
|
||||
type: "invoke-res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code: string; message: string } | null;
|
||||
};
|
||||
|
||||
type BridgeHelloOkFrame = {
|
||||
type: "hello-ok";
|
||||
serverName: string;
|
||||
canvasHostUrl?: string;
|
||||
};
|
||||
type BridgePairOkFrame = { type: "pair-ok"; token: string };
|
||||
type BridgeErrorFrame = { type: "error"; code: string; message: string };
|
||||
|
||||
type AnyBridgeFrame =
|
||||
| BridgeHelloFrame
|
||||
| BridgePairRequestFrame
|
||||
| BridgeEventFrame
|
||||
| BridgeRPCRequestFrame
|
||||
| BridgeRPCResponseFrame
|
||||
| BridgePingFrame
|
||||
| BridgePongFrame
|
||||
| BridgeInvokeRequestFrame
|
||||
| BridgeInvokeResponseFrame
|
||||
| BridgeHelloOkFrame
|
||||
| BridgePairOkFrame
|
||||
| BridgeErrorFrame
|
||||
| { type: string; [k: string]: unknown };
|
||||
|
||||
export type NodeBridgeServer = {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
invoke: (opts: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
timeoutMs?: number;
|
||||
}) => Promise<BridgeInvokeResponseFrame>;
|
||||
sendEvent: (opts: {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
}) => void;
|
||||
listConnected: () => NodeBridgeClientInfo[];
|
||||
listeners: Array<{ host: string; port: number }>;
|
||||
};
|
||||
|
||||
export type NodeBridgeClientInfo = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
pairingBaseDir?: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
||||
onRequest?: (
|
||||
nodeId: string,
|
||||
req: BridgeRPCRequestFrame,
|
||||
) => Promise<
|
||||
| { ok: true; payloadJSON?: string | null }
|
||||
| { ok: false; error: { code: string; message: string; details?: unknown } }
|
||||
>;
|
||||
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onPairRequested?: (
|
||||
request: NodePairingPendingRequest,
|
||||
) => Promise<void> | void;
|
||||
serverName?: string;
|
||||
};
|
||||
|
||||
function isTestEnv() {
|
||||
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
|
||||
}
|
||||
|
||||
export function configureNodeBridgeSocket(socket: {
|
||||
setNoDelay: (noDelay?: boolean) => void;
|
||||
setKeepAlive: (enable?: boolean, initialDelay?: number) => void;
|
||||
}) {
|
||||
socket.setNoDelay(true);
|
||||
socket.setKeepAlive(true, 15_000);
|
||||
}
|
||||
|
||||
function encodeLine(frame: AnyBridgeFrame) {
|
||||
return `${JSON.stringify(frame)}\n`;
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function startNodeBridgeServer(
|
||||
opts: NodeBridgeServerOpts,
|
||||
): Promise<NodeBridgeServer> {
|
||||
if (isTestEnv() && process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1") {
|
||||
return {
|
||||
port: 0,
|
||||
close: async () => {},
|
||||
invoke: async () => {
|
||||
throw new Error("bridge disabled in tests");
|
||||
},
|
||||
sendEvent: () => {},
|
||||
listConnected: () => [],
|
||||
listeners: [],
|
||||
};
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof opts.serverName === "string" && opts.serverName.trim()
|
||||
? opts.serverName.trim()
|
||||
: os.hostname();
|
||||
|
||||
const buildCanvasHostUrl = (socket: net.Socket) => {
|
||||
return resolveCanvasHostUrl({
|
||||
canvasPort: opts.canvasHostPort,
|
||||
hostOverride: opts.canvasHostHost,
|
||||
localAddress: socket.localAddress,
|
||||
scheme: "http",
|
||||
});
|
||||
};
|
||||
|
||||
type ConnectionState = {
|
||||
socket: net.Socket;
|
||||
nodeInfo: NodeBridgeClientInfo;
|
||||
invokeWaiters: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const connections = new Map<string, ConnectionState>();
|
||||
|
||||
const shouldAlsoListenOnLoopback = (host: string | undefined) => {
|
||||
const h = String(host ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!h) return false; // default listen() already includes loopback
|
||||
if (h === "0.0.0.0" || h === "::") return false; // already includes loopback
|
||||
if (h === "localhost") return false;
|
||||
if (h === "127.0.0.1" || h.startsWith("127.")) return false;
|
||||
if (h === "::1") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
const onConnection = (socket: net.Socket) => {
|
||||
configureNodeBridgeSocket(socket);
|
||||
|
||||
let buffer = "";
|
||||
let isAuthenticated = false;
|
||||
let nodeId: string | null = null;
|
||||
let nodeInfo: NodeBridgeClientInfo | null = null;
|
||||
const invokeWaiters = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
|
||||
const abort = new AbortController();
|
||||
const stop = () => {
|
||||
if (!abort.signal.aborted) abort.abort();
|
||||
for (const [, waiter] of invokeWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error("bridge connection closed"));
|
||||
}
|
||||
invokeWaiters.clear();
|
||||
if (nodeId) {
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket === socket) connections.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (frame: AnyBridgeFrame) => {
|
||||
try {
|
||||
socket.write(encodeLine(frame));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const sendError = (code: string, message: string) => {
|
||||
send({ type: "error", code, message } satisfies BridgeErrorFrame);
|
||||
};
|
||||
|
||||
const remoteAddress = (() => {
|
||||
const addr = socket.remoteAddress?.trim();
|
||||
return addr && addr.length > 0 ? addr : undefined;
|
||||
})();
|
||||
|
||||
const handleHello = async (hello: BridgeHelloFrame) => {
|
||||
nodeId = String(hello.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = typeof hello.token === "string" ? hello.token.trim() : "";
|
||||
if (!token) {
|
||||
const paired = await getPairedNode(nodeId, opts.pairingBaseDir);
|
||||
sendError(paired ? "UNAUTHORIZED" : "NOT_PAIRED", "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await verifyNodeToken(
|
||||
nodeId,
|
||||
token,
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
if (!verified.ok || !verified.node) {
|
||||
sendError("UNAUTHORIZED", "invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
const inferCaps = (frame: {
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
}): string[] | undefined => {
|
||||
const platform = String(frame.platform ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const family = String(frame.deviceFamily ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (platform.includes("ios") || platform.includes("ipados")) {
|
||||
return ["canvas", "camera"];
|
||||
}
|
||||
if (platform.includes("android")) {
|
||||
return ["canvas", "camera"];
|
||||
}
|
||||
if (family === "ipad" || family === "iphone" || family === "ios") {
|
||||
return ["canvas", "camera"];
|
||||
}
|
||||
if (family === "android") {
|
||||
return ["canvas", "camera"];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizePermissions = (
|
||||
raw: unknown,
|
||||
): Record<string, boolean> | undefined => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
||||
return undefined;
|
||||
const entries = Object.entries(raw as Record<string, unknown>)
|
||||
.map(([key, value]) => [String(key).trim(), value === true] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
const caps =
|
||||
(Array.isArray(hello.caps)
|
||||
? hello.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined) ??
|
||||
verified.node.caps ??
|
||||
inferCaps(hello);
|
||||
|
||||
const commands =
|
||||
Array.isArray(hello.commands) && hello.commands.length > 0
|
||||
? hello.commands.map((c) => String(c)).filter(Boolean)
|
||||
: verified.node.commands;
|
||||
const helloPermissions = normalizePermissions(hello.permissions);
|
||||
const basePermissions = verified.node.permissions ?? {};
|
||||
const permissions = helloPermissions
|
||||
? { ...basePermissions, ...helloPermissions }
|
||||
: verified.node.permissions;
|
||||
|
||||
isAuthenticated = true;
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: verified.node.displayName ?? hello.displayName,
|
||||
platform: verified.node.platform ?? hello.platform,
|
||||
version: verified.node.version ?? hello.version,
|
||||
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
|
||||
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
|
||||
caps,
|
||||
commands,
|
||||
permissions,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
await updatePairedNodeMetadata(
|
||||
nodeId,
|
||||
{
|
||||
displayName: nodeInfo.displayName,
|
||||
platform: nodeInfo.platform,
|
||||
version: nodeInfo.version,
|
||||
deviceFamily: nodeInfo.deviceFamily,
|
||||
modelIdentifier: nodeInfo.modelIdentifier,
|
||||
remoteIp: nodeInfo.remoteIp,
|
||||
caps: nodeInfo.caps,
|
||||
commands: nodeInfo.commands,
|
||||
permissions: nodeInfo.permissions,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({
|
||||
type: "hello-ok",
|
||||
serverName,
|
||||
canvasHostUrl: buildCanvasHostUrl(socket),
|
||||
} satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
};
|
||||
|
||||
const waitForApproval = async (request: {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
ts: number;
|
||||
isRepair?: boolean;
|
||||
}): Promise<
|
||||
{ ok: true; token: string } | { ok: false; reason: string }
|
||||
> => {
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
while (!abort.signal.aborted && Date.now() < deadline) {
|
||||
const list = await listNodePairing(opts.pairingBaseDir);
|
||||
const stillPending = list.pending.some(
|
||||
(p) => p.requestId === request.requestId,
|
||||
);
|
||||
if (stillPending) {
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paired = await getPairedNode(request.nodeId, opts.pairingBaseDir);
|
||||
if (!paired) return { ok: false, reason: "pairing rejected" };
|
||||
|
||||
// For a repair, ensure this approval happened after the request was created.
|
||||
if (paired.approvedAtMs < request.ts) {
|
||||
return { ok: false, reason: "pairing rejected" };
|
||||
}
|
||||
|
||||
return { ok: true, token: paired.token };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: abort.signal.aborted ? "disconnected" : "pairing expired",
|
||||
};
|
||||
};
|
||||
|
||||
const handlePairRequest = async (req: BridgePairRequestFrame) => {
|
||||
nodeId = String(req.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestNodePairing(
|
||||
{
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: Array.isArray(req.caps)
|
||||
? req.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
commands: Array.isArray(req.commands)
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
permissions:
|
||||
req.permissions && typeof req.permissions === "object"
|
||||
? (req.permissions as Record<string, boolean>)
|
||||
: undefined,
|
||||
remoteIp: remoteAddress,
|
||||
silent: req.silent === true ? true : undefined,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
if (result.created) {
|
||||
await opts.onPairRequested?.(result.request);
|
||||
}
|
||||
|
||||
const wait = await waitForApproval(result.request);
|
||||
if (!wait.ok) {
|
||||
sendError("UNAUTHORIZED", wait.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: Array.isArray(req.caps)
|
||||
? req.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
commands: Array.isArray(req.commands)
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
permissions:
|
||||
req.permissions && typeof req.permissions === "object"
|
||||
? (req.permissions as Record<string, boolean>)
|
||||
: undefined,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
|
||||
send({
|
||||
type: "hello-ok",
|
||||
serverName,
|
||||
canvasHostUrl: buildCanvasHostUrl(socket),
|
||||
} satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
};
|
||||
|
||||
const handleEvent = async (evt: BridgeEventFrame) => {
|
||||
if (!isAuthenticated || !nodeId) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
return;
|
||||
}
|
||||
await opts.onEvent?.(nodeId, evt);
|
||||
};
|
||||
|
||||
const handleRequest = async (req: BridgeRPCRequestFrame) => {
|
||||
if (!isAuthenticated || !nodeId) {
|
||||
send({
|
||||
type: "res",
|
||||
id: String(req.id ?? ""),
|
||||
ok: false,
|
||||
error: { code: "UNAUTHORIZED", message: "not authenticated" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.onRequest) {
|
||||
send({
|
||||
type: "res",
|
||||
id: String(req.id ?? ""),
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "RPC not supported" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = String(req.id ?? "");
|
||||
const method = String(req.method ?? "");
|
||||
if (!id || !method) {
|
||||
send({
|
||||
type: "res",
|
||||
id: id || "invalid",
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "id and method required" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await opts.onRequest(nodeId, {
|
||||
type: "req",
|
||||
id,
|
||||
method,
|
||||
paramsJSON: req.paramsJSON ?? null,
|
||||
});
|
||||
if (result.ok) {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: true,
|
||||
payloadJSON: result.payloadJSON ?? null,
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
} else {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: false,
|
||||
error: result.error,
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
}
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: String(err) },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx === -1) break;
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
void (async () => {
|
||||
let frame: AnyBridgeFrame;
|
||||
try {
|
||||
frame = JSON.parse(trimmed) as AnyBridgeFrame;
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof frame.type === "string" ? frame.type : "";
|
||||
try {
|
||||
switch (type) {
|
||||
case "hello":
|
||||
await handleHello(frame as BridgeHelloFrame);
|
||||
break;
|
||||
case "pair-request":
|
||||
await handlePairRequest(frame as BridgePairRequestFrame);
|
||||
break;
|
||||
case "event":
|
||||
await handleEvent(frame as BridgeEventFrame);
|
||||
break;
|
||||
case "req":
|
||||
await handleRequest(frame as BridgeRPCRequestFrame);
|
||||
break;
|
||||
case "ping": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const ping = frame as BridgePingFrame;
|
||||
send({
|
||||
type: "pong",
|
||||
id: String(ping.id ?? ""),
|
||||
} satisfies BridgePongFrame);
|
||||
break;
|
||||
}
|
||||
case "invoke-res": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const res = frame as BridgeInvokeResponseFrame;
|
||||
const waiter = invokeWaiters.get(res.id);
|
||||
if (waiter) {
|
||||
invokeWaiters.delete(res.id);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(res);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "invoke": {
|
||||
// Direction is gateway -> node only.
|
||||
sendError("INVALID_REQUEST", "invoke not allowed from node");
|
||||
break;
|
||||
}
|
||||
case "res":
|
||||
// Direction is node -> gateway only.
|
||||
sendError("INVALID_REQUEST", "res not allowed from node");
|
||||
break;
|
||||
case "pong":
|
||||
// ignore
|
||||
break;
|
||||
default:
|
||||
sendError("INVALID_REQUEST", "unknown type");
|
||||
}
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
const info = nodeInfo;
|
||||
stop();
|
||||
if (info && isAuthenticated) void opts.onDisconnected?.(info);
|
||||
});
|
||||
socket.on("error", () => {
|
||||
// close handler will run after close
|
||||
});
|
||||
};
|
||||
|
||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||
const primary = net.createServer(onConnection);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
primary.once("error", onError);
|
||||
primary.listen(opts.port, opts.host, () => {
|
||||
primary.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
listeners.push({
|
||||
host: String(opts.host ?? "").trim() || "(default)",
|
||||
server: primary,
|
||||
});
|
||||
|
||||
const address = primary.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
if (shouldAlsoListenOnLoopback(opts.host)) {
|
||||
const loopback = net.createServer(onConnection);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
loopback.once("error", onError);
|
||||
loopback.listen(port, loopbackHost, () => {
|
||||
loopback.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
listeners.push({ host: loopbackHost, server: loopback });
|
||||
} catch {
|
||||
try {
|
||||
loopback.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: async () => {
|
||||
for (const sock of connections.values()) {
|
||||
try {
|
||||
sock.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
connections.clear();
|
||||
await Promise.all(
|
||||
listeners.map(
|
||||
(l) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
l.server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
|
||||
listeners: listeners.map((l) => ({ host: l.host, port })),
|
||||
sendEvent: ({ nodeId, event, payloadJSON }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedEvent = String(event ?? "").trim();
|
||||
if (!normalizedNodeId || !normalizedEvent) return;
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn) return;
|
||||
try {
|
||||
conn.socket.write(
|
||||
encodeLine({
|
||||
type: "event",
|
||||
event: normalizedEvent,
|
||||
payloadJSON: payloadJSON ?? null,
|
||||
} satisfies BridgeEventFrame),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedCommand = String(command ?? "").trim();
|
||||
if (!normalizedNodeId) {
|
||||
throw new Error("INVALID_REQUEST: nodeId required");
|
||||
}
|
||||
if (!normalizedCommand) {
|
||||
throw new Error("INVALID_REQUEST: command required");
|
||||
}
|
||||
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn) {
|
||||
throw new Error(
|
||||
`UNAVAILABLE: node not connected (${normalizedNodeId})`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;
|
||||
|
||||
return await new Promise<BridgeInvokeResponseFrame>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
conn.invokeWaiters.delete(id);
|
||||
reject(new Error("UNAVAILABLE: invoke timeout"));
|
||||
},
|
||||
Math.max(0, timeout),
|
||||
);
|
||||
|
||||
conn.invokeWaiters.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
conn.socket.write(
|
||||
encodeLine({
|
||||
type: "invoke",
|
||||
id,
|
||||
command: normalizedCommand,
|
||||
paramsJSON: paramsJSON ?? null,
|
||||
} satisfies BridgeInvokeRequestFrame),
|
||||
);
|
||||
} catch (err) {
|
||||
conn.invokeWaiters.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
export { configureNodeBridgeSocket } from "./server/socket.js";
|
||||
export { startNodeBridgeServer } from "./server/start.js";
|
||||
export type {
|
||||
BridgeEventFrame,
|
||||
BridgeInvokeResponseFrame,
|
||||
BridgeRPCRequestFrame,
|
||||
NodeBridgeClientInfo,
|
||||
NodeBridgeServer,
|
||||
NodeBridgeServerOpts,
|
||||
} from "./server/types.js";
|
||||
|
||||
489
src/infra/bridge/server/connection.ts
Normal file
489
src/infra/bridge/server/connection.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import type net from "node:net";
|
||||
|
||||
import {
|
||||
getPairedNode,
|
||||
listNodePairing,
|
||||
requestNodePairing,
|
||||
updatePairedNodeMetadata,
|
||||
verifyNodeToken,
|
||||
} from "../../node-pairing.js";
|
||||
|
||||
import { encodeLine } from "./encode.js";
|
||||
import { configureNodeBridgeSocket } from "./socket.js";
|
||||
import type {
|
||||
AnyBridgeFrame,
|
||||
BridgeErrorFrame,
|
||||
BridgeEventFrame,
|
||||
BridgeHelloFrame,
|
||||
BridgeHelloOkFrame,
|
||||
BridgeInvokeResponseFrame,
|
||||
BridgePairOkFrame,
|
||||
BridgePairRequestFrame,
|
||||
BridgePingFrame,
|
||||
BridgePongFrame,
|
||||
BridgeRPCRequestFrame,
|
||||
BridgeRPCResponseFrame,
|
||||
NodeBridgeClientInfo,
|
||||
NodeBridgeServerOpts,
|
||||
} from "./types.js";
|
||||
|
||||
type InvokeWaiter = {
|
||||
resolve: (value: BridgeInvokeResponseFrame) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export type ConnectionState = {
|
||||
socket: net.Socket;
|
||||
nodeInfo: NodeBridgeClientInfo;
|
||||
invokeWaiters: Map<string, InvokeWaiter>;
|
||||
};
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function createNodeBridgeConnectionHandler(params: {
|
||||
opts: NodeBridgeServerOpts;
|
||||
connections: Map<string, ConnectionState>;
|
||||
serverName: string;
|
||||
buildCanvasHostUrl: (socket: net.Socket) => string | undefined;
|
||||
}) {
|
||||
const { opts, connections, serverName } = params;
|
||||
|
||||
return (socket: net.Socket) => {
|
||||
configureNodeBridgeSocket(socket);
|
||||
|
||||
let buffer = "";
|
||||
let isAuthenticated = false;
|
||||
let nodeId: string | null = null;
|
||||
let nodeInfo: NodeBridgeClientInfo | null = null;
|
||||
const invokeWaiters = new Map<string, InvokeWaiter>();
|
||||
|
||||
const abort = new AbortController();
|
||||
const stop = () => {
|
||||
if (!abort.signal.aborted) abort.abort();
|
||||
for (const [, waiter] of invokeWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error("bridge connection closed"));
|
||||
}
|
||||
invokeWaiters.clear();
|
||||
if (nodeId) {
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket === socket) connections.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (frame: AnyBridgeFrame) => {
|
||||
try {
|
||||
socket.write(encodeLine(frame));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const sendError = (code: string, message: string) => {
|
||||
send({ type: "error", code, message } satisfies BridgeErrorFrame);
|
||||
};
|
||||
|
||||
const remoteAddress = (() => {
|
||||
const addr = socket.remoteAddress?.trim();
|
||||
return addr && addr.length > 0 ? addr : undefined;
|
||||
})();
|
||||
|
||||
const inferCaps = (frame: {
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
}): string[] | undefined => {
|
||||
const platform = String(frame.platform ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const family = String(frame.deviceFamily ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (platform.includes("ios") || platform.includes("ipados"))
|
||||
return ["canvas", "camera"];
|
||||
if (platform.includes("android")) return ["canvas", "camera"];
|
||||
if (family === "ipad" || family === "iphone" || family === "ios")
|
||||
return ["canvas", "camera"];
|
||||
if (family === "android") return ["canvas", "camera"];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizePermissions = (
|
||||
raw: unknown,
|
||||
): Record<string, boolean> | undefined => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
||||
return undefined;
|
||||
const entries = Object.entries(raw as Record<string, unknown>)
|
||||
.map(([key, value]) => [String(key).trim(), value === true] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
const handleHello = async (hello: BridgeHelloFrame) => {
|
||||
nodeId = String(hello.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = typeof hello.token === "string" ? hello.token.trim() : "";
|
||||
if (!token) {
|
||||
const paired = await getPairedNode(nodeId, opts.pairingBaseDir);
|
||||
sendError(paired ? "UNAUTHORIZED" : "NOT_PAIRED", "pairing required");
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await verifyNodeToken(
|
||||
nodeId,
|
||||
token,
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
if (!verified.ok || !verified.node) {
|
||||
sendError("UNAUTHORIZED", "invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps =
|
||||
(Array.isArray(hello.caps)
|
||||
? hello.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined) ??
|
||||
verified.node.caps ??
|
||||
inferCaps(hello);
|
||||
|
||||
const commands =
|
||||
Array.isArray(hello.commands) && hello.commands.length > 0
|
||||
? hello.commands.map((c) => String(c)).filter(Boolean)
|
||||
: verified.node.commands;
|
||||
const helloPermissions = normalizePermissions(hello.permissions);
|
||||
const basePermissions = verified.node.permissions ?? {};
|
||||
const permissions = helloPermissions
|
||||
? { ...basePermissions, ...helloPermissions }
|
||||
: verified.node.permissions;
|
||||
|
||||
isAuthenticated = true;
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: verified.node.displayName ?? hello.displayName,
|
||||
platform: verified.node.platform ?? hello.platform,
|
||||
version: verified.node.version ?? hello.version,
|
||||
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
|
||||
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
|
||||
caps,
|
||||
commands,
|
||||
permissions,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
await updatePairedNodeMetadata(
|
||||
nodeId,
|
||||
{
|
||||
displayName: nodeInfo.displayName,
|
||||
platform: nodeInfo.platform,
|
||||
version: nodeInfo.version,
|
||||
deviceFamily: nodeInfo.deviceFamily,
|
||||
modelIdentifier: nodeInfo.modelIdentifier,
|
||||
remoteIp: nodeInfo.remoteIp,
|
||||
caps: nodeInfo.caps,
|
||||
commands: nodeInfo.commands,
|
||||
permissions: nodeInfo.permissions,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({
|
||||
type: "hello-ok",
|
||||
serverName,
|
||||
canvasHostUrl: params.buildCanvasHostUrl(socket),
|
||||
} satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
};
|
||||
|
||||
const waitForApproval = async (request: {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
ts: number;
|
||||
}): Promise<
|
||||
{ ok: true; token: string } | { ok: false; reason: string }
|
||||
> => {
|
||||
const deadline = Date.now() + 5 * 60 * 1000;
|
||||
while (!abort.signal.aborted && Date.now() < deadline) {
|
||||
const list = await listNodePairing(opts.pairingBaseDir);
|
||||
const stillPending = list.pending.some(
|
||||
(p) => p.requestId === request.requestId,
|
||||
);
|
||||
if (stillPending) {
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paired = await getPairedNode(request.nodeId, opts.pairingBaseDir);
|
||||
if (!paired) return { ok: false, reason: "pairing rejected" };
|
||||
|
||||
// Ensure this approval happened after the request was created.
|
||||
if (paired.approvedAtMs < request.ts) {
|
||||
return { ok: false, reason: "pairing rejected" };
|
||||
}
|
||||
|
||||
return { ok: true, token: paired.token };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: abort.signal.aborted ? "disconnected" : "pairing expired",
|
||||
};
|
||||
};
|
||||
|
||||
const handlePairRequest = async (req: BridgePairRequestFrame) => {
|
||||
nodeId = String(req.nodeId ?? "").trim();
|
||||
if (!nodeId) {
|
||||
sendError("INVALID_REQUEST", "nodeId required");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestNodePairing(
|
||||
{
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: Array.isArray(req.caps)
|
||||
? req.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
commands: Array.isArray(req.commands)
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
permissions:
|
||||
req.permissions && typeof req.permissions === "object"
|
||||
? (req.permissions as Record<string, boolean>)
|
||||
: undefined,
|
||||
remoteIp: remoteAddress,
|
||||
silent: req.silent === true ? true : undefined,
|
||||
},
|
||||
opts.pairingBaseDir,
|
||||
);
|
||||
if (result.created) await opts.onPairRequested?.(result.request);
|
||||
|
||||
const wait = await waitForApproval({
|
||||
requestId: result.request.requestId,
|
||||
nodeId: result.request.nodeId,
|
||||
ts: result.request.ts,
|
||||
});
|
||||
if (!wait.ok) {
|
||||
sendError("UNAUTHORIZED", wait.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticated = true;
|
||||
const existing = connections.get(nodeId);
|
||||
if (existing?.socket && existing.socket !== socket) {
|
||||
try {
|
||||
existing.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
nodeInfo = {
|
||||
nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: Array.isArray(req.caps)
|
||||
? req.caps.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
commands: Array.isArray(req.commands)
|
||||
? req.commands.map((c) => String(c)).filter(Boolean)
|
||||
: undefined,
|
||||
permissions:
|
||||
req.permissions && typeof req.permissions === "object"
|
||||
? (req.permissions as Record<string, boolean>)
|
||||
: undefined,
|
||||
remoteIp: remoteAddress,
|
||||
};
|
||||
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
|
||||
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
|
||||
send({
|
||||
type: "hello-ok",
|
||||
serverName,
|
||||
canvasHostUrl: params.buildCanvasHostUrl(socket),
|
||||
} satisfies BridgeHelloOkFrame);
|
||||
await opts.onAuthenticated?.(nodeInfo);
|
||||
};
|
||||
|
||||
const handleEvent = async (evt: BridgeEventFrame) => {
|
||||
if (!isAuthenticated || !nodeId) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
return;
|
||||
}
|
||||
await opts.onEvent?.(nodeId, evt);
|
||||
};
|
||||
|
||||
const handleRequest = async (req: BridgeRPCRequestFrame) => {
|
||||
if (!isAuthenticated || !nodeId) {
|
||||
send({
|
||||
type: "res",
|
||||
id: String(req.id ?? ""),
|
||||
ok: false,
|
||||
error: { code: "UNAUTHORIZED", message: "not authenticated" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.onRequest) {
|
||||
send({
|
||||
type: "res",
|
||||
id: String(req.id ?? ""),
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "RPC not supported" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = String(req.id ?? "");
|
||||
const method = String(req.method ?? "");
|
||||
if (!id || !method) {
|
||||
send({
|
||||
type: "res",
|
||||
id: id || "invalid",
|
||||
ok: false,
|
||||
error: { code: "INVALID_REQUEST", message: "id and method required" },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await opts.onRequest(nodeId, {
|
||||
type: "req",
|
||||
id,
|
||||
method,
|
||||
paramsJSON: req.paramsJSON ?? null,
|
||||
});
|
||||
if (result.ok) {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: true,
|
||||
payloadJSON: result.payloadJSON ?? null,
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
} else {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: false,
|
||||
error: result.error,
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
}
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: String(err) },
|
||||
} satisfies BridgeRPCResponseFrame);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
while (true) {
|
||||
const idx = buffer.indexOf("\n");
|
||||
if (idx === -1) break;
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
void (async () => {
|
||||
let frame: AnyBridgeFrame;
|
||||
try {
|
||||
frame = JSON.parse(trimmed) as AnyBridgeFrame;
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof frame.type === "string" ? frame.type : "";
|
||||
try {
|
||||
switch (type) {
|
||||
case "hello":
|
||||
await handleHello(frame as BridgeHelloFrame);
|
||||
break;
|
||||
case "pair-request":
|
||||
await handlePairRequest(frame as BridgePairRequestFrame);
|
||||
break;
|
||||
case "event":
|
||||
await handleEvent(frame as BridgeEventFrame);
|
||||
break;
|
||||
case "req":
|
||||
await handleRequest(frame as BridgeRPCRequestFrame);
|
||||
break;
|
||||
case "ping": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const ping = frame as BridgePingFrame;
|
||||
send({
|
||||
type: "pong",
|
||||
id: String(ping.id ?? ""),
|
||||
} satisfies BridgePongFrame);
|
||||
break;
|
||||
}
|
||||
case "invoke-res": {
|
||||
if (!isAuthenticated) {
|
||||
sendError("UNAUTHORIZED", "not authenticated");
|
||||
break;
|
||||
}
|
||||
const res = frame as BridgeInvokeResponseFrame;
|
||||
const waiter = invokeWaiters.get(res.id);
|
||||
if (waiter) {
|
||||
invokeWaiters.delete(res.id);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(res);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "invoke":
|
||||
// Direction is gateway -> node only.
|
||||
sendError("INVALID_REQUEST", "invoke not allowed from node");
|
||||
break;
|
||||
case "res":
|
||||
// Direction is node -> gateway only.
|
||||
sendError("INVALID_REQUEST", "res not allowed from node");
|
||||
break;
|
||||
case "pong":
|
||||
// ignore
|
||||
break;
|
||||
default:
|
||||
sendError("INVALID_REQUEST", "unknown type");
|
||||
}
|
||||
} catch (err) {
|
||||
sendError("INVALID_REQUEST", String(err));
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
const info = nodeInfo;
|
||||
stop();
|
||||
if (info && isAuthenticated) void opts.onDisconnected?.(info);
|
||||
});
|
||||
socket.on("error", () => {
|
||||
// close handler will run after close
|
||||
});
|
||||
};
|
||||
}
|
||||
14
src/infra/bridge/server/disabled.ts
Normal file
14
src/infra/bridge/server/disabled.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NodeBridgeServer } from "./types.js";
|
||||
|
||||
export function createDisabledNodeBridgeServer(): NodeBridgeServer {
|
||||
return {
|
||||
port: 0,
|
||||
close: async () => {},
|
||||
invoke: async () => {
|
||||
throw new Error("bridge disabled in tests");
|
||||
},
|
||||
sendEvent: () => {},
|
||||
listConnected: () => [],
|
||||
listeners: [],
|
||||
};
|
||||
}
|
||||
5
src/infra/bridge/server/encode.ts
Normal file
5
src/infra/bridge/server/encode.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AnyBridgeFrame } from "./types.js";
|
||||
|
||||
export function encodeLine(frame: AnyBridgeFrame) {
|
||||
return `${JSON.stringify(frame)}\n`;
|
||||
}
|
||||
11
src/infra/bridge/server/loopback.ts
Normal file
11
src/infra/bridge/server/loopback.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function shouldAlsoListenOnLoopback(host: string | undefined) {
|
||||
const h = String(host ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!h) return false; // default listen() already includes loopback
|
||||
if (h === "0.0.0.0" || h === "::") return false; // already includes loopback
|
||||
if (h === "localhost") return false;
|
||||
if (h === "127.0.0.1" || h.startsWith("127.")) return false;
|
||||
if (h === "::1") return false;
|
||||
return true;
|
||||
}
|
||||
7
src/infra/bridge/server/socket.ts
Normal file
7
src/infra/bridge/server/socket.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function configureNodeBridgeSocket(socket: {
|
||||
setNoDelay: (noDelay?: boolean) => void;
|
||||
setKeepAlive: (enable?: boolean, initialDelay?: number) => void;
|
||||
}) {
|
||||
socket.setNoDelay(true);
|
||||
socket.setKeepAlive(true, 15_000);
|
||||
}
|
||||
181
src/infra/bridge/server/start.ts
Normal file
181
src/infra/bridge/server/start.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
|
||||
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
|
||||
|
||||
import {
|
||||
type ConnectionState,
|
||||
createNodeBridgeConnectionHandler,
|
||||
} from "./connection.js";
|
||||
import { createDisabledNodeBridgeServer } from "./disabled.js";
|
||||
import { encodeLine } from "./encode.js";
|
||||
import { shouldAlsoListenOnLoopback } from "./loopback.js";
|
||||
import { isNodeBridgeTestEnv } from "./test-env.js";
|
||||
import type {
|
||||
BridgeEventFrame,
|
||||
BridgeInvokeRequestFrame,
|
||||
BridgeInvokeResponseFrame,
|
||||
NodeBridgeServer,
|
||||
NodeBridgeServerOpts,
|
||||
} from "./types.js";
|
||||
|
||||
export async function startNodeBridgeServer(
|
||||
opts: NodeBridgeServerOpts,
|
||||
): Promise<NodeBridgeServer> {
|
||||
if (
|
||||
isNodeBridgeTestEnv() &&
|
||||
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1"
|
||||
) {
|
||||
return createDisabledNodeBridgeServer();
|
||||
}
|
||||
|
||||
const serverName =
|
||||
typeof opts.serverName === "string" && opts.serverName.trim()
|
||||
? opts.serverName.trim()
|
||||
: os.hostname();
|
||||
|
||||
const buildCanvasHostUrl = (socket: net.Socket) => {
|
||||
return resolveCanvasHostUrl({
|
||||
canvasPort: opts.canvasHostPort,
|
||||
hostOverride: opts.canvasHostHost,
|
||||
localAddress: socket.localAddress,
|
||||
scheme: "http",
|
||||
});
|
||||
};
|
||||
|
||||
const connections = new Map<string, ConnectionState>();
|
||||
const onConnection = createNodeBridgeConnectionHandler({
|
||||
opts,
|
||||
connections,
|
||||
serverName,
|
||||
buildCanvasHostUrl,
|
||||
});
|
||||
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||
const primary = net.createServer(onConnection);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
primary.once("error", onError);
|
||||
primary.listen(opts.port, opts.host, () => {
|
||||
primary.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
listeners.push({
|
||||
host: String(opts.host ?? "").trim() || "(default)",
|
||||
server: primary,
|
||||
});
|
||||
|
||||
const address = primary.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
if (shouldAlsoListenOnLoopback(opts.host)) {
|
||||
const loopback = net.createServer(onConnection);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
loopback.once("error", onError);
|
||||
loopback.listen(port, loopbackHost, () => {
|
||||
loopback.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
listeners.push({ host: loopbackHost, server: loopback });
|
||||
} catch {
|
||||
try {
|
||||
loopback.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: async () => {
|
||||
for (const sock of connections.values()) {
|
||||
try {
|
||||
sock.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
connections.clear();
|
||||
await Promise.all(
|
||||
listeners.map(
|
||||
(l) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
l.server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
|
||||
listeners: listeners.map((l) => ({ host: l.host, port })),
|
||||
sendEvent: ({ nodeId, event, payloadJSON }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedEvent = String(event ?? "").trim();
|
||||
if (!normalizedNodeId || !normalizedEvent) return;
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn) return;
|
||||
try {
|
||||
conn.socket.write(
|
||||
encodeLine({
|
||||
type: "event",
|
||||
event: normalizedEvent,
|
||||
payloadJSON: payloadJSON ?? null,
|
||||
} satisfies BridgeEventFrame),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
|
||||
const normalizedNodeId = String(nodeId ?? "").trim();
|
||||
const normalizedCommand = String(command ?? "").trim();
|
||||
if (!normalizedNodeId)
|
||||
throw new Error("INVALID_REQUEST: nodeId required");
|
||||
if (!normalizedCommand)
|
||||
throw new Error("INVALID_REQUEST: command required");
|
||||
|
||||
const conn = connections.get(normalizedNodeId);
|
||||
if (!conn)
|
||||
throw new Error(
|
||||
`UNAVAILABLE: node not connected (${normalizedNodeId})`,
|
||||
);
|
||||
|
||||
const id = randomUUID();
|
||||
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;
|
||||
|
||||
return await new Promise<BridgeInvokeResponseFrame>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
conn.invokeWaiters.delete(id);
|
||||
reject(new Error("UNAVAILABLE: invoke timeout"));
|
||||
},
|
||||
Math.max(0, timeout),
|
||||
);
|
||||
|
||||
conn.invokeWaiters.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
conn.socket.write(
|
||||
encodeLine({
|
||||
type: "invoke",
|
||||
id,
|
||||
command: normalizedCommand,
|
||||
paramsJSON: paramsJSON ?? null,
|
||||
} satisfies BridgeInvokeRequestFrame),
|
||||
);
|
||||
} catch (err) {
|
||||
conn.invokeWaiters.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
3
src/infra/bridge/server/test-env.ts
Normal file
3
src/infra/bridge/server/test-env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isNodeBridgeTestEnv() {
|
||||
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
|
||||
}
|
||||
146
src/infra/bridge/server/types.ts
Normal file
146
src/infra/bridge/server/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { NodePairingPendingRequest } from "../../node-pairing.js";
|
||||
|
||||
export type BridgeHelloFrame = {
|
||||
type: "hello";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
token?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type BridgePairRequestFrame = {
|
||||
type: "pair-request";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
remoteAddress?: string;
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
export type BridgeEventFrame = {
|
||||
type: "event";
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
};
|
||||
|
||||
export type BridgeRPCRequestFrame = {
|
||||
type: "req";
|
||||
id: string;
|
||||
method: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
export type BridgeRPCResponseFrame = {
|
||||
type: "res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code: string; message: string; details?: unknown } | null;
|
||||
};
|
||||
|
||||
export type BridgePingFrame = { type: "ping"; id: string };
|
||||
export type BridgePongFrame = { type: "pong"; id: string };
|
||||
|
||||
export type BridgeInvokeRequestFrame = {
|
||||
type: "invoke";
|
||||
id: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
export type BridgeInvokeResponseFrame = {
|
||||
type: "invoke-res";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code: string; message: string } | null;
|
||||
};
|
||||
|
||||
export type BridgeHelloOkFrame = {
|
||||
type: "hello-ok";
|
||||
serverName: string;
|
||||
canvasHostUrl?: string;
|
||||
};
|
||||
|
||||
export type BridgePairOkFrame = { type: "pair-ok"; token: string };
|
||||
export type BridgeErrorFrame = { type: "error"; code: string; message: string };
|
||||
|
||||
export type AnyBridgeFrame =
|
||||
| BridgeHelloFrame
|
||||
| BridgePairRequestFrame
|
||||
| BridgeEventFrame
|
||||
| BridgeRPCRequestFrame
|
||||
| BridgeRPCResponseFrame
|
||||
| BridgePingFrame
|
||||
| BridgePongFrame
|
||||
| BridgeInvokeRequestFrame
|
||||
| BridgeInvokeResponseFrame
|
||||
| BridgeHelloOkFrame
|
||||
| BridgePairOkFrame
|
||||
| BridgeErrorFrame
|
||||
| { type: string; [k: string]: unknown };
|
||||
|
||||
export type NodeBridgeServer = {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
invoke: (opts: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
paramsJSON?: string | null;
|
||||
timeoutMs?: number;
|
||||
}) => Promise<BridgeInvokeResponseFrame>;
|
||||
sendEvent: (opts: {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
}) => void;
|
||||
listConnected: () => NodeBridgeClientInfo[];
|
||||
listeners: Array<{ host: string; port: number }>;
|
||||
};
|
||||
|
||||
export type NodeBridgeClientInfo = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
pairingBaseDir?: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
|
||||
onRequest?: (
|
||||
nodeId: string,
|
||||
req: BridgeRPCRequestFrame,
|
||||
) => Promise<
|
||||
| { ok: true; payloadJSON?: string | null }
|
||||
| { ok: false; error: { code: string; message: string; details?: unknown } }
|
||||
>;
|
||||
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
|
||||
onPairRequested?: (
|
||||
request: NodePairingPendingRequest,
|
||||
) => Promise<void> | void;
|
||||
serverName?: string;
|
||||
};
|
||||
@@ -460,316 +460,4 @@ describe("runHeartbeatOnce", () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects ackMaxChars for heartbeat acks", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
ackMaxChars: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips WhatsApp delivery when not linked or running", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => false,
|
||||
hasActiveWebListener: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("skipped");
|
||||
expect(res).toMatchObject({ reason: "whatsapp-not-linked" });
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes through accountId for telegram heartbeats", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "123456",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendTelegram,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"Hello from heartbeat",
|
||||
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
work: { botToken: "test-bot-token-123" },
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "123456",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendTelegram,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"Hello from heartbeat",
|
||||
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
324
src/infra/heartbeat-runner.part-2.test.ts
Normal file
324
src/infra/heartbeat-runner.part-2.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
|
||||
// Avoid pulling optional runtime deps during isolated runs.
|
||||
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||
|
||||
describe("resolveHeartbeatIntervalMs", () => {
|
||||
it("respects ackMaxChars for heartbeat acks", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
ackMaxChars: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips WhatsApp delivery when not linked or running", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => false,
|
||||
hasActiveWebListener: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe("skipped");
|
||||
expect(res).toMatchObject({ reason: "whatsapp-not-linked" });
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes through accountId for telegram heartbeats", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "123456",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendTelegram,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"Hello from heartbeat",
|
||||
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pre-resolve telegram accountId (allows config-only account tokens)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
work: { botToken: "test-bot-token-123" },
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
};
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "123456",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendTelegram,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"Hello from heartbeat",
|
||||
expect.objectContaining({ accountId: undefined, verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
58
src/infra/state-migrations.fs.ts
Normal file
58
src/infra/state-migrations.fs.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
export type SessionEntryLike = {
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export function safeReadDir(dir: string): fs.Dirent[] {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function existsDir(dir: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function fileExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p) && fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLegacyWhatsAppAuthFile(name: string): boolean {
|
||||
if (name === "creds.json" || name === "creds.json.bak") return true;
|
||||
if (!name.endsWith(".json")) return false;
|
||||
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
|
||||
}
|
||||
|
||||
export function readSessionStoreJson5(storePath: string): {
|
||||
store: Record<string, SessionEntryLike>;
|
||||
ok: boolean;
|
||||
} {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return { store: parsed as Record<string, SessionEntryLike>, ok: true };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { store: {}, ok: false };
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
@@ -16,6 +14,15 @@ import {
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
ensureDir,
|
||||
existsDir,
|
||||
fileExists,
|
||||
isLegacyWhatsAppAuthFile,
|
||||
readSessionStoreJson5,
|
||||
type SessionEntryLike,
|
||||
safeReadDir,
|
||||
} from "./state-migrations.fs.js";
|
||||
|
||||
export type LegacyStateDetection = {
|
||||
targetAgentId: string;
|
||||
@@ -42,11 +49,6 @@ export type LegacyStateDetection = {
|
||||
preview: string[];
|
||||
};
|
||||
|
||||
type SessionEntryLike = { sessionId?: string; updatedAt?: number } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
@@ -54,56 +56,6 @@ type MigrationLogger = {
|
||||
|
||||
let autoMigrateChecked = false;
|
||||
|
||||
function safeReadDir(dir: string): fs.Dirent[] {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function existsDir(dir: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function fileExists(p: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(p) && fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyWhatsAppAuthFile(name: string): boolean {
|
||||
if (name === "creds.json" || name === "creds.json.bak") return true;
|
||||
if (!name.endsWith(".json")) return false;
|
||||
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
|
||||
}
|
||||
|
||||
function readSessionStoreJson5(storePath: string): {
|
||||
store: Record<string, SessionEntryLike>;
|
||||
ok: boolean;
|
||||
} {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return { store: parsed as Record<string, SessionEntryLike>, ok: true };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { store: {}, ok: false };
|
||||
}
|
||||
|
||||
function isSurfaceGroupKey(key: string): boolean {
|
||||
return key.includes(":group:") || key.includes(":channel:");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user