From 4ad165521171a34cbe232c615cc6a22ee3455e35 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 16 Jun 2026 16:23:49 +1000 Subject: [PATCH] feat(dashboard): remove SPA dependency on deleted session token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server no longer injects window.__HERMES_SESSION_TOKEN__, so every SPA read of it is dead — and one (the ChatPage banner) was an active regression that would fire 'Session token unavailable' on every loopback load, plus a WS-setup bail that would have prevented the loopback chat WS from wiring up at all. - api.ts: drop _sessionToken/SESSION_HEADER/setSessionHeader/getSessionToken and the X-Hermes-Session-Token injection in fetchJSON + authedFetch; remove the loopback stale-token-401 page-reload block. KEEP credentials:'include' (gated cookie auth) and the gated 401->/login redirect. - buildWsAuthParam: loopback returns no auth param (["",""]); buildWsUrl only appends the param when present -> bare loopback WS URL (matches the server's loopback WS accepting with no credential). Gated still mints ?ticket=. - ChatPage.tsx: remove the spurious 'Session token unavailable' banner and the !token bail that would block loopback chat; banner now driven only by WS onclose errors. - SessionsPage.tsx: drop the X-Hermes-Session-Token export header; keep credentials:'include'. - gatewayClient.ts / ChatSidebar.tsx: loopback connects with no auth param (removed the token-missing bail/throw); gated ?ticket= path preserved. - plugins registry.ts / sdk.d.ts: doc comments updated to cookie/loopback auth. - remove __HERMES_SESSION_TOKEN__ from all Window declare-global blocks; keep __HERMES_AUTH_REQUIRED__ and __HERMES_BASE_PATH__. Verified: grep finds zero __HERMES_SESSION_TOKEN__/X-Hermes-Session-Token in web/src; npx tsc --noEmit is clean. Co-authored-by: Hermes subagent --- web/src/components/ChatSidebar.tsx | 8 +- web/src/lib/api.ts | 157 +++++++---------------------- web/src/lib/gatewayClient.ts | 23 ++--- web/src/pages/ChatPage.tsx | 43 +++----- web/src/pages/SessionsPage.tsx | 5 - web/src/plugins/registry.ts | 8 +- web/src/plugins/sdk.d.ts | 7 +- 7 files changed, 75 insertions(+), 176 deletions(-) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 1a53741d8fd..a0148f9743b 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -184,7 +184,8 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { if (!channel) { return; } - // In loopback mode the legacy ?token= path is fine; in gated + // In loopback mode the WS needs no auth param (the server accepts + // loopback connections on the peer-IP + Host/Origin guard); 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 @@ -193,11 +194,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { let ws: WebSocket | null = null; void (async () => { const [authName, authValue] = await buildWsAuthParam(); - if (!authValue || unmounting) { + if (unmounting) { return; } const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams({ [authName]: authValue, channel }); + const qs = new URLSearchParams({ channel }); + if (authName) qs.set(authName, authValue); ws = new WebSocket( `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`, ); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2a49d5a9f7e..487d33ac71a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -19,27 +19,16 @@ const BASE = HERMES_BASE_PATH; import type { DashboardTheme } from "@/themes/types"; -// Ephemeral session token for protected endpoints. -// Injected into index.html by the server — never fetched via API. 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`. */ + * WS-upgrade path to single-use ``?ticket=`` fetched via + * :func:`getWsTicket`; loopback connects with no auth param. */ __HERMES_AUTH_REQUIRED__?: boolean; } } -let _sessionToken: string | null = null; -const SESSION_HEADER = "X-Hermes-Session-Token"; - -function setSessionHeader(headers: Headers, token: string): void { - if (!headers.has(SESSION_HEADER)) { - headers.set(SESSION_HEADER, token); - } -} // ── Global management-profile scope ────────────────────────────────── // The dashboard is a machine-level management surface: one header switcher @@ -92,19 +81,13 @@ export async function fetchJSON( options?: FetchJSONOptions, ): Promise { url = withManagementProfile(url); - // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); - const token = window.__HERMES_SESSION_TOKEN__; - if (token) { - setSessionHeader(headers, token); - } const res = await fetch(`${BASE}${url}`, { ...init, headers, // ``credentials: 'include'`` so the cookie-auth path (gated mode) works // for any fetch routed through here. Loopback mode is unaffected — the - // server doesn't read cookies and the legacy session-token header is - // already attached above. + // server doesn't read cookies and enforces no identity gate. credentials: init?.credentials ?? "include", }); if (res.status === 401) { @@ -141,43 +124,6 @@ export async function fetchJSON( // Never resolve — the page is about to unload. return new Promise(() => {}); } - // Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart - // (``hermes update``, ``hermes gateway restart``, etc.). A tab kept - // open across the restart holds the OLD token in - // ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render, - // so every fetch returns 401. The HTML is served ``Cache-Control: - // no-store`` so a reload picks up the freshly-injected token. Trigger - // that reload once on the first stale-token 401 — gated mode is - // handled above, so reaching here in gated mode means a real - // middleware failure that should not reload-loop. - if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) { - let alreadyReloaded = false; - try { - alreadyReloaded = - sessionStorage.getItem("hermes.tokenReloadAttempted") === "1"; - } catch { - /* SSR / privacy mode — fall through to throw */ - } - if (!alreadyReloaded) { - try { - sessionStorage.setItem("hermes.tokenReloadAttempted", "1"); - } catch { - /* SSR / privacy mode — best effort */ - } - window.location.reload(); - return new Promise(() => {}); - } - } - } - if (res.ok) { - // Clear the stale-token reload guard: a successful 2xx proves the - // current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next - // 401 — if any — should be allowed to trigger its own reload cycle. - try { - sessionStorage.removeItem("hermes.tokenReloadAttempted"); - } catch { - /* SSR / privacy mode — ignore */ - } } if (!res.ok) { const text = await res.text().catch(() => res.statusText); @@ -191,16 +137,6 @@ function pluginPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } -async function getSessionToken(): Promise { - if (_sessionToken) return _sessionToken; - const injected = window.__HERMES_SESSION_TOKEN__; - if (injected) { - _sessionToken = injected; - return _sessionToken; - } - 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. * @@ -227,15 +163,15 @@ export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: numb /** * 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. + * mode returns an empty pair (the server accepts loopback WS with no auth + * param — peer-IP + Host/Origin guard is the boundary). */ 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]; + return ["", ""]; } /** @@ -244,10 +180,11 @@ export async function buildWsAuthParam(): Promise<[string, string]> { * Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so * the caller can read ``.blob()`` / ``.formData()`` / stream it. * - * Auth, in both modes, exactly as ``fetchJSON`` does it: - * - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header. - * - gated OAuth: no token header (it's absent by design); the - * ``hermes_session_at`` cookie rides along via ``credentials: 'include'``. + * Auth, in both modes: + * - loopback / ``--insecure``: no credential needed; the server enforces + * no identity gate on a loopback bind. + * - gated OAuth: the ``hermes_session_at`` cookie rides along via + * ``credentials: 'include'``. * * Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on * non-2xx (the caller decides — a 404 on a download is meaningful), and @@ -260,10 +197,6 @@ export async function authedFetch( init?: RequestInit, ): Promise { const headers = new Headers(init?.headers); - const token = window.__HERMES_SESSION_TOKEN__; - if (token) { - setSessionHeader(headers, token); - } return fetch(`${BASE}${url}`, { ...init, headers, @@ -274,10 +207,9 @@ export async function authedFetch( /** * Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint, * with the correct auth query param appended for the active mode (fresh - * single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and - * the SPA should use this instead of hand-assembling a WS URL + reading - * ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket - * path can never be forgotten. + * single-use ``ticket`` in gated mode, no auth param in loopback). Plugins + * and the SPA should use this instead of hand-assembling a WS URL, so the + * gated-mode ticket path can never be forgotten. * * ``path`` is the dashboard-relative path (e.g. * ``"/api/plugins/kanban/events"``); the base-path prefix and host are @@ -291,8 +223,11 @@ export async function buildWsUrl( const [authName, authValue] = await buildWsAuthParam(); const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams(params ?? {}); - qs.set(authName, authValue); - return `${proto}//${window.location.host}${BASE}${path}?${qs}`; + if (authName) { + qs.set(authName, authValue); + } + const query = qs.toString(); + return `${proto}//${window.location.host}${BASE}${path}${query ? `?${query}` : ""}`; } /** Build a ``?profile=`` query suffix, or "" when unset. @@ -319,13 +254,8 @@ export const api = { * AuthWidget component swallows 401s from this call: if the gate isn't * engaged, /api/auth/me returns 401 and the widget renders nothing. * - * ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint - * 401s by design, and fetchJSON's default loopback behaviour treats a - * 401 as a rotated session token and full-page-reloads to pick up a - * fresh one. Because every *other* dashboard request succeeds (and so - * clears the one-shot reload guard), that turns this expected 401 into - * an infinite reload loop. Opting out keeps the 401 a plain throw the - * widget can catch. + * ``allowUnauthorized`` keeps the expected loopback 401 a plain throw the + * widget can catch, rather than routing it through any shared 401 handling. */ getAuthMe: () => fetchJSON("/api/auth/me", undefined, { @@ -481,17 +411,14 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), }), - revealEnvVar: async (key: string) => { - const token = await getSessionToken(); - return fetchJSON<{ key: string; value: string }>("/api/env/reveal", { + revealEnvVar: (key: string) => + fetchJSON<{ key: string; value: string }>("/api/env/reveal", { method: "POST", headers: { "Content-Type": "application/json", - [SESSION_HEADER]: token, }, body: JSON.stringify({ key }), - }); - }, + }), // Cron jobs getCronJobs: (profile = "all") => @@ -716,58 +643,46 @@ export const api = { // OAuth provider management getOAuthProviders: () => fetchJSON("/api/providers/oauth"), - disconnectOAuthProvider: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean; provider: string }>( + disconnectOAuthProvider: (providerId: string) => + fetchJSON<{ ok: boolean; provider: string }>( `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, - startOAuthLogin: async (providerId: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + startOAuthLogin: (providerId: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, { method: "POST", headers: { "Content-Type": "application/json", - [SESSION_HEADER]: token, }, body: "{}", }, - ); - }, - submitOAuthCode: async (providerId: string, sessionId: string, code: string) => { - const token = await getSessionToken(); - return fetchJSON( + ), + submitOAuthCode: (providerId: string, sessionId: string, code: string) => + fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, { method: "POST", headers: { "Content-Type": "application/json", - [SESSION_HEADER]: token, }, body: JSON.stringify({ session_id: sessionId, code }), }, - ); - }, + ), pollOAuthSession: (providerId: string, sessionId: string) => fetchJSON( `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`, ), - cancelOAuthSession: async (sessionId: string) => { - const token = await getSessionToken(); - return fetchJSON<{ ok: boolean }>( + cancelOAuthSession: (sessionId: string) => + fetchJSON<{ ok: boolean }>( `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", - headers: { [SESSION_HEADER]: token }, }, - ); - }, + ), // Messaging platforms (gateway channels) getMessagingPlatforms: () => diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 16b31ae68a0..8b111d5fce5 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -109,9 +109,10 @@ export class GatewayClient { if (this._state === "open" || this._state === "connecting") return; this.setState("connecting"); - // 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). + // Gated mode: the SPA must fetch a single-use ticket via + // /api/auth/ws-ticket; loopback mode needs no auth param (the server + // accepts loopback WS on the peer-IP + Host/Origin guard). An explicit + // ``token`` overrides both (test-only path). let authParamName: string; let authParamValue: string; if (token) { @@ -122,19 +123,16 @@ export class GatewayClient { 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", - ); - } + authParamName = ""; + authParamValue = ""; } const scheme = location.protocol === "https:" ? "wss:" : "ws:"; + const authQuery = authParamName + ? `?${authParamName}=${encodeURIComponent(authParamValue)}` + : ""; const ws = new WebSocket( - `${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`, + `${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws${authQuery}`, ); this.ws = ws; @@ -247,7 +245,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 b8c1ecbcbf1..d01fecc9753 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -9,7 +9,7 @@ * │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` . * │ write(data) PTY output bytes → VT100 parser . * ▼ . - * WebSocket /api/pty?token= . + * WebSocket /api/pty?ticket= (gated; none on loopback) . * ▼ . * FastAPI pty_ws (hermes_cli/web_server.py) . * ▼ . @@ -46,10 +46,13 @@ function buildWsUrl( profile: string, ): string { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - // ``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 }); + // ``authParam`` is ``["ticket", ]`` in gated mode and an empty + // pair ``["", ""]`` in loopback mode (the server accepts loopback WS with + // no auth param — peer-IP + Host/Origin guard is the boundary). The + // server-side helper ``_ws_auth_ok`` picks whichever shape matches the + // current gate state. + const qs = new URLSearchParams({ channel }); + if (authParam[0]) qs.set(authParam[0], authParam[1]); if (resume) qs.set("resume", resume); // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the // selected profile, so the conversation runs with that profile's model, @@ -125,18 +128,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // collapses the host's box, so ResizeObserver never fires on return). const syncMetricsRef = useRef<(() => void) | null>(null); const [searchParams, setSearchParams] = useSearchParams(); - // Lazy-init: the missing-token check happens at construction so the effect - // body doesn't have to setState (React 19's set-state-in-effect rule). - // In gated (OAuth) mode the server intentionally omits the session token — - // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam), - // so a missing token there is expected, not an error. - const [banner, setBanner] = useState(() => - typeof window !== "undefined" && - !window.__HERMES_SESSION_TOKEN__ && - !window.__HERMES_AUTH_REQUIRED__ - ? "Session token unavailable. Open this page through `hermes dashboard`, not directly." - : null, - ); + // Connection status banner — populated by the WS ``onclose`` handler when + // a connection is refused (auth failure, host/origin mismatch, etc.). + // There's no client-side credential to be "missing" anymore: loopback + // needs none and gated mints a WS ticket on demand (buildWsAuthParam). + const [banner, setBanner] = useState(null); const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); const copyResetRef = useRef | null>(null); // Raw state for the mobile side-sheet + a derived value that force- @@ -296,15 +292,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const host = hostRef.current; if (!host) return; - const token = window.__HERMES_SESSION_TOKEN__; - const gated = !!window.__HERMES_AUTH_REQUIRED__; - // Banner already initialised above; just bail before wiring xterm/WS. - // In gated mode the token is absent by design — buildWsAuthParam() mints - // a WS ticket instead, so don't bail; let the effect reach that path. - if (!token && !gated) { - return; - } - + // No client-side credential gate here: loopback WS needs no auth param + // and gated mode mints a single-use ticket in buildWsAuthParam(). Wire + // up xterm/WS unconditionally. const tierW0 = terminalTierWidthPx(host); const term = new Terminal({ allowProposedApi: true, @@ -941,7 +931,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { declare global { interface Window { - __HERMES_SESSION_TOKEN__?: string; __HERMES_AUTH_REQUIRED__?: boolean; } } diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index c48d2453876..cf54da212ca 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -1105,11 +1105,6 @@ export default function SessionsPage() { try { const res = await fetch(api.exportSessionUrl(id), { credentials: "include", - headers: { - "X-Hermes-Session-Token": - (window as unknown as { __HERMES_SESSION_TOKEN__?: string }) - .__HERMES_SESSION_TOKEN__ ?? "", - }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); diff --git a/web/src/plugins/registry.ts b/web/src/plugins/registry.ts index 392c536d0ad..8543363a700 100644 --- a/web/src/plugins/registry.ts +++ b/web/src/plugins/registry.ts @@ -128,12 +128,12 @@ export function exposePluginSDK() { // Raw fetchJSON for plugin-specific JSON endpoints fetchJSON, // Authenticated fetch for non-JSON endpoints (uploads / blob downloads). - // Handles loopback-token vs gated-cookie auth so plugins never read - // window.__HERMES_SESSION_TOKEN__ directly. + // Handles gated-cookie auth (and needs no credential on loopback) so + // plugins never have to manage dashboard auth themselves. authedFetch, // Build a ws(s):// URL with the correct auth param for the active mode - // (single-use ticket in gated mode, token in loopback). Use this for any - // plugin WebSocket instead of hand-assembling the URL. + // (single-use ticket in gated mode, no auth param in loopback). Use this + // for any plugin WebSocket instead of hand-assembling the URL. buildWsUrl, // Lower-level: resolve just the [authParamName, authParamValue] pair, for // plugins that need to build the WS URL themselves. diff --git a/web/src/plugins/sdk.d.ts b/web/src/plugins/sdk.d.ts index c55b855ab82..b26ad4cada3 100644 --- a/web/src/plugins/sdk.d.ts +++ b/web/src/plugins/sdk.d.ts @@ -58,15 +58,16 @@ export type FetchJSON = ( * binary/blob downloads). Same auth handling as ``fetchJSON`` but returns * the raw ``Response``, does not parse, does not throw on non-2xx, and does * not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead - * of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``. + * of calling ``fetch`` directly so dashboard auth (gated cookie / loopback) + * is handled for them. */ export type AuthedFetch = (url: string, init?: RequestInit) => Promise; /** * Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with * the correct auth query param for the active mode (single-use ``ticket`` in - * gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any - * WebSocket instead of hand-assembling the URL + reading the session token. + * gated OAuth mode, no auth param in loopback). Plugins MUST use this for any + * WebSocket instead of hand-assembling the URL. */ export type BuildWsUrl = ( path: string,