feat(dashboard-auth): SPA WS auth — getWsTicket() + buildWsAuthParam()

Phase 5 task 5.3. The dashboard's three WS-using surfaces (ChatPage,
gatewayClient, ChatSidebar) previously hardcoded ?token=<session>. In
gated mode the server rejects that path; the SPA must mint a single-use
ticket via POST /api/auth/ws-ticket and pass ?ticket= on the upgrade.

web/src/lib/api.ts: adds getWsTicket() (POST /api/auth/ws-ticket with
credentials: 'include') and buildWsAuthParam() — a helper that returns
['ticket', <minted>] in gated mode and ['token', <session>] in loopback.
Window.__HERMES_AUTH_REQUIRED__ is read from the server-injected
bootstrap script and toggles the path. Documented as the bridge from
cookie auth (REST) to WS auth.

web/src/pages/ChatPage.tsx: buildWsUrl() now takes an [authName, authValue]
pair instead of a bare token. The WS construct is wrapped in an IIFE so
the outer effect can stay synchronous (the cleanup returns the effect's
disposer at top level). onDataDisposable + onResizeDisposable hoisted to
`let` bindings the cleanup closes over.

web/src/lib/gatewayClient.ts: connect() branches on
window.__HERMES_AUTH_REQUIRED__ before opening /api/ws. Explicit token
overrides win (test-only path); otherwise gated → fetch ticket, loopback
→ use injected session token.

web/src/components/ChatSidebar.tsx: events-feed WS opens through the
same IIFE pattern as ChatPage. The ws local is hoisted so the cleanup's
ws?.close() works after the async mint resolves.

Server side already injects window.__HERMES_AUTH_REQUIRED__ in
_serve_index (Phase 3.5).
This commit is contained in:
Ben 2026-05-21 16:12:34 +10:00
parent bb72e4614a
commit 1c99c2f5eb
4 changed files with 141 additions and 63 deletions

View file

@ -30,7 +30,7 @@ import { Card } from "@/components/ui/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { HERMES_BASE_PATH } from "@/lib/api";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
@ -152,36 +152,44 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
useEffect(() => {
const token = window.__HERMES_SESSION_TOKEN__;
if (!token || !channel) {
if (!channel) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
const ws = new WebSocket(
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
// In loopback mode the legacy ?token=<session> path is fine; in gated
// mode we have to mint a single-use ticket from the cookie. The IIFE
// keeps the outer effect synchronous so its ``return cleanup`` stays
// at the top level; the local ``ws`` is hoisted to a closed-over
// binding the cleanup reads via ``wsRef``.
let unmounting = false;
const surface = (msg: string) => !unmounting && setError(msg);
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
surface(DISCONNECTED);
let ws: WebSocket | null = null;
void (async () => {
const [authName, authValue] = await buildWsAuthParam();
if (!authValue || unmounting) {
return;
}
});
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ [authName]: authValue, channel });
ws = new WebSocket(
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);
ws.addEventListener("message", (ev) => {
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
const surface = (msg: string) => !unmounting && setError(msg);
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
surface(DISCONNECTED);
}
});
ws.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
try {
@ -265,11 +273,12 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) {
),
);
}
});
});
})();
return () => {
unmounting = true;
ws.close();
ws?.close();
};
}, [channel, version]);

View file

@ -25,6 +25,11 @@ declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_BASE_PATH__?: string;
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
* engaged (public bind, no ``--insecure``). Toggles the SPA's
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
* fetched via :func:`getWsTicket`. */
__HERMES_AUTH_REQUIRED__?: boolean;
}
}
let _sessionToken: string | null = null;
@ -61,6 +66,43 @@ async function getSessionToken(): Promise<string> {
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
}
/**
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
*
* The dashboard's gated-mode WS auth (``hermes_cli.web_server._ws_auth_ok``)
* rejects the legacy ``?token=<_SESSION_TOKEN>`` path and only accepts
* ``?ticket=<minted>`` consumed against the in-memory ticket store. Browsers
* can't set ``Authorization`` on a WS upgrade, so this round-trip via the
* authenticated REST endpoint is the bridge from cookie auth to WS auth.
*
* Tickets are single-use and TTL=30s every WS connect attempt must
* fetch a fresh ticket.
*/
export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
const res = await fetch(`${BASE}/api/auth/ws-ticket`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);
}
return res.json();
}
/**
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
* connect. In gated mode mints a fresh single-use ticket; in loopback
* mode returns the injected session token.
*/
export async function buildWsAuthParam(): Promise<[string, string]> {
if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
return ["ticket", ticket];
}
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
return ["token", token];
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: (limit = 20, offset = 0) =>

View file

@ -13,7 +13,7 @@
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
import { HERMES_BASE_PATH } from "@/lib/api";
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
export type GatewayEventName =
| "gateway.ready"
@ -109,17 +109,32 @@ export class GatewayClient {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
if (!resolved) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
// Explicit ``token`` overrides the gate check (test-only path).
let authParamName: string;
let authParamValue: string;
if (token) {
authParamName = "token";
authParamValue = token;
} else if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
authParamName = "ticket";
authParamValue = ticket;
} else {
authParamName = "token";
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
if (!authParamValue) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?token=${encodeURIComponent(resolved)}`,
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
);
this.ws = ws;
@ -233,5 +248,6 @@ export class GatewayClient {
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}

View file

@ -24,7 +24,7 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Button } from "@nous-research/ui/ui/components/button";
import { Typography } from "@/components/NouiTypography";
import { HERMES_BASE_PATH } from "@/lib/api";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -38,12 +38,15 @@ import { api } from "@/lib/api";
import { PluginSlot } from "@/plugins";
function buildWsUrl(
token: string,
authParam: [string, string],
resume: string | null,
channel: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
// ``authParam`` is ``["token", <session>]`` in loopback mode and
// ``["ticket", <minted>]`` in gated mode. The server-side helper
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
if (resume) qs.set("resume", resume);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
}
@ -544,15 +547,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
});
});
// WebSocket
const url = buildWsUrl(token, resumeParam, channel);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
// Suppress banner/terminal side-effects when cleanup() calls `ws.close()`
// (React StrictMode remount, route change) so we never write to a
// disposed xterm or setState on an unmounted tree.
// WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this
// awaits a single-use ticket via /api/auth/ws-ticket before opening;
// in loopback mode it resolves synchronously against the injected
// session token. The IIFE keeps the outer effect synchronous so its
// ``return cleanup`` stays at the top level; handlers + disposables
// are hoisted to ``let`` bindings the cleanup closes over.
let unmounting = false;
let onDataDisposable: { dispose(): void } | null = null;
let onResizeDisposable: { dispose(): void } | null = null;
void (async () => {
const authParam = await buildWsAuthParam();
if (unmounting) return;
const url = buildWsUrl(authParam, resumeParam, channel);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
setBanner(null);
@ -605,31 +615,32 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// mouse reporting, so we drop SGR mouse reports entirely instead of
// forwarding them into Hermes. Keyboard input, paste, and resize still
// behave normally.
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
const onDataDisposable = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
onDataDisposable = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (SGR_MOUSE_RE.test(data)) {
return;
}
if (SGR_MOUSE_RE.test(data)) {
return;
}
ws.send(data);
});
ws.send(data);
});
const onResizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
}
});
onResizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
}
});
})();
term.focus();
return () => {
unmounting = true;
syncMetricsRef.current = null;
onDataDisposable.dispose();
onResizeDisposable.dispose();
onDataDisposable?.dispose();
onResizeDisposable?.dispose();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(