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:
kshitijk4poor
2026-08-25 03:23:46 +05:30
parent 760d3af0d7
commit 434ea57eb0
8 changed files with 444 additions and 45 deletions
+37 -4
View File
@@ -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)
})
+27 -3
View File
@@ -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 || ''))
}
+9 -6
View File
@@ -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
View File
@@ -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',
+25
View File
@@ -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
+12
View File
@@ -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
+227
View File
@@ -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()
+1 -4
View File
@@ -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)