From 7ad57c45183da634a9d80047f060720d8997b9c4 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Sun, 19 Apr 2026 14:34:26 -0400 Subject: [PATCH] feat(dashboard): port read-only views to daemon SDK via WS bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a vanilla-JS daemon bridge that speaks the same binary-mux WebSocket protocol as `@axiom-labs/arc-client` so the dashboard can talk directly to the v3 daemon without pulling the Node SDK into the browser bundle. - `public/components/arc-client-bridge.js` (new) — frame codec + RPC / subscribe client that mirrors `packages/client/src/frame.ts` + `protocol.ts` byte-for-byte. Exposes `ensureArcClient()` which lazily fetches the daemon token from the dashboard and opens a WS, retrying on failure so the HTTP fallback stays working until the daemon is up. - `public/components/profiles.js` — prefers `profile.list` over the WS when the daemon is reachable; falls back to `/api/profiles` otherwise. - `public/components/overview.js` — augments `/api/overview` with live `health.get` + `agent.list` results from the daemon. - `src/api.ts` — new `daemonToken` handler (localhost-only) plus `readDaemonRootToken` + `isLocalRequest` helpers. - `src/server.ts` — registers `/api/daemon-token` and proxies `/ws` upgrades that carry the `arc-daemon` subprotocol to 127.0.0.1:7272. Rewrites the daemon's 101 response to echo the subprotocol (strict WS clients reject handshakes that don't). Returns 502 when the daemon is offline so the dashboard degrades cleanly to HTTP. - `tests/bridge.test.ts` (new, 7 tests) — proves the bridge codec round-trips byte-identically with the authoritative client codec, and exercises the `daemonToken` helpers. Typecheck clean; all 31 dashboard tests pass. E2E smoke verified: auth.login + health.get + profile.list all succeed through the proxy; `/api/daemon-token` returns 503 and `/ws` returns 502 when the daemon isn't running. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../public/components/arc-client-bridge.js | 367 ++++++++++++++++++ .../dashboard/public/components/overview.js | 38 +- .../dashboard/public/components/profiles.js | 35 +- packages/dashboard/public/index.html | 1 + packages/dashboard/src/api.ts | 61 +++ packages/dashboard/src/server.ts | 154 +++++++- packages/dashboard/tests/bridge.test.ts | 160 ++++++++ 7 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 packages/dashboard/public/components/arc-client-bridge.js create mode 100644 packages/dashboard/tests/bridge.test.ts diff --git a/packages/dashboard/public/components/arc-client-bridge.js b/packages/dashboard/public/components/arc-client-bridge.js new file mode 100644 index 0000000..1784741 --- /dev/null +++ b/packages/dashboard/public/components/arc-client-bridge.js @@ -0,0 +1,367 @@ +// ARC Dashboard — Daemon WebSocket Bridge +// +// Tiny vanilla-JS shim that speaks the same binary-mux protocol as +// `@axiom-labs/arc-client` (see packages/client/src/frame.ts + +// packages/client/src/protocol.ts). Loaded as an ES module from +// index.html; also exposes `window.ArcClient` for non-module callers. +// +// Frame layout (matches packages/client/src/frame.ts): +// +// ┌────┬──────┬────────────┬──────────────────┐ +// │ ch │ flag │ len (u32be)│ payload (N bytes)│ +// │ 1B │ 1B │ 4B │ │ +// └────┴──────┴────────────┴──────────────────┘ +// +// Control envelopes (channel 0) are JSON: +// { v:1, id, type:"request"|"response"|"event"|"subscribe"|"unsubscribe"|"error", +// method?, params?, result?, topic?, payload?, code?, message? } + +export const Channel = Object.freeze({ + Control: 0x00, + Terminal: 0x01, + File: 0x02, + Audio: 0x03, +}); + +export const PROTOCOL_VERSION = 1; + +// --- Frame codec ----------------------------------------------------------- + +export function encodeFrame(channel, flags, payload) { + const len = payload.length; + const out = new Uint8Array(6 + len); + out[0] = channel & 0xff; + out[1] = flags & 0xff; + out[2] = (len >>> 24) & 0xff; + out[3] = (len >>> 16) & 0xff; + out[4] = (len >>> 8) & 0xff; + out[5] = len & 0xff; + out.set(payload, 6); + return out; +} + +export function decodeFrame(buf) { + if (buf.length < 6) throw new Error('frame too short'); + const channel = buf[0]; + const flags = buf[1]; + const len = (buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]; + if (buf.length < 6 + len) throw new Error('frame truncated'); + return { channel, flags, payload: buf.subarray(6, 6 + len) }; +} + +export function encodeControl(obj) { + const json = JSON.stringify(obj); + const payload = new TextEncoder().encode(json); + return encodeFrame(Channel.Control, 0, payload); +} + +export function decodeControlPayload(payload) { + return JSON.parse(new TextDecoder().decode(payload)); +} + +// --- Client ---------------------------------------------------------------- + +class ArcClientBridge { + constructor() { + this._ws = null; + this._pending = new Map(); + this._topicHandlers = new Map(); + this._terminalHandler = null; + this._idCounter = 0; + this._url = null; + this._token = null; + this._connected = false; + this._closed = false; + this._reconnectTimer = null; + this._reconnectAttempt = 0; + } + + /** + * Open a WebSocket to the dashboard `/ws` proxy (preferred) or the + * supplied URL, then authenticate with `token`. + * Returns a promise that resolves once the login RPC succeeds. + */ + async connect(token, url) { + if (this._connected) return; + this._token = token || null; + this._url = url || this._defaultUrl(); + this._closed = false; + await this._openSocket(this._url); + if (this._token) { + try { + await this._callRaw('auth.login', { token: this._token }); + } catch (err) { + this._connected = false; + this._ws?.close(); + this._ws = null; + throw err; + } + } + this._connected = true; + // Resubscribe after reconnect. + for (const topic of this._topicHandlers.keys()) { + try { + await this._sendSubscribe(topic); + } catch { + /* best effort */ + } + } + } + + close() { + this._closed = true; + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + if (this._ws) { + try { + this._ws.close(); + } catch { + /* ignore */ + } + } + this._ws = null; + this._connected = false; + } + + /** RPC call — returns a promise resolving to the result. */ + async call(method, params) { + if (!this._ws || this._ws.readyState !== 1 /* OPEN */) { + throw new Error('ArcClient: not connected'); + } + return this._callRaw(method, params); + } + + /** Subscribe to a topic. Returns an async unsubscribe function. */ + async subscribe(topic, handler) { + let set = this._topicHandlers.get(topic); + const first = !set; + if (!set) { + set = new Set(); + this._topicHandlers.set(topic, set); + } + set.add(handler); + if (first) await this._sendSubscribe(topic); + return async () => { + set.delete(handler); + if (set.size === 0) { + this._topicHandlers.delete(topic); + try { + await this._sendUnsubscribe(topic); + } catch { + /* connection closed — no-op */ + } + } + }; + } + + attachTerminal(handler) { + this._terminalHandler = handler || null; + } + + isConnected() { + return this._connected && this._ws?.readyState === 1; + } + + // --- Internals ---------------------------------------------------------- + + _defaultUrl() { + const loc = globalThis.location; + if (loc) { + const proto = loc.protocol === 'https:' ? 'wss' : 'ws'; + return `${proto}://${loc.host}/ws`; + } + return 'ws://127.0.0.1:7272'; + } + + _nextId() { + this._idCounter = (this._idCounter + 1) & 0xffffff; + return `${Date.now().toString(36)}-${this._idCounter.toString(36)}`; + } + + _openSocket(url) { + return new Promise((resolve, reject) => { + let ws; + try { + // Signal to the dashboard server that this upgrade should be + // proxied to the daemon (vs. handed to the legacy text-frame WS). + ws = new WebSocket(url, ['arc-daemon']); + } catch (err) { + reject(err); + return; + } + ws.binaryType = 'arraybuffer'; + const onOpen = () => { + ws.removeEventListener('error', onError); + this._ws = ws; + this._reconnectAttempt = 0; + resolve(); + }; + const onError = (ev) => { + ws.removeEventListener('open', onOpen); + reject(new Error(`WebSocket error: ${ev?.message || 'failed to connect'}`)); + }; + ws.addEventListener('open', onOpen, { once: true }); + ws.addEventListener('error', onError, { once: true }); + ws.addEventListener('message', (ev) => this._handleMessage(ev.data)); + ws.addEventListener('close', () => this._handleClose()); + }); + } + + _handleMessage(data) { + let buf; + if (data instanceof ArrayBuffer) { + buf = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + buf = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } else { + // Text frame — ignore; protocol is binary. + return; + } + let frame; + try { + frame = decodeFrame(buf); + } catch { + return; + } + if (frame.channel === Channel.Control) { + let envelope; + try { + envelope = decodeControlPayload(frame.payload); + } catch { + return; + } + this._handleEnvelope(envelope); + return; + } + if (frame.channel === Channel.Terminal && this._terminalHandler) { + this._terminalHandler('', frame.payload); + } + } + + _handleEnvelope(envelope) { + if (!envelope || typeof envelope !== 'object') return; + if (envelope.type === 'response' || envelope.type === 'error') { + const pending = this._pending.get(envelope.id); + if (!pending) return; + this._pending.delete(envelope.id); + if (envelope.type === 'error') { + const err = new Error(envelope.message || 'rpc error'); + err.code = envelope.code; + pending.reject(err); + } else { + pending.resolve(envelope.result); + } + return; + } + if (envelope.type === 'event' && envelope.topic) { + const set = this._topicHandlers.get(envelope.topic); + if (!set) return; + for (const h of set) { + try { h(envelope.payload); } catch { /* swallow handler errors */ } + } + } + } + + _handleClose() { + this._ws = null; + this._connected = false; + for (const [id, pending] of this._pending) { + pending.reject(new Error('connection closed')); + this._pending.delete(id); + } + if (this._closed || !this._token) return; + // Exponential backoff, capped at 15s. + const delay = Math.min(15000, 500 * 2 ** this._reconnectAttempt); + this._reconnectAttempt += 1; + this._reconnectTimer = setTimeout(() => { + this.connect(this._token, this._url).catch(() => { + /* next close will retry */ + }); + }, delay); + } + + _callRaw(method, params) { + const id = this._nextId(); + const envelope = { v: PROTOCOL_VERSION, id, type: 'request', method, params }; + const frame = encodeControl(envelope); + return new Promise((resolve, reject) => { + this._pending.set(id, { resolve, reject }); + try { + this._ws.send(frame); + } catch (err) { + this._pending.delete(id); + reject(err); + } + }); + } + + _sendSubscribe(topic) { + const id = this._nextId(); + return new Promise((resolve, reject) => { + this._pending.set(id, { resolve: () => resolve(), reject }); + try { + this._ws.send(encodeControl({ v: PROTOCOL_VERSION, id, type: 'subscribe', topic })); + } catch (err) { + this._pending.delete(id); + reject(err); + } + }); + } + + _sendUnsubscribe(topic) { + const id = this._nextId(); + return new Promise((resolve, reject) => { + this._pending.set(id, { resolve: () => resolve(), reject }); + try { + this._ws.send(encodeControl({ v: PROTOCOL_VERSION, id, type: 'unsubscribe', topic })); + } catch (err) { + this._pending.delete(id); + reject(err); + } + }); + } +} + +// --- Module singleton + bootstrap ------------------------------------------ + +const bridge = new ArcClientBridge(); + +/** + * Lazy initializer — fetches the daemon token from the dashboard and opens + * the WebSocket. Returns the shared bridge instance once connected, or + * `null` if the daemon isn't reachable (HTTP fallback should kick in). + * + * Failures (token missing, WS refused, etc.) resolve to `null` without + * caching, so a later render can retry once the daemon comes up. A live + * connection IS cached — subsequent callers get the same bridge cheaply. + */ +let livePromise = null; +export function ensureArcClient() { + if (bridge.isConnected()) return Promise.resolve(bridge); + if (livePromise) return livePromise; + livePromise = (async () => { + try { + const res = await fetch('/api/daemon-token'); + if (!res.ok) return null; + const body = await res.json(); + if (!body || typeof body.token !== 'string') return null; + await bridge.connect(body.token); + return bridge; + } catch { + return null; + } finally { + // Allow retry on the next call regardless of outcome; a successful + // connection short-circuits above via `bridge.isConnected()`. + livePromise = null; + } + })(); + return livePromise; +} + +export const ArcClient = bridge; + +// Also expose globally for non-module scripts. +if (typeof globalThis !== 'undefined') { + globalThis.ArcClient = bridge; + globalThis.ensureArcClient = ensureArcClient; +} diff --git a/packages/dashboard/public/components/overview.js b/packages/dashboard/public/components/overview.js index 4bb8aa4..1a4a14a 100644 --- a/packages/dashboard/public/components/overview.js +++ b/packages/dashboard/public/components/overview.js @@ -2,6 +2,26 @@ import { api } from '../scripts/api.js'; import { registerView } from '../scripts/router.js'; import { escapeHtml } from '../scripts/utils.js'; +import { ensureArcClient } from './arc-client-bridge.js'; + +/** + * Fetch daemon-side health + agent summary via the WS SDK when possible. + * Returns null when the daemon isn't reachable — callers fall back to + * the HTTP-backed /api/overview route. + */ +async function loadDaemonSnapshot() { + const client = await ensureArcClient(); + if (!client) return null; + try { + const [health, agents] = await Promise.all([ + client.call('health.get'), + client.call('agent.list'), + ]); + return { health, agents: Array.isArray(agents?.agents) ? agents.agents : [] }; + } catch { + return null; + } +} function heroCard(label, value, unit, status) { const colorClass = status === 'ok' ? '' : status === 'warn' ? ' stat-row__value--warning' : status === 'error' ? ' stat-row__value--error' : ''; @@ -123,17 +143,29 @@ function traceRow(trace) { } async function render() { - // Fetch overview, traces, and hooks in parallel - const [overviewResult, tracesResult, hooksResult] = await Promise.allSettled([ + // Fetch overview, traces, hooks, and (if the daemon is up) its snapshot + // in parallel. Any failure falls back to empty / defaults. + const [overviewResult, tracesResult, hooksResult, daemonResult] = await Promise.allSettled([ api.overview(), api.traces('', 5), - api.hooks() + api.hooks(), + loadDaemonSnapshot(), ]); const data = overviewResult.status === 'fulfilled' ? overviewResult.value : { sessions: { active: 0, total: 0 }, tasks: { working: 0, completed: 0, total: 0 }, skills: { total: 0 }, agents: { online: 0, total: 0 }, factory: null, health: 'ok' }; let traces = tracesResult.status === 'fulfilled' ? tracesResult.value : []; if (!Array.isArray(traces)) traces = []; const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : null; + const daemon = daemonResult.status === 'fulfilled' ? daemonResult.value : null; + + // Prefer daemon-reported agent/health numbers when available — these are + // the canonical v3 source-of-truth; the HTTP /api/overview path is a + // legacy snapshot from ~/.arc/*.json. + if (daemon) { + const running = daemon.agents.filter((a) => a.status === 'running').length; + data.agents = { ...(data.agents ?? {}), online: running, total: daemon.agents.length }; + if (daemon.health?.ok) data.health = 'ok'; + } const s = data.sessions || {}; const t = data.tasks || {}; diff --git a/packages/dashboard/public/components/profiles.js b/packages/dashboard/public/components/profiles.js index 05009bc..3e1cdcd 100644 --- a/packages/dashboard/public/components/profiles.js +++ b/packages/dashboard/public/components/profiles.js @@ -2,6 +2,39 @@ import { api } from '../scripts/api.js'; import { registerView, navigateTo } from '../scripts/router.js'; import { escapeHtml } from '../scripts/utils.js'; +import { ensureArcClient } from './arc-client-bridge.js'; + +/** + * Fetch profiles via the daemon SDK (binary-mux WS) when available, + * falling back to the existing HTTP endpoint when the daemon isn't + * running. The two shapes differ — the daemon returns + * { profiles: [{ name, tool, active }] } + * while /api/profiles returns the richer dashboard-enhanced rows. + */ +async function loadProfiles() { + const client = await ensureArcClient(); + if (client) { + try { + const res = await client.call('profile.list'); + if (res && Array.isArray(res.profiles)) { + return res.profiles.map((p) => ({ + name: p.name, + tool: p.tool, + active: Boolean(p.active), + authType: p.authType ?? 'unknown', + configDir: p.configDir ?? '', + description: p.description ?? '', + createdAt: p.createdAt ?? '', + useShared: p.useShared ?? false, + inherits: p.inherits ?? null, + })); + } + } catch { + // Fall through to HTTP. + } + } + return api.profiles(); +} const TOOL_LABELS = { claude: 'Claude Code', @@ -94,7 +127,7 @@ function attachActionHandlers() { async function render() { let profiles; try { - profiles = await api.profiles(); + profiles = await loadProfiles(); } catch { profiles = []; } diff --git a/packages/dashboard/public/index.html b/packages/dashboard/public/index.html index 317aa7e..7d8219b 100644 --- a/packages/dashboard/public/index.html +++ b/packages/dashboard/public/index.html @@ -81,6 +81,7 @@ + diff --git a/packages/dashboard/src/api.ts b/packages/dashboard/src/api.ts index bbd3252..5a6f660 100644 --- a/packages/dashboard/src/api.ts +++ b/packages/dashboard/src/api.ts @@ -12,6 +12,7 @@ import { queryLogEvents, getRoundtablesDir, getPipelinesDir, + getArcDir, type RiskTier, } from "@axiom-labs/arc-core"; import type { DashboardContext } from "./types.js"; @@ -237,6 +238,44 @@ function parseJsonBody(req: IncomingMessage): Promise | }); } +// --------------------------------------------------------------------------- +// Daemon token helpers — expose the daemon's root token to browser-side code +// so it can open an authenticated WebSocket through the `/ws` proxy. +// Readable only from localhost to avoid leaking the secret. +// --------------------------------------------------------------------------- + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +/** + * True iff the HTTP request originated on the loopback interface. + * Checks both the Host header (server-side mount) and remoteAddress. + */ +export function isLocalRequest(req: IncomingMessage): boolean { + const host = (req.headers.host ?? "").split(":")[0].toLowerCase(); + if (host && LOOPBACK_HOSTS.has(host)) return true; + const addr = req.socket.remoteAddress ?? ""; + // Strip IPv6-mapped-IPv4 prefix like `::ffff:127.0.0.1`. + const normalized = addr.startsWith("::ffff:") ? addr.slice(7) : addr; + return LOOPBACK_HOSTS.has(normalized); +} + +/** + * Read `~/.arc/auth.json` (written by the daemon on first run) and return + * the rootToken. Returns null if the file is missing / malformed. + */ +export function readDaemonRootToken(arcDir: string = getArcDir()): string | null { + try { + const raw = fs.readFileSync(path.join(arcDir, "auth.json"), "utf8"); + const parsed = JSON.parse(raw) as { rootToken?: unknown; v?: unknown }; + if (parsed.v === 1 && typeof parsed.rootToken === "string" && parsed.rootToken.length >= 16) { + return parsed.rootToken; + } + return null; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Risk tier ordering (for distribution aggregation) // --------------------------------------------------------------------------- @@ -701,6 +740,28 @@ export function createApiHandlers(ctx: DashboardContext) { json(res, { token: null }); }, + // ------------------------------------------------------------------- + // GET /api/daemon-token — returns the daemon rootToken (localhost only) + // + // Used by the in-browser ArcClient bridge to authenticate the + // WebSocket it opens through `/ws`. Refuses any non-loopback request + // to prevent exfiltration of the token via DNS rebinding. + // ------------------------------------------------------------------- + daemonToken(req: IncomingMessage, res: ServerResponse): void { + if (!isLocalRequest(req)) { + return errorJson(res, "Forbidden — daemon token is localhost-only", 403); + } + const token = readDaemonRootToken(); + if (!token) { + return errorJson( + res, + "Daemon auth.json not found — is `arc daemon start` running?", + 503, + ); + } + json(res, { token }); + }, + // ------------------------------------------------------------------- // POST /api/chat/message // ------------------------------------------------------------------- diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 52a3aab..4f63a5b 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -8,6 +8,7 @@ // --------------------------------------------------------------------------- import http from "node:http"; +import net from "node:net"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -215,6 +216,10 @@ export function createDashboardServer( json(res, { token }); }); + // Daemon token endpoint — returns the daemon's rootToken so in-browser + // code can open an authenticated WS through the `/ws` proxy. Localhost-only. + router.add("GET", "/api/daemon-token", api.daemonToken); + // Mutation routes router.add("POST", "/api/profiles/:name/switch", api.switchProfile); router.add("DELETE", "/api/profiles/:name", api.deleteProfile); @@ -340,10 +345,28 @@ export function createDashboardServer( }); // Wire WebSocket upgrade. + // + // Path & subprotocol routing: + // /ws + protocol contains "arc-daemon" → proxy raw bytes to daemon + // /ws + no matching protocol → legacy dashboard text WS + // other paths → legacy dashboard text WS server.on("upgrade", (req, socket, head) => { const pathname = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname; + const protoHeader = (req.headers["sec-websocket-protocol"] ?? "") as string; + const wantsDaemon = + pathname === "/ws" && + protoHeader + .split(",") + .map((s) => s.trim()) + .includes("arc-daemon"); if (logRequests) { - process.stderr.write(`\x1b[36m[dash] UPGRADE ${pathname}\x1b[0m\n`); + process.stderr.write( + `\x1b[36m[dash] UPGRADE ${pathname}${wantsDaemon ? " → daemon-proxy" : ""}\x1b[0m\n`, + ); + } + if (wantsDaemon) { + proxyUpgradeToDaemon(req, socket, head); + return; } try { wsServer.handleUpgrade(req, socket, head); @@ -436,3 +459,132 @@ function json(res: http.ServerResponse, data: unknown, status = 200): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); } + +// --------------------------------------------------------------------------- +// Daemon WebSocket proxy — forwards a browser-initiated upgrade on `/ws` to +// the local daemon at 127.0.0.1:. We open a raw TCP socket, +// replay the client's HTTP upgrade request verbatim, and pipe both +// directions. The daemon produces the 101 response itself. +// +// If the daemon socket can't be reached, we answer the client with a bare +// HTTP/1.1 502 and destroy the connection — so dashboards without a daemon +// running degrade to the HTTP fallback cleanly. +// --------------------------------------------------------------------------- + +/** + * Pick the first subprotocol we recognize from the client's offered list. + * Today that's just `arc-daemon` (the sentinel the bridge uses to request + * daemon routing). Returns null if the client didn't offer anything. + */ +function pickSubprotocol(headerValue: string | null): string | null { + if (!headerValue) return null; + for (const raw of headerValue.split(",")) { + const name = raw.trim(); + if (name === "arc-daemon") return name; + } + return null; +} + +function proxyUpgradeToDaemon( + req: http.IncomingMessage, + socket: import("node:stream").Duplex, + head: Buffer, +): void { + const envPort = Number.parseInt(process.env["ARC_PORT"] ?? "", 10); + const daemonPort = Number.isFinite(envPort) && envPort > 0 ? envPort : 7272; + const daemonHost = process.env["ARC_HOST"] ?? "127.0.0.1"; + + const upstream = net.connect({ host: daemonHost, port: daemonPort }); + let upstreamConnected = false; + + const refuse = (message: string): void => { + process.stderr.write( + `\x1b[33m[dash] daemon proxy: ${message} (${daemonHost}:${daemonPort})\x1b[0m\n`, + ); + try { + socket.write( + "HTTP/1.1 502 Bad Gateway\r\n" + + "Content-Type: text/plain\r\n" + + "Connection: close\r\n" + + "\r\n" + + `daemon unreachable at ${daemonHost}:${daemonPort}\n`, + ); + } catch { + /* socket may already be destroyed */ + } + socket.destroy(); + try { upstream.destroy(); } catch { /* ignore */ } + }; + + upstream.once("error", (err) => { + if (!upstreamConnected) refuse(err.message); + else socket.destroy(); + }); + socket.once("error", () => { + try { upstream.destroy(); } catch { /* ignore */ } + }); + + upstream.once("connect", () => { + upstreamConnected = true; + // Re-serialize the inbound upgrade request and forward to daemon. + // The daemon checks the Host header for loopback — override it so it + // matches the bind regardless of what the browser sent. We also + // strip `sec-websocket-protocol` because the daemon's handshake + // doesn't echo subprotocols; we re-inject the header ourselves into + // the daemon's 101 response below so the strict browser/ws client + // sees the subprotocol it asked for. + const clientProtoHeader = + (req.headers["sec-websocket-protocol"] as string | undefined) ?? null; + const pathAndQuery = req.url ?? "/"; + const headerLines = [`GET ${pathAndQuery} HTTP/1.1`, `Host: 127.0.0.1:${daemonPort}`]; + for (const [name, value] of Object.entries(req.headers)) { + if (!value) continue; + const lower = name.toLowerCase(); + if (lower === "host" || lower === "sec-websocket-protocol") continue; + if (Array.isArray(value)) { + for (const v of value) headerLines.push(`${name}: ${v}`); + } else { + headerLines.push(`${name}: ${value}`); + } + } + headerLines.push("", ""); + upstream.write(headerLines.join("\r\n")); + if (head.length > 0) upstream.write(head); + + // Buffer and rewrite the daemon's 101 response so we can inject + // `Sec-WebSocket-Protocol: arc-daemon` — required because strict WS + // clients (browser, `ws` npm) reject handshakes that don't echo the + // subprotocol they requested. + let handshakeBuf = Buffer.alloc(0); + let handshakeDone = false; + const selectedProtocol = pickSubprotocol(clientProtoHeader); + + const onHandshakeData = (chunk: Buffer): void => { + if (handshakeDone) { + socket.write(chunk); + return; + } + handshakeBuf = Buffer.concat([handshakeBuf, chunk]); + const sep = handshakeBuf.indexOf("\r\n\r\n"); + if (sep === -1) return; + const headerBlock = handshakeBuf.subarray(0, sep).toString("latin1"); + const rest = handshakeBuf.subarray(sep + 4); + const lines = headerBlock.split("\r\n"); + const hasProtoHeader = lines.some((l) => /^sec-websocket-protocol:/i.test(l)); + const augmented = + !hasProtoHeader && selectedProtocol + ? [...lines, `Sec-WebSocket-Protocol: ${selectedProtocol}`] + : lines; + const rewritten = augmented.join("\r\n") + "\r\n\r\n"; + socket.write(rewritten); + if (rest.length > 0) socket.write(rest); + handshakeDone = true; + upstream.removeListener("data", onHandshakeData); + // Switch to raw piping in both directions from here on. + upstream.pipe(socket); + }; + + upstream.on("data", onHandshakeData); + socket.pipe(upstream); + }); +} diff --git a/packages/dashboard/tests/bridge.test.ts b/packages/dashboard/tests/bridge.test.ts new file mode 100644 index 0000000..a09fbb2 --- /dev/null +++ b/packages/dashboard/tests/bridge.test.ts @@ -0,0 +1,160 @@ +// Tests for the dashboard's daemon bridge. +// +// We can't import the browser-side `arc-client-bridge.js` directly under +// Node because it expects `window.WebSocket`. What we CAN verify is that +// its wire format matches the canonical client codec — because that's the +// whole point of the shim. Read the shim source and exercise its exported +// codec functions under a minimal shim for DOM globals. +// +// Also sanity-checks the dashboard helpers that expose the daemon token +// over HTTP (`readDaemonRootToken`, `isLocalRequest`). + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, beforeAll, afterAll } from "vitest"; + +import { encodeFrame, decodeFrame, encodeControl, Channel } from "@axiom-labs/arc-client"; +import { isLocalRequest, readDaemonRootToken } from "../src/api.js"; + +// --------------------------------------------------------------------------- +// The bridge is a browser module. Strip the `export` keywords + require +// `ArcClient` instance under a Node sandbox that provides a global +// `WebSocket` stub. This proves the codec round-trips identically with the +// authoritative Zod-validated client codec. +// --------------------------------------------------------------------------- + +const bridgePath = fileURLToPath( + new URL("../public/components/arc-client-bridge.js", import.meta.url), +); + +async function loadBridgeExports(): Promise<{ + encodeFrame: (channel: number, flags: number, payload: Uint8Array) => Uint8Array; + decodeFrame: (buf: Uint8Array) => { channel: number; flags: number; payload: Uint8Array }; + encodeControl: (obj: unknown) => Uint8Array; + decodeControlPayload: (payload: Uint8Array) => unknown; + Channel: Record; + PROTOCOL_VERSION: number; +}> { + // Provide a no-op WebSocket global so the module loads without errors. + type WsStubCtor = new (url: string, protocols?: string | string[]) => unknown; + const stub: WsStubCtor = class { + constructor(_url: string, _protocols?: string | string[]) { + /* unused */ + } + }; + (globalThis as { WebSocket?: WsStubCtor }).WebSocket = stub; + const mod = (await import( + /* @vite-ignore */ `file://${bridgePath.replace(/\\/g, "/")}` + )) as { + encodeFrame: (channel: number, flags: number, payload: Uint8Array) => Uint8Array; + decodeFrame: (buf: Uint8Array) => { channel: number; flags: number; payload: Uint8Array }; + encodeControl: (obj: unknown) => Uint8Array; + decodeControlPayload: (payload: Uint8Array) => unknown; + Channel: Record; + PROTOCOL_VERSION: number; + }; + return mod; +} + +describe("arc-client-bridge frame codec", () => { + it("round-trips arbitrary payloads identically to @axiom-labs/arc-client", async () => { + const bridge = await loadBridgeExports(); + const payloads = [ + new Uint8Array(0), + new Uint8Array([1, 2, 3]), + new TextEncoder().encode('{"v":1,"id":"abc","type":"request","method":"health.get"}'), + new Uint8Array(1024).fill(0x7f), + ]; + for (const p of payloads) { + const bridgeEncoded = bridge.encodeFrame(bridge.Channel.Control, 0, p); + const clientEncoded = encodeFrame({ channel: 0, flags: 0, payload: p }); + expect(Array.from(bridgeEncoded)).toEqual(Array.from(clientEncoded)); + + const bridgeDecoded = bridge.decodeFrame(bridgeEncoded); + const clientDecoded = decodeFrame(clientEncoded); + expect(bridgeDecoded.channel).toBe(clientDecoded.channel); + expect(bridgeDecoded.flags).toBe(clientDecoded.flags); + expect(Array.from(bridgeDecoded.payload)).toEqual(Array.from(clientDecoded.payload)); + } + }); + + it("encodes control envelopes byte-for-byte matching the canonical codec", async () => { + const bridge = await loadBridgeExports(); + const envelope = { v: 1, id: "42", type: "request", method: "health.get" }; + const fromBridge = bridge.encodeControl(envelope); + const fromClient = encodeControl(envelope); + expect(Array.from(fromBridge)).toEqual(Array.from(fromClient)); + }); + + it("uses channel 0 for control and respects the 6-byte header", async () => { + const bridge = await loadBridgeExports(); + expect(bridge.Channel.Control).toBe(Channel.Control); + expect(bridge.PROTOCOL_VERSION).toBe(1); + const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const encoded = bridge.encodeFrame(bridge.Channel.Control, 0, payload); + expect(encoded[0]).toBe(0); // channel + expect(encoded[1]).toBe(0); // flags + expect(encoded[2]).toBe(0); + expect(encoded[3]).toBe(0); + expect(encoded[4]).toBe(0); + expect(encoded[5]).toBe(4); // length + expect(Array.from(encoded.subarray(6))).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); +}); + +// --------------------------------------------------------------------------- +// daemonToken server helpers +// --------------------------------------------------------------------------- + +describe("dashboard daemon token helpers", () => { + let tmp: string; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "arc-dashboard-test-")); + }); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("readDaemonRootToken returns null when auth.json is missing", () => { + expect(readDaemonRootToken(tmp)).toBeNull(); + }); + + it("readDaemonRootToken returns the token when auth.json is valid", () => { + const token = "a".repeat(64); + fs.writeFileSync(path.join(tmp, "auth.json"), JSON.stringify({ v: 1, rootToken: token })); + expect(readDaemonRootToken(tmp)).toBe(token); + }); + + it("readDaemonRootToken rejects malformed / short tokens", () => { + fs.writeFileSync(path.join(tmp, "auth.json"), JSON.stringify({ v: 1, rootToken: "short" })); + expect(readDaemonRootToken(tmp)).toBeNull(); + fs.writeFileSync(path.join(tmp, "auth.json"), "{ not json"); + expect(readDaemonRootToken(tmp)).toBeNull(); + }); + + it("isLocalRequest recognises loopback hosts and remote addresses", () => { + // Fake IncomingMessage shapes — isLocalRequest only reads headers + socket. + const make = ( + host: string | undefined, + remoteAddress: string, + ): import("node:http").IncomingMessage => + ({ + headers: host === undefined ? {} : { host }, + socket: { remoteAddress } as import("node:net").Socket, + }) as unknown as import("node:http").IncomingMessage; + + expect(isLocalRequest(make("localhost:3700", "127.0.0.1"))).toBe(true); + expect(isLocalRequest(make("127.0.0.1:3700", "127.0.0.1"))).toBe(true); + expect(isLocalRequest(make("[::1]:3700", "::1"))).toBe(true); + // No host header — falls back to remoteAddress. + expect(isLocalRequest(make(undefined, "127.0.0.1"))).toBe(true); + expect(isLocalRequest(make(undefined, "::ffff:127.0.0.1"))).toBe(true); + // Non-loopback. + expect(isLocalRequest(make("evil.example:3700", "203.0.113.7"))).toBe(false); + expect(isLocalRequest(make(undefined, "203.0.113.7"))).toBe(false); + }); +});