feat(dashboard): remove SPA dependency on deleted session token

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 <noreply@nousresearch.com>
This commit is contained in:
Ben 2026-06-16 16:23:49 +10:00
parent 85d9d27043
commit 4ad1655211
7 changed files with 75 additions and 176 deletions

View file

@ -184,7 +184,8 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
if (!channel) {
return;
}
// In loopback mode the legacy ?token=<session> 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()}`,
);

View file

@ -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<T>(
options?: FetchJSONOptions,
): Promise<T> {
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<T>(
// Never resolve — the page is about to unload.
return new Promise<T>(() => {});
}
// 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<T>(() => {});
}
}
}
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<string> {
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<Response> {
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=<name>`` 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<AuthMeResponse>("/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<OAuthProvidersResponse>("/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<OAuthStartResponse>(
),
startOAuthLogin: (providerId: string) =>
fetchJSON<OAuthStartResponse>(
`/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<OAuthSubmitResponse>(
),
submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
fetchJSON<OAuthSubmitResponse>(
`/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<OAuthPollResponse>(
`/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: () =>

View file

@ -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;
}
}

View file

@ -9,7 +9,7 @@
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
* │ write(data) PTY output bytes → VT100 parser .
* ▼ .
* WebSocket /api/pty?token=<session> .
* WebSocket /api/pty?ticket=<minted> (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", <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 });
// ``authParam`` is ``["ticket", <minted>]`` 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<string | null>(() =>
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<string | null>(null);
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | 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;
}
}

View file

@ -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();

View file

@ -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.

View file

@ -58,15 +58,16 @@ export type FetchJSON = <T = unknown>(
* 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<Response>;
/**
* 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,