refactor: remove bridge protocol
This commit is contained in:
@@ -7,7 +7,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
||||
describe("bonjour-discovery", () => {
|
||||
it("discovers beacons on darwin across local + wide-area domains", async () => {
|
||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
||||
const studioInstance = "Peter’s Mac Studio Bridge";
|
||||
const studioInstance = "Peter’s Mac Studio Gateway";
|
||||
|
||||
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
|
||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||
@@ -17,8 +17,8 @@ describe("bonjour-discovery", () => {
|
||||
if (domain === "local.") {
|
||||
return {
|
||||
stdout: [
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
|
||||
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
||||
"Add 2 3 local. _clawdbot-gateway._tcp. Peter\\226\\128\\153s Mac Studio Gateway",
|
||||
"Add 2 3 local. _clawdbot-gateway._tcp. Laptop Gateway",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
@@ -30,7 +30,7 @@ describe("bonjour-discovery", () => {
|
||||
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
|
||||
return {
|
||||
stdout: [
|
||||
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
|
||||
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-gateway._tcp. Tailnet Gateway`,
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
@@ -46,27 +46,26 @@ describe("bonjour-discovery", () => {
|
||||
const host =
|
||||
instance === studioInstance
|
||||
? "studio.local"
|
||||
: instance === "Laptop Bridge"
|
||||
: instance === "Laptop Gateway"
|
||||
? "laptop.local"
|
||||
: "tailnet.local";
|
||||
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
||||
const tailnetDns = instance === "Tailnet Gateway" ? "studio.tailnet.ts.net" : "";
|
||||
const displayName =
|
||||
instance === studioInstance
|
||||
? "Peter’s\\032Mac\\032Studio"
|
||||
: instance.replace(" Bridge", "");
|
||||
: instance.replace(" Gateway", "");
|
||||
const txtParts = [
|
||||
"txtvers=1",
|
||||
`displayName=${displayName}`,
|
||||
`lanHost=${host}`,
|
||||
"gatewayPort=18789",
|
||||
"bridgePort=18790",
|
||||
"sshPort=22",
|
||||
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return {
|
||||
stdout: [
|
||||
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
|
||||
`${instance}._clawdbot-gateway._tcp. can be reached at ${host}:18789`,
|
||||
txtParts.join(" "),
|
||||
"",
|
||||
].join("\n"),
|
||||
@@ -113,7 +112,7 @@ describe("bonjour-discovery", () => {
|
||||
const domain = argv[3] ?? "";
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
||||
return {
|
||||
stdout: ["Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", ""].join("\n"),
|
||||
stdout: ["Add 2 3 local. _clawdbot-gateway._tcp. Studio Gateway", ""].join("\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
@@ -124,8 +123,8 @@ describe("bonjour-discovery", () => {
|
||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||
return {
|
||||
stdout: [
|
||||
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
|
||||
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
|
||||
"Studio Gateway._clawdbot-gateway._tcp. can be reached at studio.local:18789",
|
||||
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 sshPort=22",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
@@ -154,7 +153,7 @@ describe("bonjour-discovery", () => {
|
||||
expect(beacons).toEqual([
|
||||
expect.objectContaining({
|
||||
domain: "local.",
|
||||
instanceName: "Studio Bridge",
|
||||
instanceName: "Studio Gateway",
|
||||
displayName: "Peter’s Mac Studio",
|
||||
txt: expect.objectContaining({
|
||||
displayName: "Peter’s Mac Studio",
|
||||
@@ -204,10 +203,10 @@ describe("bonjour-discovery", () => {
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "PTR" &&
|
||||
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
|
||||
qname === "_clawdbot-gateway._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
|
||||
stdout: `studio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
@@ -218,10 +217,10 @@ describe("bonjour-discovery", () => {
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "SRV" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
|
||||
stdout: `0 0 18789 studio.clawdbot.internal.\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
@@ -232,14 +231,13 @@ describe("bonjour-discovery", () => {
|
||||
if (
|
||||
server === "100.123.224.76" &&
|
||||
qtype === "TXT" &&
|
||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
||||
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
|
||||
) {
|
||||
return {
|
||||
stdout: [
|
||||
`"displayName=Studio"`,
|
||||
`"transport=bridge"`,
|
||||
`"bridgePort=18790"`,
|
||||
`"gatewayPort=18789"`,
|
||||
`"transport=gateway"`,
|
||||
`"sshPort=22"`,
|
||||
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
||||
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
||||
@@ -266,10 +264,10 @@ describe("bonjour-discovery", () => {
|
||||
expect(beacons).toEqual([
|
||||
expect.objectContaining({
|
||||
domain: WIDE_AREA_DISCOVERY_DOMAIN,
|
||||
instanceName: "studio-bridge",
|
||||
instanceName: "studio-gateway",
|
||||
displayName: "Studio",
|
||||
host: "studio.clawdbot.internal",
|
||||
port: 18790,
|
||||
port: 18789,
|
||||
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||
gatewayPort: 18789,
|
||||
sshPort: 22,
|
||||
|
||||
@@ -9,11 +9,10 @@ export type GatewayBonjourBeacon = {
|
||||
port?: number;
|
||||
lanHost?: string;
|
||||
tailnetDns?: string;
|
||||
bridgePort?: number;
|
||||
gatewayPort?: number;
|
||||
sshPort?: number;
|
||||
bridgeTls?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
gatewayTls?: boolean;
|
||||
gatewayTlsFingerprintSha256?: string;
|
||||
cliPath?: string;
|
||||
txt?: Record<string, string>;
|
||||
};
|
||||
@@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] {
|
||||
const instances = new Set<string>();
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || !line.includes("_clawdbot-bridge._tcp")) continue;
|
||||
if (!line || !line.includes("_clawdbot-gateway._tcp")) continue;
|
||||
if (!line.includes("Add")) continue;
|
||||
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
|
||||
const match = line.match(/_clawdbot-gateway\._tcp\.?\s+(.+)$/);
|
||||
if (match?.[1]) {
|
||||
instances.add(decodeDnsSdEscapes(match[1].trim()));
|
||||
}
|
||||
@@ -205,14 +204,13 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
|
||||
if (txt.lanHost) beacon.lanHost = txt.lanHost;
|
||||
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
||||
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
||||
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
|
||||
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
||||
if (txt.bridgeTls) {
|
||||
const raw = txt.bridgeTls.trim().toLowerCase();
|
||||
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
if (txt.gatewayTls) {
|
||||
const raw = txt.gatewayTls.trim().toLowerCase();
|
||||
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
|
||||
if (txt.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
|
||||
|
||||
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
|
||||
return beacon;
|
||||
@@ -223,13 +221,13 @@ async function discoverViaDnsSd(
|
||||
timeoutMs: number,
|
||||
run: typeof runCommandWithTimeout,
|
||||
): Promise<GatewayBonjourBeacon[]> {
|
||||
const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
|
||||
const browse = await run(["dns-sd", "-B", "_clawdbot-gateway._tcp", domain], {
|
||||
timeoutMs,
|
||||
});
|
||||
const instances = parseDnsSdBrowse(browse.stdout);
|
||||
const results: GatewayBonjourBeacon[] = [];
|
||||
for (const instance of instances) {
|
||||
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
|
||||
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-gateway._tcp", domain], {
|
||||
timeoutMs,
|
||||
});
|
||||
const parsed = parseDnsSdResolve(resolved.stdout, instance);
|
||||
@@ -266,7 +264,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
// Keep scans bounded: this is a fallback and should not block long.
|
||||
ips = ips.slice(0, 40);
|
||||
|
||||
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
|
||||
const probeName = `_clawdbot-gateway._tcp.${domain.replace(/\.$/, "")}`;
|
||||
|
||||
const concurrency = 6;
|
||||
let nextIndex = 0;
|
||||
@@ -310,7 +308,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
if (budget <= 0) break;
|
||||
const ptrName = ptr.trim().replace(/\.$/, "");
|
||||
if (!ptrName) continue;
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-gateway\._tcp\..*$/, "");
|
||||
|
||||
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
|
||||
timeoutMs: Math.max(1, Math.min(350, budget)),
|
||||
@@ -343,12 +341,16 @@ async function discoverWideAreaViaTailnetDns(
|
||||
host: srvParsed.host,
|
||||
port: srvParsed.port,
|
||||
txt: Object.keys(txtMap).length ? txtMap : undefined,
|
||||
bridgePort: parseIntOrNull(txtMap.bridgePort),
|
||||
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
|
||||
sshPort: parseIntOrNull(txtMap.sshPort),
|
||||
tailnetDns: txtMap.tailnetDns || undefined,
|
||||
cliPath: txtMap.cliPath || undefined,
|
||||
};
|
||||
if (txtMap.gatewayTls) {
|
||||
const raw = txtMap.gatewayTls.trim().toLowerCase();
|
||||
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txtMap.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256;
|
||||
|
||||
results.push(beacon);
|
||||
}
|
||||
@@ -363,9 +365,9 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("=") && line.includes("_clawdbot-bridge._tcp")) {
|
||||
if (line.startsWith("=") && line.includes("_clawdbot-gateway._tcp")) {
|
||||
if (current) results.push(current);
|
||||
const marker = " _clawdbot-bridge._tcp";
|
||||
const marker = " _clawdbot-gateway._tcp";
|
||||
const idx = line.indexOf(marker);
|
||||
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
|
||||
const parts = left.split(/\s+/);
|
||||
@@ -400,9 +402,13 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
||||
if (txt.lanHost) current.lanHost = txt.lanHost;
|
||||
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
|
||||
if (txt.cliPath) current.cliPath = txt.cliPath;
|
||||
current.bridgePort = parseIntOrNull(txt.bridgePort);
|
||||
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||
current.sshPort = parseIntOrNull(txt.sshPort);
|
||||
if (txt.gatewayTls) {
|
||||
const raw = txt.gatewayTls.trim().toLowerCase();
|
||||
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txt.gatewayTlsSha256) current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +421,7 @@ async function discoverViaAvahi(
|
||||
timeoutMs: number,
|
||||
run: typeof runCommandWithTimeout,
|
||||
): Promise<GatewayBonjourBeacon[]> {
|
||||
const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
|
||||
const args = ["avahi-browse", "-rt", "_clawdbot-gateway._tcp"];
|
||||
if (domain && domain !== "local.") {
|
||||
// avahi-browse wants a plain domain (no trailing dot)
|
||||
args.push("-d", domain.replace(/\.$/, ""));
|
||||
|
||||
@@ -110,24 +110,23 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
tailnetDns: "host.tailnet.ts.net",
|
||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||
});
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
|
||||
expect(bridgeCall?.[0]?.port).toBe(18790);
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(gatewayCall?.[0]?.type).toBe("clawdbot-gateway");
|
||||
expect(gatewayCall?.[0]?.port).toBe(18789);
|
||||
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
"/opt/homebrew/bin/clawdbot",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
|
||||
|
||||
// We don't await `advertise()`, but it should still be called for each service.
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
@@ -166,7 +165,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
// 1 service × 2 listeners
|
||||
@@ -209,7 +207,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
await started.stop();
|
||||
@@ -248,7 +245,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
// initial advertise attempt happens immediately
|
||||
@@ -295,7 +291,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
@@ -328,14 +323,13 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(gatewayCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("Mac");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
@@ -17,10 +17,7 @@ export type GatewayBonjourAdvertiseOpts = {
|
||||
sshPort?: number;
|
||||
gatewayTlsEnabled?: boolean;
|
||||
gatewayTlsFingerprintSha256?: string;
|
||||
bridgePort?: number;
|
||||
canvasPort?: number;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
tailnetDns?: string;
|
||||
cliPath?: string;
|
||||
};
|
||||
@@ -106,9 +103,6 @@ export async function startGatewayBonjourAdvertiser(
|
||||
lanHost: `${hostname}.local`,
|
||||
displayName,
|
||||
};
|
||||
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
||||
txtBase.bridgePort = String(opts.bridgePort);
|
||||
}
|
||||
if (opts.gatewayTlsEnabled) {
|
||||
txtBase.gatewayTls = "1";
|
||||
if (opts.gatewayTlsFingerprintSha256) {
|
||||
@@ -118,12 +112,6 @@ export async function startGatewayBonjourAdvertiser(
|
||||
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
|
||||
txtBase.canvasPort = String(opts.canvasPort);
|
||||
}
|
||||
if (opts.bridgeTlsEnabled) {
|
||||
txtBase.bridgeTls = "1";
|
||||
if (opts.bridgeTlsFingerprintSha256) {
|
||||
txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256;
|
||||
}
|
||||
}
|
||||
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
||||
txtBase.tailnetDns = opts.tailnetDns.trim();
|
||||
}
|
||||
@@ -133,26 +121,23 @@ export async function startGatewayBonjourAdvertiser(
|
||||
|
||||
const services: Array<{ label: string; svc: BonjourService }> = [];
|
||||
|
||||
// Bridge beacon (used by macOS/iOS/Android nodes and the mac app onboarding flow).
|
||||
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
|
||||
const bridge = responder.createService({
|
||||
name: safeServiceName(instanceName),
|
||||
type: "clawdbot-bridge",
|
||||
protocol: Protocol.TCP,
|
||||
port: opts.bridgePort,
|
||||
domain: "local",
|
||||
hostname,
|
||||
txt: {
|
||||
...txtBase,
|
||||
sshPort: String(opts.sshPort ?? 22),
|
||||
transport: "bridge",
|
||||
},
|
||||
});
|
||||
services.push({
|
||||
label: "bridge",
|
||||
svc: bridge as unknown as BonjourService,
|
||||
});
|
||||
}
|
||||
const gateway = responder.createService({
|
||||
name: safeServiceName(instanceName),
|
||||
type: "clawdbot-gateway",
|
||||
protocol: Protocol.TCP,
|
||||
port: opts.gatewayPort,
|
||||
domain: "local",
|
||||
hostname,
|
||||
txt: {
|
||||
...txtBase,
|
||||
sshPort: String(opts.sshPort ?? 22),
|
||||
transport: "gateway",
|
||||
},
|
||||
});
|
||||
services.push({
|
||||
label: "gateway",
|
||||
svc: gateway as unknown as BonjourService,
|
||||
});
|
||||
|
||||
let ciaoCancellationRejectionHandler: (() => void) | undefined;
|
||||
if (services.length > 0) {
|
||||
@@ -164,9 +149,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
logDebug(
|
||||
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
|
||||
safeServiceName(instanceName),
|
||||
)}, gatewayPort=${opts.gatewayPort}, bridgePort=${opts.bridgePort ?? 0}, sshPort=${
|
||||
opts.sshPort ?? 22
|
||||
})`,
|
||||
)}, gatewayPort=${opts.gatewayPort}, sshPort=${opts.sshPort ?? 22})`,
|
||||
);
|
||||
|
||||
for (const { label, svc } of services) {
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
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, vi } from "vitest";
|
||||
|
||||
import { pollUntil } from "../../../test/helpers/poll.js";
|
||||
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
|
||||
import { configureNodeBridgeSocket, startNodeBridgeServer } from "./server.js";
|
||||
|
||||
const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000;
|
||||
const suiteTimeoutMs = process.platform === "win32" ? 20000 : 10000;
|
||||
|
||||
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", { timeout: suiteTimeoutMs }, () => {
|
||||
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("enables keepalive on sockets", () => {
|
||||
const socket = {
|
||||
setNoDelay: vi.fn(),
|
||||
setKeepAlive: vi.fn(),
|
||||
};
|
||||
configureNodeBridgeSocket(socket);
|
||||
expect(socket.setNoDelay).toHaveBeenCalledWith(true);
|
||||
expect(socket.setKeepAlive).toHaveBeenCalledWith(true, 15_000);
|
||||
});
|
||||
|
||||
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("does not add a loopback listener when bind already includes loopback", async () => {
|
||||
const loopback = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
expect(loopback.listeners).toHaveLength(1);
|
||||
expect(loopback.listeners[0]?.host).toBe("127.0.0.1");
|
||||
await loopback.close();
|
||||
|
||||
const wildcard = await startNodeBridgeServer({
|
||||
host: "0.0.0.0",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
expect(wildcard.listeners).toHaveLength(1);
|
||||
expect(wildcard.listeners[0]?.host).toBe("0.0.0.0");
|
||||
await wildcard.close();
|
||||
});
|
||||
|
||||
it("also listens on loopback when bound to a non-loopback host", async () => {
|
||||
const host = pickNonLoopbackIPv4();
|
||||
if (!host) return;
|
||||
|
||||
const server = await startNodeBridgeServer({
|
||||
host,
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
});
|
||||
|
||||
const hosts = server.listeners.map((l) => l.host).sort();
|
||||
expect(hosts).toContain(host);
|
||||
const hasLoopback = hosts.includes("127.0.0.1");
|
||||
if (hasLoopback) {
|
||||
const socket = net.connect({ host: "127.0.0.1", port: server.port });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "hello", nodeId: "n-loopback" });
|
||||
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 });
|
||||
await waitForSocketConnect(socket);
|
||||
const readLine = createLineReader(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n2", 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 === "n2");
|
||||
},
|
||||
{ timeoutMs: pairingTimeoutMs },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, 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 });
|
||||
await waitForSocketConnect(socket2);
|
||||
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 });
|
||||
await waitForSocketConnect(socket);
|
||||
sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" });
|
||||
|
||||
await pollUntil(async () => requested, { timeoutMs: pairingTimeoutMs });
|
||||
|
||||
expect(requested?.nodeId).toBe("n3");
|
||||
expect(typeof requested?.requestId).toBe("string");
|
||||
|
||||
socket.destroy();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("handles req/res RPC after authentication", async () => {
|
||||
let lastRequest: { nodeId?: string; id?: string; method?: string } | null = null;
|
||||
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
onRequest: async (nodeId, req) => {
|
||||
lastRequest = { nodeId, id: req.id, method: req.method };
|
||||
return { ok: true, payloadJSON: JSON.stringify({ ok: true }) };
|
||||
},
|
||||
});
|
||||
|
||||
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: "n3-rpc",
|
||||
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 === "n3-rpc");
|
||||
},
|
||||
{ timeoutMs: pairingTimeoutMs },
|
||||
);
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
await approveNodePairing(pending.requestId, baseDir);
|
||||
|
||||
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");
|
||||
|
||||
sendLine(socket, { type: "req", id: "r1", method: "health" });
|
||||
const res = JSON.parse(await readLine()) as {
|
||||
type: string;
|
||||
id?: string;
|
||||
ok?: boolean;
|
||||
payloadJSON?: string | null;
|
||||
error?: unknown;
|
||||
};
|
||||
expect(res.type).toBe("res");
|
||||
expect(res.id).toBe("r1");
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payloadJSON).toBe(JSON.stringify({ ok: true }));
|
||||
expect(res.error).toBeUndefined();
|
||||
|
||||
expect(lastRequest).toEqual({
|
||||
nodeId: "n3-rpc",
|
||||
id: "r1",
|
||||
method: "health",
|
||||
});
|
||||
|
||||
socket.destroy();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("passes node metadata to onAuthenticated and onDisconnected", async () => {
|
||||
let lastAuthed: {
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
} | null = null;
|
||||
|
||||
let disconnected: {
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
} | null = null;
|
||||
|
||||
let resolveDisconnected: (() => void) | null = null;
|
||||
const disconnectedP = new Promise<void>((resolve) => {
|
||||
resolveDisconnected = resolve;
|
||||
});
|
||||
|
||||
let pendingRequest: {
|
||||
requestId: string;
|
||||
nodeId: string;
|
||||
ts: number;
|
||||
} | null = null;
|
||||
const server = await startNodeBridgeServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pairingBaseDir: baseDir,
|
||||
onAuthenticated: async (node) => {
|
||||
lastAuthed = node;
|
||||
},
|
||||
onPairRequested: async (request) => {
|
||||
pendingRequest = {
|
||||
requestId: request.requestId,
|
||||
nodeId: request.nodeId,
|
||||
ts: request.ts,
|
||||
};
|
||||
},
|
||||
onDisconnected: async (node) => {
|
||||
disconnected = node;
|
||||
resolveDisconnected?.();
|
||||
},
|
||||
});
|
||||
|
||||
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: "n4",
|
||||
displayName: "Node",
|
||||
platform: "ios",
|
||||
version: "1.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad16,6",
|
||||
permissions: { screenRecording: true, notifications: false },
|
||||
});
|
||||
|
||||
// Approve the pending request from the gateway side.
|
||||
const pending = await pollUntil(async () => pendingRequest, { timeoutMs: pairingTimeoutMs });
|
||||
expect(pending).toBeTruthy();
|
||||
if (!pending) throw new Error("expected a pending request");
|
||||
const approved = await approveNodePairing(pending.requestId, 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 });
|
||||
await waitForSocketConnect(socket2);
|
||||
const readLine2 = createLineReader(socket2);
|
||||
sendLine(socket2, {
|
||||
type: "hello",
|
||||
nodeId: "n4",
|
||||
token,
|
||||
displayName: "Different name",
|
||||
platform: "ios",
|
||||
version: "2.0",
|
||||
deviceFamily: "iPad",
|
||||
modelIdentifier: "iPad99,1",
|
||||
permissions: { screenRecording: false },
|
||||
});
|
||||
const line3 = JSON.parse(await readLine2()) as { type: string };
|
||||
expect(line3.type).toBe("hello-ok");
|
||||
|
||||
await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), {
|
||||
timeoutMs: pairingTimeoutMs,
|
||||
});
|
||||
|
||||
expect(lastAuthed?.nodeId).toBe("n4");
|
||||
// Prefer paired metadata over hello payload (token verifies the stored node record).
|
||||
expect(lastAuthed?.displayName).toBe("Node");
|
||||
expect(lastAuthed?.platform).toBe("ios");
|
||||
expect(lastAuthed?.version).toBe("1.0");
|
||||
expect(lastAuthed?.deviceFamily).toBe("iPad");
|
||||
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
|
||||
expect(lastAuthed?.permissions).toEqual({
|
||||
screenRecording: false,
|
||||
notifications: false,
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
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,10 +0,0 @@
|
||||
export { configureNodeBridgeSocket } from "./server/socket.js";
|
||||
export { startNodeBridgeServer } from "./server/start.js";
|
||||
export type {
|
||||
BridgeEventFrame,
|
||||
BridgeInvokeResponseFrame,
|
||||
BridgeRPCRequestFrame,
|
||||
NodeBridgeClientInfo,
|
||||
NodeBridgeServer,
|
||||
NodeBridgeServerOpts,
|
||||
} from "./server/types.js";
|
||||
@@ -1,482 +0,0 @@
|
||||
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,
|
||||
coreVersion: verified.node.coreVersion ?? hello.coreVersion,
|
||||
uiVersion: verified.node.uiVersion ?? hello.uiVersion,
|
||||
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,
|
||||
coreVersion: nodeInfo.coreVersion,
|
||||
uiVersion: nodeInfo.uiVersion,
|
||||
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,
|
||||
coreVersion: req.coreVersion,
|
||||
uiVersion: req.uiVersion,
|
||||
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,
|
||||
coreVersion: req.coreVersion,
|
||||
uiVersion: req.uiVersion,
|
||||
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
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AnyBridgeFrame } from "./types.js";
|
||||
|
||||
export function encodeLine(frame: AnyBridgeFrame) {
|
||||
return `${JSON.stringify(frame)}\n`;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function configureNodeBridgeSocket(socket: {
|
||||
setNoDelay: (noDelay?: boolean) => void;
|
||||
setKeepAlive: (enable?: boolean, initialDelay?: number) => void;
|
||||
}) {
|
||||
socket.setNoDelay(true);
|
||||
socket.setKeepAlive(true, 15_000);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import tls from "node:tls";
|
||||
|
||||
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 createServer = () =>
|
||||
opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection);
|
||||
const primary = createServer();
|
||||
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 = createServer();
|
||||
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)));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isNodeBridgeTestEnv() {
|
||||
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { TlsOptions } from "node:tls";
|
||||
|
||||
import type { NodePairingPendingRequest } from "../../node-pairing.js";
|
||||
|
||||
export type BridgeHelloFrame = {
|
||||
type: "hello";
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
token?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
coreVersion?: string;
|
||||
uiVersion?: 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;
|
||||
coreVersion?: string;
|
||||
uiVersion?: 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;
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
tls?: TlsOptions;
|
||||
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;
|
||||
};
|
||||
@@ -2,10 +2,10 @@ import type { SkillEligibilityContext, SkillEntry } from "../agents/skills.js";
|
||||
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { NodeBridgeServer } from "./bridge/server.js";
|
||||
import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
|
||||
import type { NodeRegistry } from "../gateway/node-registry.js";
|
||||
|
||||
type RemoteNodeRecord = {
|
||||
nodeId: string;
|
||||
@@ -19,7 +19,7 @@ type RemoteNodeRecord = {
|
||||
|
||||
const log = createSubsystemLogger("gateway/skills-remote");
|
||||
const remoteNodes = new Map<string, RemoteNodeRecord>();
|
||||
let remoteBridge: NodeBridgeServer | null = null;
|
||||
let remoteRegistry: NodeRegistry | null = null;
|
||||
|
||||
function describeNode(nodeId: string): string {
|
||||
const record = remoteNodes.get(nodeId);
|
||||
@@ -55,22 +55,16 @@ function extractErrorMessage(err: unknown): string | undefined {
|
||||
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
|
||||
const message = extractErrorMessage(err);
|
||||
const label = describeNode(nodeId);
|
||||
if (message?.includes("UNAVAILABLE: node not connected")) {
|
||||
if (message?.includes("node not connected")) {
|
||||
log.info(
|
||||
`remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message?.includes("UNAVAILABLE: invoke timeout")) {
|
||||
if (message?.includes("invoke timed out") || message?.includes("timeout")) {
|
||||
log.warn(`remote bin probe timed out (${label}); check node connectivity for ${label}`);
|
||||
return;
|
||||
}
|
||||
if (message?.includes("bridge connection closed")) {
|
||||
log.warn(
|
||||
`remote bin probe aborted: bridge connection closed (${label}); check nodes list/status for ${label}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.warn(`remote bin probe error (${label}): ${message ?? "unknown"}`);
|
||||
}
|
||||
|
||||
@@ -117,8 +111,8 @@ function upsertNode(record: {
|
||||
});
|
||||
}
|
||||
|
||||
export function setSkillsRemoteBridge(bridge: NodeBridgeServer | null) {
|
||||
remoteBridge = bridge;
|
||||
export function setSkillsRemoteRegistry(registry: NodeRegistry | null) {
|
||||
remoteRegistry = registry;
|
||||
}
|
||||
|
||||
export async function primeRemoteSkillsCache() {
|
||||
@@ -198,10 +192,12 @@ function buildBinProbeScript(bins: string[]): string {
|
||||
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
|
||||
}
|
||||
|
||||
function parseBinProbePayload(payloadJSON: string | null | undefined): string[] {
|
||||
if (!payloadJSON) return [];
|
||||
function parseBinProbePayload(payloadJSON: string | null | undefined, payload?: unknown): string[] {
|
||||
if (!payloadJSON && !payload) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
|
||||
const parsed = payloadJSON
|
||||
? (JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown })
|
||||
: (payload as { stdout?: unknown; bins?: unknown });
|
||||
if (Array.isArray(parsed.bins)) {
|
||||
return parsed.bins.map((bin) => String(bin).trim()).filter(Boolean);
|
||||
}
|
||||
@@ -225,7 +221,7 @@ export async function refreshRemoteNodeBins(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
if (!remoteBridge) return;
|
||||
if (!remoteRegistry) return;
|
||||
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
|
||||
const canWhich = supportsSystemWhich(params.commands);
|
||||
const canRun = supportsSystemRun(params.commands);
|
||||
@@ -243,20 +239,20 @@ export async function refreshRemoteNodeBins(params: {
|
||||
|
||||
try {
|
||||
const binsList = [...requiredBins];
|
||||
const res = await remoteBridge.invoke(
|
||||
const res = await remoteRegistry.invoke(
|
||||
canWhich
|
||||
? {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.which",
|
||||
paramsJSON: JSON.stringify({ bins: binsList }),
|
||||
params: { bins: binsList },
|
||||
timeoutMs: params.timeoutMs ?? 15_000,
|
||||
}
|
||||
: {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.run",
|
||||
paramsJSON: JSON.stringify({
|
||||
params: {
|
||||
command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)],
|
||||
}),
|
||||
},
|
||||
timeoutMs: params.timeoutMs ?? 15_000,
|
||||
},
|
||||
);
|
||||
@@ -264,7 +260,7 @@ export async function refreshRemoteNodeBins(params: {
|
||||
logRemoteBinProbeFailure(params.nodeId, res.error?.message ?? "unknown");
|
||||
return;
|
||||
}
|
||||
const bins = parseBinProbePayload(res.payloadJSON);
|
||||
const bins = parseBinProbePayload(res.payloadJSON, res.payload);
|
||||
recordRemoteNodeBins(params.nodeId, bins);
|
||||
await updatePairedNodeMetadata(params.nodeId, { bins });
|
||||
bumpSkillsSnapshotVersion({ reason: "remote-node" });
|
||||
@@ -296,8 +292,8 @@ export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] |
|
||||
}
|
||||
|
||||
export async function refreshRemoteBinsForConnectedNodes(cfg: ClawdbotConfig) {
|
||||
if (!remoteBridge) return;
|
||||
const connected = remoteBridge.listConnected();
|
||||
if (!remoteRegistry) return;
|
||||
const connected = remoteRegistry.listConnected();
|
||||
for (const node of connected) {
|
||||
await refreshRemoteNodeBins({
|
||||
nodeId: node.nodeId,
|
||||
|
||||
@@ -5,12 +5,12 @@ import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { BridgeTlsConfig } from "../../../config/types.gateway.js";
|
||||
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js";
|
||||
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
|
||||
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type BridgeTlsRuntime = {
|
||||
export type GatewayTlsRuntime = {
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
certPath?: string;
|
||||
@@ -59,25 +59,25 @@ async function generateSelfSignedCert(params: {
|
||||
"-out",
|
||||
params.certPath,
|
||||
"-subj",
|
||||
"/CN=clawdbot-bridge",
|
||||
"/CN=clawdbot-gateway",
|
||||
]);
|
||||
await fs.chmod(params.keyPath, 0o600).catch(() => {});
|
||||
await fs.chmod(params.certPath, 0o600).catch(() => {});
|
||||
params.log?.info?.(
|
||||
`bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
|
||||
`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadBridgeTlsRuntime(
|
||||
cfg: BridgeTlsConfig | undefined,
|
||||
export async function loadGatewayTlsRuntime(
|
||||
cfg: GatewayTlsConfig | undefined,
|
||||
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
||||
): Promise<BridgeTlsRuntime> {
|
||||
): Promise<GatewayTlsRuntime> {
|
||||
if (!cfg || cfg.enabled !== true) return { enabled: false, required: false };
|
||||
|
||||
const autoGenerate = cfg.autoGenerate !== false;
|
||||
const baseDir = path.join(CONFIG_DIR, "bridge", "tls");
|
||||
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem"));
|
||||
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem"));
|
||||
const baseDir = path.join(CONFIG_DIR, "gateway", "tls");
|
||||
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "gateway-cert.pem"));
|
||||
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "gateway-key.pem"));
|
||||
const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined;
|
||||
|
||||
const hasCert = await fileExists(certPath);
|
||||
@@ -92,7 +92,7 @@ export async function loadBridgeTlsRuntime(
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
error: `bridge tls: failed to generate cert (${String(err)})`,
|
||||
error: `gateway tls: failed to generate cert (${String(err)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function loadBridgeTlsRuntime(
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
error: "bridge tls: cert/key missing",
|
||||
error: "gateway tls: cert/key missing",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function loadBridgeTlsRuntime(
|
||||
certPath,
|
||||
keyPath,
|
||||
caPath,
|
||||
error: "bridge tls: unable to compute certificate fingerprint",
|
||||
error: "gateway tls: unable to compute certificate fingerprint",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function loadBridgeTlsRuntime(
|
||||
certPath,
|
||||
keyPath,
|
||||
caPath,
|
||||
error: `bridge tls: failed to load cert (${String(err)})`,
|
||||
error: `gateway tls: failed to load cert (${String(err)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWideAreaBridgeZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
||||
import { renderWideAreaGatewayZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
||||
|
||||
describe("wide-area DNS-SD zone rendering", () => {
|
||||
it("renders a clawdbot.internal zone with bridge PTR/SRV/TXT records", () => {
|
||||
const txt = renderWideAreaBridgeZoneText({
|
||||
it("renders a clawdbot.internal zone with gateway PTR/SRV/TXT records", () => {
|
||||
const txt = renderWideAreaGatewayZoneText({
|
||||
serial: 2025121701,
|
||||
bridgePort: 18790,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (Clawdbot)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
@@ -20,8 +19,8 @@ describe("wide-area DNS-SD zone rendering", () => {
|
||||
expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`);
|
||||
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
|
||||
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
|
||||
expect(txt).toContain(`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`);
|
||||
expect(txt).toContain(`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`);
|
||||
expect(txt).toContain(`_clawdbot-gateway._tcp IN PTR studio-london._clawdbot-gateway._tcp`);
|
||||
expect(txt).toContain(`studio-london._clawdbot-gateway._tcp IN SRV 0 0 18789 studio-london`);
|
||||
expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`);
|
||||
expect(txt).toContain(`gatewayPort=18789`);
|
||||
expect(txt).toContain(`sshPort=22`);
|
||||
@@ -29,9 +28,8 @@ describe("wide-area DNS-SD zone rendering", () => {
|
||||
});
|
||||
|
||||
it("includes tailnetDns when provided", () => {
|
||||
const txt = renderWideAreaBridgeZoneText({
|
||||
const txt = renderWideAreaGatewayZoneText({
|
||||
serial: 2025121701,
|
||||
bridgePort: 18790,
|
||||
gatewayPort: 18789,
|
||||
displayName: "Mac Studio (Clawdbot)",
|
||||
tailnetIPv4: "100.123.224.76",
|
||||
|
||||
@@ -65,14 +65,13 @@ function computeContentHash(body: string): string {
|
||||
return (h >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
export type WideAreaBridgeZoneOpts = {
|
||||
bridgePort: number;
|
||||
gatewayPort?: number;
|
||||
export type WideAreaGatewayZoneOpts = {
|
||||
gatewayPort: number;
|
||||
displayName: string;
|
||||
tailnetIPv4: string;
|
||||
tailnetIPv6?: string;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
gatewayTlsEnabled?: boolean;
|
||||
gatewayTlsFingerprintSha256?: string;
|
||||
instanceLabel?: string;
|
||||
hostLabel?: string;
|
||||
tailnetDns?: string;
|
||||
@@ -80,23 +79,20 @@ export type WideAreaBridgeZoneOpts = {
|
||||
cliPath?: string;
|
||||
};
|
||||
|
||||
function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
function renderZone(opts: WideAreaGatewayZoneOpts & { serial: number }): string {
|
||||
const hostname = os.hostname().split(".")[0] ?? "clawdbot";
|
||||
const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot");
|
||||
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge");
|
||||
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-gateway`, "clawdbot-gateway");
|
||||
|
||||
const txt = [
|
||||
`displayName=${opts.displayName.trim() || hostname}`,
|
||||
`transport=bridge`,
|
||||
`bridgePort=${opts.bridgePort}`,
|
||||
`transport=gateway`,
|
||||
`gatewayPort=${opts.gatewayPort}`,
|
||||
];
|
||||
if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) {
|
||||
txt.push(`gatewayPort=${opts.gatewayPort}`);
|
||||
}
|
||||
if (opts.bridgeTlsEnabled) {
|
||||
txt.push(`bridgeTls=1`);
|
||||
if (opts.bridgeTlsFingerprintSha256) {
|
||||
txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`);
|
||||
if (opts.gatewayTlsEnabled) {
|
||||
txt.push(`gatewayTls=1`);
|
||||
if (opts.gatewayTlsFingerprintSha256) {
|
||||
txt.push(`gatewayTlsSha256=${opts.gatewayTlsFingerprintSha256}`);
|
||||
}
|
||||
}
|
||||
if (opts.tailnetDns?.trim()) {
|
||||
@@ -122,9 +118,11 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`);
|
||||
}
|
||||
|
||||
records.push(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`);
|
||||
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`);
|
||||
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
|
||||
records.push(`_clawdbot-gateway._tcp IN PTR ${instanceLabel}._clawdbot-gateway._tcp`);
|
||||
records.push(
|
||||
`${instanceLabel}._clawdbot-gateway._tcp IN SRV 0 0 ${opts.gatewayPort} ${hostLabel}`,
|
||||
);
|
||||
records.push(`${instanceLabel}._clawdbot-gateway._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
|
||||
|
||||
const contentBody = `${records.join("\n")}\n`;
|
||||
const hashBody = `${records
|
||||
@@ -137,14 +135,14 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
return `; clawdbot-content-hash: ${contentHash}\n${contentBody}`;
|
||||
}
|
||||
|
||||
export function renderWideAreaBridgeZoneText(
|
||||
opts: WideAreaBridgeZoneOpts & { serial: number },
|
||||
export function renderWideAreaGatewayZoneText(
|
||||
opts: WideAreaGatewayZoneOpts & { serial: number },
|
||||
): string {
|
||||
return renderZone(opts);
|
||||
}
|
||||
|
||||
export async function writeWideAreaBridgeZone(
|
||||
opts: WideAreaBridgeZoneOpts,
|
||||
export async function writeWideAreaGatewayZone(
|
||||
opts: WideAreaGatewayZoneOpts,
|
||||
): Promise<{ zonePath: string; changed: boolean }> {
|
||||
const zonePath = getWideAreaZonePath();
|
||||
await ensureDir(path.dirname(zonePath));
|
||||
@@ -157,7 +155,7 @@ export async function writeWideAreaBridgeZone(
|
||||
}
|
||||
})();
|
||||
|
||||
const nextNoSerial = renderWideAreaBridgeZoneText({ ...opts, serial: 0 });
|
||||
const nextNoSerial = renderWideAreaGatewayZoneText({ ...opts, serial: 0 });
|
||||
const nextHash = extractContentHash(nextNoSerial);
|
||||
const existingHash = existing ? extractContentHash(existing) : null;
|
||||
|
||||
@@ -167,7 +165,7 @@ export async function writeWideAreaBridgeZone(
|
||||
|
||||
const existingSerial = existing ? extractSerial(existing) : null;
|
||||
const serial = nextSerial(existingSerial, new Date());
|
||||
const next = renderWideAreaBridgeZoneText({ ...opts, serial });
|
||||
const next = renderWideAreaGatewayZoneText({ ...opts, serial });
|
||||
fs.writeFileSync(zonePath, next, "utf-8");
|
||||
return { zonePath, changed: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user