mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
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:
parent
bb72e4614a
commit
1c99c2f5eb
4 changed files with 141 additions and 63 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue