mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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".
123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
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}`
|
|
}
|