refactor(desktop+dashboard): extract shared WebSocket/JSON-RPC layer

The Electron desktop app and the web dashboard each carried their own
copy of the tui_gateway JSON-RPC WebSocket client plus near-identical
auth'd WS-URL construction. The dashboard's copy was the historical
source of the "is the dashboard required to run the desktop app?"
confusion, since the two surfaces looked coupled.

Consolidate the genuinely shared transport into the existing
framework-agnostic `@hermes/shared` package so both surfaces consume it
independently — neither app depends on the other:

- Move `resolveGatewayWsUrl` + `GatewayReauthRequiredError` (single-use
  OAuth ticket re-mint vs long-lived token fallback) into
  `@hermes/shared`; desktop now imports them directly.
- Add `buildHermesWebSocketUrl`, one base-path/scheme/auth-aware URL
  builder, and route every dashboard WS endpoint through it
  (`/api/ws`, `/api/events`, `/api/pty`, plugin WS URLs).
- Reduce the dashboard `GatewayClient` to a thin subclass of the shared
  `JsonRpcGatewayClient`, deleting ~210 lines of duplicated pending-call
  /event-dispatch/connect plumbing while keeping its dashboard-specific
  ticket-vs-token auth selection.
- Drop the stale "start it with --tui" chat banner, which implied the
  dashboard flag was required.

Behavior is preserved on both surfaces; the dashboard additionally
inherits the shared client's 15s connect timeout (previously
desktop-only), so a hung connect now fails fast instead of pinning the
composer in "connecting".
This commit is contained in:
Brooklyn Nicholson 2026-06-28 21:20:35 -05:00
parent c8fd47be14
commit dfb561a3ae
15 changed files with 210 additions and 339 deletions

View file

@ -1,10 +1,10 @@
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,

View file

@ -1,8 +1,8 @@
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'

View file

@ -1,7 +1,6 @@
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
import { describe, expect, it, vi } from 'vitest'
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }

View file

@ -1,91 +0,0 @@
import type { HermesConnection } from '@/global'
/**
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
* consumed). For local/token gateways the URL carries a long-lived token and
* never needs re-minting.
*
* Resolution rules:
*
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
* back to `conn.wsUrl` that ticket is dead and the connect is guaranteed to
* fail with an opaque "connection closed" error. Instead, let the mint error
* propagate so the caller can surface the gateway's reauth message
* ("session has expired… Sign in again").
*
* - token / local, or when the preload method is genuinely absent (older
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
* the fallback is safe and preserves compatibility.
*
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
* callers can distinguish "the user must re-authenticate" from a generic
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
* optional profile selects which backend to mint for critical when swapping
* to a pooled profile, since the default mint resolves the primary backend. */
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(
desktop: ResolveGatewayWsUrlDeps,
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
): Promise<string> {
const mint = desktop.getGatewayWsUrl
// Mint for THIS connection's profile, not the primary. Without it a pooled
// profile swap re-mints the default backend's URL and connects to the wrong
// backend.
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
// dead, so connecting with it cannot succeed. Surface a reauth error
// rather than silently attempting a doomed connect.
throw new GatewayReauthRequiredError(
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
)
}
try {
return await mint(profile)
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
{ cause: error }
)
}
}
// token / local: the URL carries a long-lived token. Re-mint when available
// (cheap, keeps parity), but the cached URL is a safe fallback.
if (mint) {
const fresh = await mint(profile).catch(() => null)
if (fresh) {
return fresh
}
}
return conn.wsUrl
}

View file

@ -1,8 +1,7 @@
import type { ConnectionState, GatewayEvent } from '@hermes/shared'
import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared'
import { atom } from 'nanostores'
import { HermesGateway } from '@/hermes'
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { setGatewayState } from '@/store/session'
// ── Multi-profile gateway routing ──────────────────────────────────────────

View file

@ -8,3 +8,14 @@ export {
type JsonRpcFrame,
type WebSocketLike
} from './json-rpc-gateway'
export {
GatewayReauthRequiredError,
buildHermesWebSocketUrl,
isGatewayReauthRequired,
resolveGatewayWsUrl,
type GatewayAuthMode,
type GatewayWsConnection,
type HermesWebSocketUrlOptions,
type ResolveGatewayWsUrlDeps,
type WebSocketAuthParam
} from './websocket-url'

View file

@ -0,0 +1,123 @@
export type GatewayAuthMode = 'oauth' | 'token' | (string & {})
export interface GatewayWsConnection {
authMode?: GatewayAuthMode | null
profile?: null | string
wsUrl: string
}
export interface ResolveGatewayWsUrlDeps {
/**
* Returns a fresh WebSocket URL for the selected backend/profile.
* OAuth-gated gateways use single-use tickets, so callers should mint
* immediately before opening the socket.
*/
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(
deps: ResolveGatewayWsUrlDeps,
conn: GatewayWsConnection
): Promise<string> {
const mint = deps.getGatewayWsUrl
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
throw new GatewayReauthRequiredError(
'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.'
)
}
try {
return await mint(profile)
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
{ cause: error }
)
}
}
if (mint) {
const fresh = await mint(profile).catch(() => null)
if (fresh) {
return fresh
}
}
return conn.wsUrl
}
export type WebSocketAuthParam = readonly [name: string, value: string]
export interface HermesWebSocketUrlOptions {
/** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */
path: string
/** Optional URL prefix when the backend is reverse-proxied below a subpath. */
basePath?: string
/** Query auth pair, usually ["token", value] or ["ticket", value]. */
authParam?: WebSocketAuthParam
/** Extra query params merged before auth. */
params?: Record<string, string>
/** Browser protocol string such as "https:"; defaults to window.location.protocol. */
protocol?: string
/** Host with optional port; defaults to window.location.host. */
host?: string
}
function readWindowLocation(): { host: string; protocol: string } {
if (typeof window === 'undefined') {
return { host: '', protocol: 'http:' }
}
return { host: window.location.host, protocol: window.location.protocol }
}
function normalizeBasePath(basePath: string | undefined): string {
if (!basePath) {
return ''
}
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
return withLead.replace(/\/+$/, '')
}
function normalizeEndpointPath(path: string): string {
return path.startsWith('/') ? path : `/${path}`
}
export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string {
const loc = readWindowLocation()
const protocol = options.protocol ?? loc.protocol
const host = options.host ?? loc.host
const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:'
const qs = new URLSearchParams(options.params ?? {})
if (options.authParam) {
const [name, value] = options.authParam
qs.set(name, value)
}
const query = qs.toString()
const suffix = query ? `?${query}` : ''
return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}`
}

1
package-lock.json generated
View file

@ -19571,6 +19571,7 @@
"web": {
"version": "0.0.0",
"dependencies": {
"@hermes/shared": "file:../apps/shared",
"@nous-research/ui": "0.18.2",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",

View file

@ -12,6 +12,7 @@
"test": "vitest run"
},
"dependencies": {
"@hermes/shared": "file:../apps/shared",
"@nous-research/ui": "0.18.2",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",

View file

@ -26,6 +26,7 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card } from "@nous-research/ui/ui/components/card";
import { buildHermesWebSocketUrl } from "@hermes/shared";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
@ -230,14 +231,17 @@ export function ChatSidebar({
let unmounting = false;
let ws: WebSocket | null = null;
void (async () => {
const [authName, authValue] = await buildWsAuthParam();
if (!authValue || unmounting) {
const authParam = await buildWsAuthParam();
if (!authParam[1] || 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()}`,
buildHermesWebSocketUrl({
authParam,
basePath: HERMES_BASE_PATH,
params: { channel },
path: "/api/events",
}),
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`

View file

@ -1,3 +1,5 @@
import { buildHermesWebSocketUrl } from "@hermes/shared";
// The dashboard can be served either at the root of its host (e.g.
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
@ -291,11 +293,12 @@ export async function buildWsUrl(
path: string,
params?: Record<string, string>,
): Promise<string> {
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}`;
return buildHermesWebSocketUrl({
authParam: await buildWsAuthParam(),
basePath: BASE,
params,
path,
});
}
/** Build a ``?profile=<name>`` query suffix, or "" when unset.

View file

@ -13,241 +13,53 @@
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
import {
JsonRpcGatewayClient,
buildHermesWebSocketUrl,
type ConnectionState,
type GatewayEvent,
type GatewayEventName,
} from "@hermes/shared";
export type GatewayEventName =
| "gateway.ready"
| "session.info"
| "message.start"
| "message.delta"
| "message.complete"
| "thinking.delta"
| "reasoning.delta"
| "reasoning.available"
| "status.update"
| "tool.start"
| "tool.progress"
| "tool.complete"
| "tool.generating"
| "clarify.request"
| "approval.request"
| "sudo.request"
| "secret.request"
| "background.complete"
| "error"
| "skin.changed"
| (string & {});
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
export interface GatewayEvent<P = unknown> {
type: GatewayEventName;
session_id?: string;
payload?: P;
}
export type { ConnectionState, GatewayEvent, GatewayEventName };
export type ConnectionState =
| "idle"
| "connecting"
| "open"
| "closed"
| "error";
interface Pending {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
/** Wildcard listener key: subscribe to every event regardless of type. */
const ANY = "*";
export class GatewayClient {
private ws: WebSocket | null = null;
private reqId = 0;
private pending = new Map<string, Pending>();
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
private _state: ConnectionState = "idle";
private stateListeners = new Set<(s: ConnectionState) => void>();
export class GatewayClient extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: "WebSocket closed",
connectErrorMessage: "WebSocket connection failed",
notConnectedErrorMessage: "gateway not connected",
requestIdPrefix: "w",
});
}
get state(): ConnectionState {
return this._state;
}
private setState(s: ConnectionState) {
if (this._state === s) return;
this._state = s;
for (const cb of this.stateListeners) cb(s);
}
onState(cb: (s: ConnectionState) => void): () => void {
this.stateListeners.add(cb);
cb(this._state);
return () => this.stateListeners.delete(cb);
}
/** Subscribe to a specific event type. Returns an unsubscribe function. */
on<P = unknown>(
type: GatewayEventName,
cb: (ev: GatewayEvent<P>) => void,
): () => void {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
set.add(cb as (ev: GatewayEvent) => void);
return () => set!.delete(cb as (ev: GatewayEvent) => void);
}
/** Subscribe to every event (fires after type-specific listeners). */
onAny(cb: (ev: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, cb);
return this.connectionState;
}
async connect(token?: string): Promise<void> {
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).
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?${authParamName}=${encodeURIComponent(authParamValue)}`,
);
this.ws = ws;
// Register message + close BEFORE awaiting open — the server emits
// `gateway.ready` immediately after accept, so a listener attached
// after the open promise resolves can race past it and drop the
// initial skin payload.
ws.addEventListener("message", (ev) => {
try {
this.dispatch(JSON.parse(ev.data));
} catch {
/* malformed frame — ignore */
}
});
ws.addEventListener("close", () => {
this.setState("closed");
this.rejectAllPending(new Error("WebSocket closed"));
});
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("error", onError);
this.setState("open");
resolve();
};
const onError = () => {
ws.removeEventListener("open", onOpen);
this.setState("error");
reject(new Error("WebSocket connection failed"));
};
ws.addEventListener("open", onOpen, { once: true });
ws.addEventListener("error", onError, { once: true });
});
}
close() {
this.ws?.close();
this.ws = null;
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined;
if (id !== undefined && this.pending.has(id)) {
const p = this.pending.get(id)!;
this.pending.delete(id);
clearTimeout(p.timer);
const err = msg.error as { message?: string } | undefined;
if (err) p.reject(new Error(err.message ?? "request failed"));
else p.resolve(msg.result);
if (this.connectionState === "open" || this.connectionState === "connecting") {
return;
}
if (msg.method !== "event") return;
const params = (msg.params ?? {}) as GatewayEvent;
if (typeof params.type !== "string") return;
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
}
private rejectAllPending(err: Error) {
for (const p of this.pending.values()) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
/** Send a JSON-RPC request. Rejects on error response or timeout. */
request<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<T> {
if (!this.ws || this._state !== "open") {
return Promise.reject(
new Error(`gateway not connected (state=${this._state})`),
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
// override path.
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
if (!authParam[1]) {
throw new Error(
"Session token not available — page must be served by the Hermes dashboard server",
);
}
const id = `w${++this.reqId}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(new Error(`request timed out: ${method}`));
}
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => resolve(v as T),
reject,
timer,
});
try {
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
} catch (e) {
clearTimeout(timer);
this.pending.delete(id);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
await super.connect(
buildHermesWebSocketUrl({
authParam,
basePath: HERMES_BASE_PATH,
path: "/api/ws",
}),
);
}
}

View file

@ -24,6 +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 "@nous-research/ui/ui/components/typography/index";
import { buildHermesWebSocketUrl, type WebSocketAuthParam } from "@hermes/shared";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, RotateCcw, X } from "lucide-react";
@ -42,24 +43,28 @@ import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
function buildWsUrl(
authParam: [string, string],
authParam: WebSocketAuthParam,
resume: string | null,
channel: string,
profile: string,
fresh: boolean,
): 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 });
if (resume) qs.set("resume", resume);
if (fresh) qs.set("fresh", "1");
const params: Record<string, string> = { channel };
if (resume) params.resume = resume;
if (fresh) params.fresh = "1";
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
if (profile) qs.set("profile", profile);
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
if (profile) params.profile = profile;
return buildHermesWebSocketUrl({
authParam,
basePath: HERMES_BASE_PATH,
params,
path: "/api/pty",
});
}
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
@ -767,7 +772,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
}
if (ev.code === 4404) {
setBanner(
"Embedded chat is disabled on this server (start it with --tui).",
ev.reason
? `Chat websocket unavailable: ${ev.reason}.`
: "Chat websocket unavailable on this server.",
);
return;
}

View file

@ -18,7 +18,8 @@
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@hermes/shared": ["../apps/shared/src/index.ts"]
},
/* Linting */
@ -29,5 +30,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src", "../apps/shared/src"]
}

View file

@ -62,6 +62,7 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@hermes/shared": path.resolve(__dirname, "../apps/shared/src"),
},
// When @nous-research/ui is symlinked via `file:../../design-language`,
// Node's module resolution would pick up shared deps from