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);
+ });
+});