diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index c311673fafc..3e8ffba64ab 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -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= 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]); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b7e2ba6c575..797dbc01e03 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { 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=`` 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("/api/status"), getSessions: (limit = 20, offset = 0) => diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 9092ef2d32d..16b31ae68a0 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -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; } } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index d257531f23e..a5c1a23a5f7 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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", ]`` in loopback mode and + // ``["ticket", ]`` 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(