From 944f15e401abc822c770316f900b82f45a32a1ea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:56:01 +0100 Subject: [PATCH] fix(discovery): decode dns-sd escaped UTF-8 --- src/infra/bonjour-discovery.test.ts | 81 +++++++++++++++++++++++++++-- src/infra/bonjour-discovery.ts | 47 +++++++++++++++-- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index 4b121c7f3..c22b4cce5 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -7,6 +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 run = vi.fn( async (argv: string[], options: { timeoutMs: number }) => { @@ -17,7 +18,7 @@ describe("bonjour-discovery", () => { if (domain === "local.") { return { stdout: [ - "Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", + "Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge", "Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge", "", ].join("\n"), @@ -44,16 +45,20 @@ describe("bonjour-discovery", () => { if (argv[0] === "dns-sd" && argv[1] === "-L") { const instance = argv[2] ?? ""; const host = - instance === "Studio Bridge" + instance === studioInstance ? "studio.local" : instance === "Laptop Bridge" ? "laptop.local" : "tailnet.local"; const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : ""; + const displayName = + instance === studioInstance + ? "Peter’s\\032Mac\\032Studio" + : instance.replace(" Bridge", ""); const txtParts = [ "txtvers=1", - `displayName=${instance.replace(" Bridge", "")}`, + `displayName=${displayName}`, `lanHost=${host}`, "gatewayPort=18789", "bridgePort=18790", @@ -85,6 +90,14 @@ describe("bonjour-discovery", () => { }); expect(beacons).toHaveLength(3); + expect(beacons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + instanceName: studioInstance, + displayName: "Peter’s Mac Studio", + }), + ]), + ); expect(beacons.map((b) => b.domain)).toEqual( expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]), ); @@ -98,6 +111,68 @@ describe("bonjour-discovery", () => { expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true); }); + it("decodes dns-sd octal escapes in TXT displayName", async () => { + const run = vi.fn( + async (argv: string[], options: { timeoutMs: number }) => { + if (options.timeoutMs < 0) throw new Error("invalid timeout"); + + 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"), + stderr: "", + code: 0, + signal: null, + killed: false, + }; + } + + 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", + "", + ].join("\n"), + stderr: "", + code: 0, + signal: null, + killed: false, + }; + } + + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + }; + }, + ); + + const beacons = await discoverGatewayBeacons({ + platform: "darwin", + timeoutMs: 800, + domains: ["local."], + run: run as unknown as typeof runCommandWithTimeout, + }); + + expect(beacons).toEqual([ + expect.objectContaining({ + domain: "local.", + instanceName: "Studio Bridge", + displayName: "Peter’s Mac Studio", + txt: expect.objectContaining({ + displayName: "Peter’s Mac Studio", + }), + }), + ]); + }); + it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => { const calls: Array<{ argv: string[]; timeoutMs: number }> = []; diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index ebb77a9b1..e1be9190a 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -27,6 +27,42 @@ const DEFAULT_TIMEOUT_MS = 2000; const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const; +function decodeDnsSdEscapes(value: string): string { + let decoded = false; + const bytes: number[] = []; + let pending = ""; + + const flush = () => { + if (!pending) return; + bytes.push(...Buffer.from(pending, "utf8")); + pending = ""; + }; + + for (let i = 0; i < value.length; i += 1) { + const ch = value[i] ?? ""; + if (ch === "\\" && i + 3 < value.length) { + const escaped = value.slice(i + 1, i + 4); + if (/^[0-9]{3}$/.test(escaped)) { + const byte = Number.parseInt(escaped, 10); + if (!Number.isFinite(byte) || byte < 0 || byte > 255) { + pending += ch; + continue; + } + flush(); + bytes.push(byte); + decoded = true; + i += 3; + continue; + } + } + pending += ch; + } + + if (!decoded) return value; + flush(); + return Buffer.from(bytes).toString("utf8"); +} + function isTailnetIPv4(address: string): boolean { const parts = address.split("."); if (parts.length !== 4) return false; @@ -119,7 +155,7 @@ function parseTxtTokens(tokens: string[]): Record { const idx = token.indexOf("="); if (idx <= 0) continue; const key = token.slice(0, idx).trim(); - const value = token.slice(idx + 1).trim(); + const value = decodeDnsSdEscapes(token.slice(idx + 1).trim()); if (!key) continue; txt[key] = value; } @@ -134,7 +170,7 @@ function parseDnsSdBrowse(stdout: string): string[] { if (!line.includes("Add")) continue; const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/); if (match?.[1]) { - instances.add(match[1].trim()); + instances.add(decodeDnsSdEscapes(match[1].trim())); } } return Array.from(instances.values()); @@ -144,7 +180,8 @@ function parseDnsSdResolve( stdout: string, instanceName: string, ): GatewayBonjourBeacon | null { - const beacon: GatewayBonjourBeacon = { instanceName }; + const decodedInstanceName = decodeDnsSdEscapes(instanceName); + const beacon: GatewayBonjourBeacon = { instanceName: decodedInstanceName }; let txt: Record = {}; for (const raw of stdout.split("\n")) { const line = raw.trim(); @@ -168,7 +205,7 @@ function parseDnsSdResolve( } beacon.txt = Object.keys(txt).length ? txt : undefined; - if (txt.displayName) beacon.displayName = txt.displayName; + if (txt.displayName) beacon.displayName = decodeDnsSdEscapes(txt.displayName); if (txt.lanHost) beacon.lanHost = txt.lanHost; if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns; if (txt.cliPath) beacon.cliPath = txt.cliPath; @@ -176,7 +213,7 @@ function parseDnsSdResolve( beacon.gatewayPort = parseIntOrNull(txt.gatewayPort); beacon.sshPort = parseIntOrNull(txt.sshPort); - if (!beacon.displayName) beacon.displayName = instanceName; + if (!beacon.displayName) beacon.displayName = decodedInstanceName; return beacon; }