diff --git a/apps/desktop/electron/backend-command.test.ts b/apps/desktop/electron/backend-command.test.ts index 2746ce78da..6422bb647a 100644 --- a/apps/desktop/electron/backend-command.test.ts +++ b/apps/desktop/electron/backend-command.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/backend-command.ts b/apps/desktop/electron/backend-command.ts index 23cb87d4f7..233627a53f 100644 --- a/apps/desktop/electron/backend-command.ts +++ b/apps/desktop/electron/backend-command.ts @@ -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 || '')) +} diff --git a/apps/desktop/electron/backend-ready.ts b/apps/desktop/electron/backend-ready.ts index d7644fc06a..a1a07b7541 100644 --- a/apps/desktop/electron/backend-ready.ts +++ b/apps/desktop/electron/backend-ready.ts @@ -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=` 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 ?? (() => '') diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e9ac3b25e4..2bfa9d3e6f 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 ` 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', diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e9e08c8d6b..75a2892429 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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= (and often diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index 0b695e076a..6988f0f87f 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -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 diff --git a/tui_gateway/entry_ws.py b/tui_gateway/entry_ws.py new file mode 100644 index 0000000000..81c2cd356b --- /dev/null +++ b/tui_gateway/entry_ws.py @@ -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=`` 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=``) and passes it to the + renderer, which includes it in the WS URL as ``?token=``. + """ + 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= 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() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6cf7f9ff81..408cde2b88 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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)