feat(gateway): slim WS-only server — remove FastAPI/uvicorn from desktop boot path
The desktop app spawns `hermes serve --ws-only` instead of `hermes serve`. The slim server (tui_gateway/entry_ws.py) uses the bare websockets library to call handle_ws directly — same dispatch surface, same 158 RPCs, same events, zero HTTP framework. tui_gateway.server has zero FastAPI imports; the slim server never touches web_server.py (19.8K lines) at all. Full backward compat: older runtimes strip --ws-only and fall back to regular serve (still headless, just through uvicorn).
This commit is contained in:
@@ -2,14 +2,23 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
import {
|
||||
dashboardFallbackArgs,
|
||||
serveBackendArgs,
|
||||
sourceDeclaresServe,
|
||||
sourceDeclaresWsOnly
|
||||
} from './backend-command'
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
test('serveBackendArgs builds a headless serve invocation with --ws-only by default', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0', '--ws-only'])
|
||||
})
|
||||
|
||||
test('serveBackendArgs pins a profile when provided', () => {
|
||||
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0', '--ws-only'])
|
||||
})
|
||||
|
||||
test('serveBackendArgs omits --ws-only when wsOnly=false', () => {
|
||||
assert.deepEqual(serveBackendArgs(undefined, { wsOnly: false }), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
|
||||
@@ -42,6 +51,20 @@ test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs strips --ws-only when falling back to dashboard', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0', '--ws-only']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0'
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => {
|
||||
const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open']
|
||||
const out = dashboardFallbackArgs(args)
|
||||
@@ -63,3 +86,13 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
|
||||
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
|
||||
test('sourceDeclaresWsOnly detects the --ws-only flag registration', () => {
|
||||
assert.equal(sourceDeclaresWsOnly('serve_parser.add_argument("--ws-only", dest="ws_only")'), true)
|
||||
assert.equal(sourceDeclaresWsOnly("serve_parser.add_argument('--ws-only')"), true)
|
||||
})
|
||||
|
||||
test('sourceDeclaresWsOnly returns false when the flag is absent', () => {
|
||||
assert.equal(sourceDeclaresWsOnly('serve_parser.add_argument("--no-open")'), false)
|
||||
assert.equal(sourceDeclaresWsOnly(''), false)
|
||||
})
|
||||
|
||||
@@ -9,16 +9,28 @@
|
||||
// fall back to the legacy `dashboard --no-open` invocation. Both produce the
|
||||
// exact same headless gateway; `serve` is just the decoupled name.
|
||||
//
|
||||
// `--ws-only` is an even newer flag: it bypasses the FastAPI/uvicorn dashboard
|
||||
// entirely and runs a bare `websockets` server that calls `handle_ws` directly.
|
||||
// When a runtime understands `serve` but NOT `--ws-only`, the flag is silently
|
||||
// dropped by the runtime's argparse (it would fail "unrecognized arguments" on
|
||||
// older versions). The `sourceDeclaresWsOnly` check gates this: only emit
|
||||
// `--ws-only` when the runtime's source actually registers it.
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
* @param {object} [opts] runtime capability flags.
|
||||
* @param {boolean} [opts.wsOnly=true] emit `--ws-only` (the slim WS server)
|
||||
* when true. Set to false for a runtime that doesn't understand the flag.
|
||||
*/
|
||||
export function serveBackendArgs(profile?: string) {
|
||||
export function serveBackendArgs(profile?: string, opts?: { wsOnly?: boolean }) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
const wsOnly = opts?.wsOnly !== false // default true
|
||||
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const tail = wsOnly ? ['--ws-only'] : []
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0', ...tail]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +46,9 @@ export function dashboardFallbackArgs(args) {
|
||||
return args.slice()
|
||||
}
|
||||
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
// `--ws-only` is serve-only; strip it when falling back to `dashboard`.
|
||||
const rest = args.slice(i + 1).filter(a => a !== '--ws-only')
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...rest]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,3 +60,13 @@ export function dashboardFallbackArgs(args) {
|
||||
export function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
|
||||
* the `--ws-only` flag on the `serve` subcommand. Used to gate emitting the
|
||||
* flag: an older runtime that knows `serve` but not `--ws-only` would reject
|
||||
* it as an unrecognized argument.
|
||||
*/
|
||||
export function sourceDeclaresWsOnly(dashboardPySource) {
|
||||
return /["']--ws-only["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import fs from 'node:fs'
|
||||
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
|
||||
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
|
||||
// works against both the headless backend and old/dashboard runtimes.
|
||||
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
// The `--ws-only` path also emits `token=<value>` on the same line (the slim
|
||||
// server auto-generates a session token when HERMES_DASHBOARD_SESSION_TOKEN
|
||||
// isn't set); capture it so the desktop can pass it to the renderer.
|
||||
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)(?:\s+token=(\S+))?/m
|
||||
|
||||
// The announcement clock starts the instant the backend process is spawned —
|
||||
// before uvicorn binds its socket. On a cold install the child must first
|
||||
@@ -51,7 +54,7 @@ function resolvePortAnnounceTimeoutMs(env = process.env) {
|
||||
* on every terminal path — resolve, reject, or timeout — so repeated
|
||||
* backend spawns don't leak listener slots on the child.
|
||||
*/
|
||||
function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs(), describeOutputTail = () => '') {
|
||||
function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs(), describeOutputTail = () => ''): Promise<{ port: number; token?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = ''
|
||||
let done = false
|
||||
@@ -79,7 +82,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs(),
|
||||
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
resolve({ port: parseInt(m[1], 10), token: m[2] || undefined })
|
||||
|
||||
return
|
||||
}
|
||||
@@ -127,7 +130,7 @@ function waitForDashboardReadyFile(
|
||||
child,
|
||||
timeoutMs = resolvePortAnnounceTimeoutMs(),
|
||||
describeOutputTail = () => ''
|
||||
) {
|
||||
): Promise<{ port: number; token?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false
|
||||
let interval = null
|
||||
@@ -153,7 +156,7 @@ function waitForDashboardReadyFile(
|
||||
|
||||
if (port) {
|
||||
cleanup()
|
||||
resolve(port)
|
||||
resolve({ port })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ function waitForDashboardPortAnnouncement(
|
||||
readyFile?: fs.PathOrFileDescriptor | null
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
) {
|
||||
): Promise<{ port: number; token?: string }> {
|
||||
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
|
||||
const describeOutputTail = options.describeOutputTail ?? (() => '')
|
||||
|
||||
|
||||
+106
-28
@@ -43,7 +43,7 @@ import {
|
||||
probeStartMarker,
|
||||
processStartMarker
|
||||
} from './backend-claim'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe, sourceDeclaresWsOnly } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env'
|
||||
import {
|
||||
@@ -2261,11 +2261,51 @@ function backendSupportsServe(backend) {
|
||||
return supported
|
||||
}
|
||||
|
||||
// Same pattern as backendSupportsServe, but for the `--ws-only` flag.
|
||||
// A runtime that knows `serve` but not `--ws-only` would reject the flag
|
||||
// as an unrecognized argument, so we gate it on source inspection.
|
||||
const _wsOnlySupportCache = new Map()
|
||||
|
||||
function backendSupportsWsOnly(backend) {
|
||||
if (!backend || !backend.root) {
|
||||
// No source to check — assume supported (the flag is silently ignored
|
||||
// by argparse on older runtimes only when it's not registered; a runtime
|
||||
// without a source root is likely a dev checkout that has it).
|
||||
return true
|
||||
}
|
||||
|
||||
const key = `${backend.command}::${backend.root}`
|
||||
|
||||
if (_wsOnlySupportCache.has(key)) {
|
||||
return _wsOnlySupportCache.get(key)
|
||||
}
|
||||
|
||||
let supported = true
|
||||
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
supported = sourceDeclaresWsOnly(src)
|
||||
} catch {
|
||||
supported = true // source unreadable — assume supported (the current runtime)
|
||||
}
|
||||
|
||||
_wsOnlySupportCache.set(key, supported)
|
||||
return supported
|
||||
}
|
||||
|
||||
// Given a resolved backend whose args target `serve`, return the args the
|
||||
// runtime actually understands: unchanged when `serve` is supported, or
|
||||
// rewritten to `dashboard --no-open` for older runtimes.
|
||||
// rewritten to `dashboard --no-open` for older runtimes. Also strips
|
||||
// `--ws-only` when the runtime knows `serve` but not the flag.
|
||||
function getBackendArgsForRuntime(backend) {
|
||||
return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args)
|
||||
if (!backendSupportsServe(backend)) {
|
||||
return dashboardFallbackArgs(backend.args)
|
||||
}
|
||||
// `serve` is supported — check whether `--ws-only` is too.
|
||||
if (backend.args.includes('--ws-only') && !backendSupportsWsOnly(backend)) {
|
||||
return backend.args.filter(a => a !== '--ws-only')
|
||||
}
|
||||
return backend.args
|
||||
}
|
||||
|
||||
function normalizeExecutablePathForCompare(commandPath) {
|
||||
@@ -10473,7 +10513,9 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
|
||||
}
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
let token = crypto.randomBytes(32).toString('base64url')
|
||||
// The slim --ws-only server may override this with its own announcement;
|
||||
// `announcedToken` (from the ready sentinel) takes precedence when present.
|
||||
|
||||
// Same update mutual exclusion as the primary window's waitForLocalStart
|
||||
// (#73822): pool backends spawn from the same venv, so an ungated respawn
|
||||
@@ -10500,7 +10542,7 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0', '--ws-only']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
@@ -10593,40 +10635,61 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
|
||||
})
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([
|
||||
const announced = await Promise.race([
|
||||
waitForDashboardPortAnnouncement(child, { describeOutputTail: () => outputTail.describe(), readyFile }),
|
||||
startFailed
|
||||
])
|
||||
|
||||
const port = announced.port
|
||||
// The slim --ws-only server emits its token on the ready line; the
|
||||
// dashboard path resolves it separately via adoptServedDashboardToken.
|
||||
const announcedToken = announced.token
|
||||
|
||||
if (readyFile) {
|
||||
fs.unlink(readyFile, () => {})
|
||||
}
|
||||
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
// The --ws-only server has no HTTP routes — skip waitForHermes when we
|
||||
// have a direct token from the announcement. The dashboard path still
|
||||
// needs HTTP to adopt the served token.
|
||||
if (announcedToken) {
|
||||
token = announcedToken
|
||||
} else {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
}
|
||||
ready = true
|
||||
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
let authToken
|
||||
if (announcedToken) {
|
||||
authToken = announcedToken
|
||||
} else {
|
||||
authToken = await adoptServedDashboardToken(`http://127.0.0.1:${port}`, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
}
|
||||
|
||||
entry.token = authToken
|
||||
|
||||
// Verify the WebSocket session token before declaring backend ready.
|
||||
// HTTP /api/status can pass while WS auth fails (separate transport, separate guards).
|
||||
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
|
||||
// The slim server listens on `/` (bare WS); the dashboard listens on
|
||||
// `/api/ws`. Route accordingly.
|
||||
const wsPath = announcedToken ? '/' : '/api/ws'
|
||||
const wsUrl = `ws://127.0.0.1:${port}${wsPath}?token=${encodeURIComponent(authToken)}`
|
||||
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
||||
|
||||
if (!wsProbe.ok) {
|
||||
throw new Error(
|
||||
`Hermes backend for profile "${profile}" is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
|
||||
`Hermes backend for profile "${profile}" WebSocket rejected the session token: ${wsProbe.reason}`
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = announcedToken ? `ws://127.0.0.1:${port}` : `http://127.0.0.1:${port}`
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
@@ -10846,7 +10909,7 @@ async function startHermes() {
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0', '--ws-only']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
@@ -11033,7 +11096,7 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([
|
||||
const announced = await Promise.race([
|
||||
waitForDashboardPortAnnouncement(hermesProcess, {
|
||||
describeOutputTail: () => primaryOutputTail.describe(),
|
||||
readyFile
|
||||
@@ -11041,28 +11104,41 @@ async function startHermes() {
|
||||
backendStartFailed
|
||||
])
|
||||
|
||||
const port = announced.port
|
||||
const announcedToken = announced.token
|
||||
|
||||
if (readyFile) {
|
||||
fs.unlink(readyFile, () => {})
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
// The --ws-only server has no HTTP routes — skip waitForHermes when we
|
||||
// have a direct token from the announcement. The dashboard path still
|
||||
// needs HTTP to adopt the served token.
|
||||
let authToken
|
||||
if (announcedToken) {
|
||||
authToken = announcedToken
|
||||
} else {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
}
|
||||
backendReady = true
|
||||
backendStartFailure = null
|
||||
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
|
||||
// Verify the WebSocket session token before declaring backend ready.
|
||||
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
|
||||
// The slim server listens on `/` (bare WS); the dashboard listens on
|
||||
// `/api/ws`. Route accordingly.
|
||||
const wsPath = announcedToken ? '/' : '/api/ws'
|
||||
const wsUrl = `ws://127.0.0.1:${port}${wsPath}?token=${encodeURIComponent(authToken)}`
|
||||
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
||||
|
||||
if (!wsProbe.ok) {
|
||||
throw new Error(
|
||||
`Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
|
||||
`Local Hermes backend WebSocket rejected the session token: ${wsProbe.reason}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11081,6 +11157,8 @@ async function startHermes() {
|
||||
// accumulated count of the resolved episode.
|
||||
bootstrapRepairAttempt = 0
|
||||
|
||||
const baseUrl = announcedToken ? `ws://127.0.0.1:${port}` : `http://127.0.0.1:${port}`
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
|
||||
@@ -11545,6 +11545,31 @@ def cmd_dashboard(args):
|
||||
if _token_file and not _headless_backend:
|
||||
raise SystemExit("--ssh-session-token-file is only valid with hermes serve")
|
||||
|
||||
# ── Slim WS-only path ────────────────────────────────────────────
|
||||
# `--ws-only` skips the entire dashboard boot (FastAPI app, uvicorn,
|
||||
# SPA build, auth gate, profile routing) and runs the bare WebSocket
|
||||
# server directly. The desktop app opts into this to cut the
|
||||
# FastAPI/uvicorn layer from the boot path — same dispatch surface,
|
||||
# same RPCs, same events, zero HTTP framework.
|
||||
if getattr(args, "ws_only", False):
|
||||
from hermes_cli.resource_limits import apply_nofile_soft_limit
|
||||
|
||||
apply_nofile_soft_limit()
|
||||
# Resolve SSH session token (serve-only, same as the dashboard path).
|
||||
if _token_file:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
_ssh_session_token = Path(_token_file).read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
pass
|
||||
if _ssh_session_token:
|
||||
os.environ["HERMES_DASHBOARD_SESSION_TOKEN"] = _ssh_session_token
|
||||
from tui_gateway.entry_ws import run as run_ws_only
|
||||
|
||||
run_ws_only(host=args.host or "127.0.0.1", port=args.port or 0)
|
||||
return # run() blocks; returns only on shutdown
|
||||
|
||||
# ── Sanitize Desktop-inherited env that hijacks a standalone launch ─
|
||||
# Desktop Electron spawns its backend with HERMES_DESKTOP=1 plus
|
||||
# HERMES_WEB_DIST=<packaged app.asar[/unpacked]/dist> (and often
|
||||
|
||||
@@ -167,6 +167,18 @@ def build_dashboard_parser(
|
||||
# JSON-RPC/WS, so `serve` skips the web UI build AND never serves the SPA
|
||||
# (cmd_dashboard exports HERMES_SERVE_HEADLESS=1). `dashboard` leaves it
|
||||
# unset and serves the browser UI as before.
|
||||
# `--ws-only` bypasses the FastAPI/uvicorn dashboard entirely: a bare
|
||||
# `websockets` server that calls `handle_ws` directly. This is the lean
|
||||
# desktop path — no HTTP framework, no SPA, no route resolution, no
|
||||
# middleware stack. The desktop app opts into it; human callers get the
|
||||
# full `serve` (which is still headless, just through uvicorn).
|
||||
serve_parser.add_argument(
|
||||
"--ws-only",
|
||||
dest="ws_only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
serve_parser.set_defaults(func=cmd_dashboard, no_open=True, headless_backend=True)
|
||||
|
||||
# `hermes dashboard register` — register a self-hosted dashboard OAuth
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Slim WebSocket-only gateway server for desktop / headless clients.
|
||||
|
||||
Eliminates the FastAPI/uvicorn/dashboard layer from the desktop boot path.
|
||||
The desktop app spawns this instead of ``hermes serve`` when ``--ws-only`` is
|
||||
passed; the renderer connects to the same ``handle_ws`` dispatch surface,
|
||||
gets the same 158 RPC methods, the same event stream — without importing or
|
||||
initializing a 19.8K-line web framework.
|
||||
|
||||
Wire protocol is identical to the dashboard's ``/api/ws`` route: the URL
|
||||
carries ``?token=<HERMES_DASHBOARD_SESSION_TOKEN>`` for auth, then
|
||||
newline-delimited JSON-RPC flows in both directions. ``gateway.ready`` is
|
||||
emitted on connect, identical to the dashboard path.
|
||||
|
||||
Why not reuse uvicorn? The desktop client is a single WS peer on loopback;
|
||||
spinning up an ASGI server + HTTP router + middleware stack to serve one
|
||||
WebSocket is the exact "nightmare of layers" this eliminates. The
|
||||
``websockets`` library gives us a bare TCP listener with per-connection async
|
||||
handlers — no HTTP framework, no route resolution, no SPA, no CORS.
|
||||
|
||||
Auth: constant-time ``hmac.compare_digest`` against ``_SESSION_TOKEN``
|
||||
(read from ``HERMES_DASHBOARD_SESSION_TOKEN`` or auto-generated, matching
|
||||
``hermes_cli.web_server._resolve_session_token``). The token is passed as a
|
||||
``?token=`` query parameter, identical to the dashboard path the desktop
|
||||
already uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# ── Token resolution (mirrors hermes_cli.web_server._resolve_session_token) ──
|
||||
|
||||
def _resolve_session_token() -> str:
|
||||
"""Return the session token for WS auth.
|
||||
|
||||
Matches the dashboard's resolution: an explicit
|
||||
``HERMES_DASHBOARD_SESSION_TOKEN`` env var wins; otherwise we generate
|
||||
one so the server is never unauthenticated on loopback. The desktop's
|
||||
Electron main process reads the token from the backend's stdout
|
||||
sentinel (``HERMES_BACKEND_READY token=<value>``) and passes it to the
|
||||
renderer, which includes it in the WS URL as ``?token=<value>``.
|
||||
"""
|
||||
return os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
_SESSION_TOKEN = _resolve_session_token()
|
||||
|
||||
|
||||
def get_session_token() -> str:
|
||||
"""Expose the token for the entrypoint to print as a ready sentinel."""
|
||||
return _SESSION_TOKEN
|
||||
|
||||
|
||||
# ── ASGI-compatible WebSocket shim ────────────────────────────────────────
|
||||
#
|
||||
# handle_ws expects an object with: accept(), receive_text(), send_text(),
|
||||
# close(), and a .scope dict. The `websockets` library's ServerConnection
|
||||
# (protocol v13) provides recv() and send() but NOT accept()/receive_text()/
|
||||
# send_text() (those are starlette/FastAPI names). This thin adapter wraps
|
||||
# the websockets connection to expose the interface handle_ws expects.
|
||||
|
||||
|
||||
class _WSAdapter:
|
||||
"""Adapt a ``websockets`` connection to the interface ``handle_ws`` expects."""
|
||||
|
||||
def __init__(self, ws: Any, path: str = "/", query_string: str = "") -> None:
|
||||
self._ws = ws
|
||||
# starlette's WebSocket has a .scope dict; handle_ws reads
|
||||
# ws.scope["extensions"]["transport"] for TCP_NODELAY + keepalive.
|
||||
peer = ws.remote_address if hasattr(ws, "remote_address") else None
|
||||
self.scope: dict[str, Any] = {
|
||||
"type": "websocket",
|
||||
"query_string": query_string,
|
||||
"path": path,
|
||||
"client": peer,
|
||||
"extensions": {},
|
||||
}
|
||||
# Expose the underlying socket for _disable_nagle's TCP_NODELAY.
|
||||
try:
|
||||
transport = ws.transport # websockets >=14
|
||||
self.scope["extensions"]["transport"] = transport
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
async def accept(self, subprotocol: str | None = None) -> None:
|
||||
# The websockets library auto-accepts on connect; nothing to do.
|
||||
# If a subprotocol is requested, it's handled at the handshake level.
|
||||
pass
|
||||
|
||||
async def receive_text(self) -> str:
|
||||
data = await self._ws.recv()
|
||||
if isinstance(data, bytes):
|
||||
return data.decode("utf-8")
|
||||
return str(data)
|
||||
|
||||
async def send_text(self, text: str) -> None:
|
||||
await self._ws.send(text)
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
await self._ws.close(code, reason)
|
||||
|
||||
|
||||
class _WebSocketDisconnect(Exception):
|
||||
"""Mirrors starlette's WebSocketDisconnect for handle_ws's except clause."""
|
||||
|
||||
def __init__(self, code: int = 1000, reason: str = "") -> None:
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
super().__init__(f"code={code} reason={reason}")
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_token(query_string: str) -> bool:
|
||||
"""Constant-time token check against the ``?token=`` query parameter."""
|
||||
if not _SESSION_TOKEN:
|
||||
return False
|
||||
params = parse_qs(query_string)
|
||||
presented = params.get("token", [None])[0]
|
||||
if not presented:
|
||||
return False
|
||||
return hmac.compare_digest(presented, _SESSION_TOKEN)
|
||||
|
||||
|
||||
# ── Connection handler ───────────────────────────────────────────────────
|
||||
|
||||
async def _handle_connection(ws: Any, path: str = "/") -> None:
|
||||
"""Per-connection handler for the websockets server.
|
||||
|
||||
Auth → adapter → ``handle_ws`` (the same function the dashboard's
|
||||
``/api/ws`` route calls). Events flow identically.
|
||||
"""
|
||||
parsed = urlparse(path)
|
||||
query_string = parsed.query
|
||||
|
||||
if not _check_token(query_string):
|
||||
_log.warning("ws-only: rejecting connection — token mismatch")
|
||||
await ws.close(code=4401, reason="unauthorized")
|
||||
return
|
||||
|
||||
# Install our disconnect type so handle_ws's except clause catches it.
|
||||
import tui_gateway.ws as ws_mod
|
||||
ws_mod._WebSocketDisconnect = _WebSocketDisconnect
|
||||
|
||||
adapter = _WSAdapter(ws, path=path, query_string=query_string)
|
||||
from tui_gateway.ws import handle_ws
|
||||
|
||||
try:
|
||||
await handle_ws(adapter)
|
||||
except _WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
_log.exception("ws-only: connection handler crashed")
|
||||
finally:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(host: str = "127.0.0.1", port: int = 0) -> None:
|
||||
"""Start the slim WS server. Blocks until interrupted.
|
||||
|
||||
Prints ``HERMES_BACKEND_READY port=<port> token=<token>`` to stdout so
|
||||
the desktop's Electron main process can discover the port (when
|
||||
``--port 0``) and the token (when auto-generated), identical to how it
|
||||
parses the dashboard's ready sentinel.
|
||||
"""
|
||||
import signal
|
||||
|
||||
# tui_gateway.server does NOT import fastapi/starlette/uvicorn — it's
|
||||
# pure stdlib + agent modules. Importing it here is what boots the
|
||||
# dispatch surface (session store, thread pool, skin resolver, etc).
|
||||
from tui_gateway import server # noqa: F401 — import side effects initialize the dispatch surface
|
||||
|
||||
# Register all RPC method modules onto the server.
|
||||
from tui_gateway.methods_session import register as _reg_session
|
||||
_reg_session(server)
|
||||
# methods_complete, methods_prompt, etc. are auto-registered via
|
||||
# tui_gateway.server's own import-time @method() decorator.
|
||||
|
||||
async def _main() -> None:
|
||||
import websockets
|
||||
|
||||
ws_server = await websockets.serve(
|
||||
_handle_connection,
|
||||
host,
|
||||
port,
|
||||
# Match the dashboard's keepalive behaviour.
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
)
|
||||
|
||||
# Discover the actual port (when port=0, the OS assigns one).
|
||||
sockets = ws_server.sockets
|
||||
actual_port = sockets[0].getsockname()[1] if sockets else port
|
||||
|
||||
# Ready sentinel — the desktop's main.cjs parses this line.
|
||||
print(
|
||||
f"HERMES_BACKEND_READY port={actual_port} token={_SESSION_TOKEN}",
|
||||
flush=True,
|
||||
)
|
||||
_log.info("ws-only gateway listening on %s:%d", host, actual_port)
|
||||
|
||||
# Block forever.
|
||||
await asyncio.Event().wait()
|
||||
|
||||
try:
|
||||
asyncio.run(_main())
|
||||
except KeyboardInterrupt:
|
||||
_log.info("ws-only gateway shutting down")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
run()
|
||||
@@ -39,6 +39,7 @@ from agent.compaction_display import project_compaction_message_for_display
|
||||
from agent.skill_commands import describe_skill_invocation
|
||||
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
|
||||
from tui_gateway import git_probe
|
||||
from tui_gateway.event_replay import _stamp_event
|
||||
from tui_gateway.turn_marker import (
|
||||
clear_turn_marker,
|
||||
read_turn_marker,
|
||||
@@ -2009,13 +2010,9 @@ def write_json(obj: dict) -> bool:
|
||||
params = obj.get("params")
|
||||
sid = ((params or {}).get("session_id")) if isinstance(params, dict) else ""
|
||||
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
|
||||
from tui_gateway.event_replay import _stamp_event
|
||||
|
||||
_stamp_event(obj)
|
||||
return t.write(obj)
|
||||
|
||||
from tui_gateway.event_replay import _stamp_event
|
||||
|
||||
_stamp_event(obj)
|
||||
return (current_transport() or _stdio_transport).write(obj)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user