"""Chat-tab WebSocket routes: /api/console, /api/pty, the /api/ws gateway sidecar and /api/pub + /api/events broadcast. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import asyncio import functools import logging import json from fastapi import APIRouter from hermes_cli.web_deps import late from fastapi import HTTPException, WebSocket, WebSocketDisconnect from hermes_cli.pty_session import RegistryFull from pathlib import Path from typing import Any, Dict, Optional import re from fastapi import FastAPI _log = logging.getLogger("hermes_cli.web_server") router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _active_session_file_for_channel = late("_active_session_file_for_channel") _build_sidecar_url = late("_build_sidecar_url") _get_console_executor = late("_get_console_executor") _legacy_pump = late("_legacy_pump") _profile_scope = late("_profile_scope") _resolve_chat_argv_async = late("_resolve_chat_argv_async") _resolve_profile_dir = late("_resolve_profile_dir") _ws_auth_ok = late("_ws_auth_ok") _ws_auth_reason = late("_ws_auth_reason") _ws_client_reason = late("_ws_client_reason") _ws_host_origin_reason = late("_ws_host_origin_reason") _ws_request_is_allowed = late("_ws_request_is_allowed") def _get_event_state(app: "FastAPI"): """Return (event_channels, event_lock) from app.state. Lazily initialises the state if the lifespan hasn't run (e.g. when TestClient is constructed without a ``with`` block). The lifespan path is preferred because it guarantees the Lock is created on the correct event loop, but the lazy path lets existing non-``with`` TestClient usages keep working. """ try: return app.state.event_channels, app.state.event_lock except AttributeError: app.state.event_channels = {} app.state.event_lock = asyncio.Lock() return app.state.event_channels, app.state.event_lock _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") def _ws_auth_mode() -> str: """Short label for the active WS auth mode — logged on every connection.""" from hermes_cli.web_server import _LOOPBACK_HOSTS, app if getattr(app.state, "auth_required", False): return "gated" bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() if bound_host and bound_host not in _LOOPBACK_HOSTS: return "insecure" return "loopback" async def _broadcast_event(app: Any, channel: str, payload: str) -> None: """Fan out one publisher frame to every subscriber on `channel`.""" event_channels, event_lock = _get_event_state(app) async with event_lock: subs = list(event_channels.get(channel, ())) for sub in subs: try: await sub.send_text(payload) except Exception: # Subscriber went away mid-send; the /api/events finally clause # will remove it from the registry on its next iteration. _log.warning("broadcast send failed for subscriber on %s", channel, exc_info=True) def _channel_or_close_code(ws: WebSocket) -> Optional[str]: """Return the channel id from the query string or None if invalid.""" channel = ws.query_params.get("channel", "") return channel if _VALID_CHANNEL_RE.match(channel) else None def _read_active_session_file(path: Path) -> Optional[str]: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None session_id = str(data.get("session_id") or "").strip() return session_id or None def _forget_active_session_file(path: Path) -> None: try: path.unlink(missing_ok=True) except OSError: pass def _ws_close_reason(text: str) -> str: """Clamp a WS close reason to the protocol's 123-byte UTF-8 limit. RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a longer string is passed. Our reasons embed an attacker-controlled origin, so truncate defensively rather than crash the close handler. """ encoded = text.encode("utf-8", "replace") if len(encoded) <= 123: return text return encoded[:120].decode("utf-8", "ignore") + "..." # --------------------------------------------------------------------------- # /api/console — safe Hermes Console command WebSocket. # # Unlike /api/pty, this endpoint never spawns a PTY, shell, or full Hermes CLI # subprocess. It runs the curated console engine in-process and exchanges # structured JSON frames with the dashboard xterm overlay. # --------------------------------------------------------------------------- _CONSOLE_PROMPT = "hermes> " _CONSOLE_COMMAND_TIMEOUT_SECONDS = 60.0 _CONSOLE_OUTPUT_LIMIT = 50000 def _console_profile_from_ws(ws: WebSocket) -> Optional[str]: profile = (ws.query_params.get("profile") or "").strip() return profile or None def _execute_console_line( engine: Any, line: str, *, confirmed: bool, profile: Optional[str], ) -> Any: # _profile_scope swaps process-global skill module paths; keep it inside # the worker thread and never hold it across awaits. with _profile_scope(profile): return engine.execute(line, confirmed=confirmed) async def _console_send( ws: WebSocket, send_lock: asyncio.Lock, payload: Dict[str, Any], ) -> None: async with send_lock: await ws.send_json(payload) async def _console_send_result( ws: WebSocket, send_lock: asyncio.Lock, result: Any, *, command_id: int, ) -> None: command = result.command or "" status = result.status if status == "ok": if result.output: await _console_send( ws, send_lock, { "type": "output", "id": command_id, "stream": "stdout", "data": result.output, "command": command, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "ok", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "error": await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": result.output or "Command failed.", "command": command, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "error", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "confirm_required": await _console_send( ws, send_lock, { "type": "confirm_required", "id": command_id, "command": command, "message": result.confirmation_message or f"Run `{command}`?", "prompt": _CONSOLE_PROMPT, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "confirm_required", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "clear": await _console_send(ws, send_lock, {"type": "clear", "id": command_id}) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "clear", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "exit": await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "exit", "command": command, "prompt": "", }, ) return await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": f"Unknown console result status: {status}", "command": command, }, ) def _console_json_payload(msg: Any) -> tuple[Optional[dict[str, Any]], Optional[str]]: raw: str | bytes | None = msg.get("text") if raw is None: raw = msg.get("bytes") if raw is None: return None, None if isinstance(raw, bytes): try: raw = raw.decode("utf-8") except UnicodeDecodeError: return None, "Console frames must be UTF-8 JSON." try: payload = json.loads(raw) except json.JSONDecodeError: return None, "Console frames must be JSON objects." if not isinstance(payload, dict): return None, "Console frames must be JSON objects." return payload, None @router.websocket("/api/console") async def console_ws(ws: WebSocket) -> None: from hermes_cli.web_server import _DASHBOARD_EMBEDDED_CHAT_ENABLED peer = ws.client.host if ws.client else "?" if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: _log.info("console refused: embedded chat disabled peer=%s", peer) await ws.close(code=4404, reason="embedded chat disabled") return auth_reason, cred = _ws_auth_reason(ws) mode = _ws_auth_mode() if auth_reason is not None: _log.warning( "console auth rejected reason=%s mode=%s cred=%s peer=%s", auth_reason, mode, cred, peer, ) await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}")) return host_origin_reason = _ws_host_origin_reason(ws) if host_origin_reason is not None: _log.warning("console refused: %s peer=%s", host_origin_reason, peer) await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason)) return client_reason = _ws_client_reason(ws) if client_reason is not None: _log.warning("console refused: %s", client_reason) await ws.close(code=4408, reason=_ws_close_reason(client_reason)) return await ws.accept() profile = _console_profile_from_ws(ws) send_lock = asyncio.Lock() try: from hermes_cli.console_engine import HermesConsoleEngine engine = HermesConsoleEngine(output_limit=_CONSOLE_OUTPUT_LIMIT) if profile and profile.lower() != "current": _resolve_profile_dir(profile) except HTTPException as exc: await _console_send( ws, send_lock, { "type": "error", "message": str(exc.detail), "prompt": "", }, ) await ws.close(code=4400, reason=_ws_close_reason(str(exc.detail))) return except Exception as exc: _log.exception("console failed to initialize") await _console_send( ws, send_lock, { "type": "error", "message": f"Console unavailable: {exc}", "prompt": "", }, ) await ws.close(code=1011) return _log.info( "console accepted peer=%s mode=%s cred=%s profile=%s", peer, mode, cred, profile or "current", ) await _console_send( ws, send_lock, { "type": "ready", "profile": profile or "current", "prompt": _CONSOLE_PROMPT, }, ) active_task: asyncio.Task | None = None pending_confirmation: Optional[str] = None command_generation = 0 async def run_command(line: str, *, confirmed: bool, command_id: int) -> None: nonlocal active_task, pending_confirmation, command_generation try: loop = asyncio.get_running_loop() result = await asyncio.wait_for( loop.run_in_executor( _get_console_executor(), functools.partial( _execute_console_line, engine, line, confirmed=confirmed, profile=profile, ), ), timeout=_CONSOLE_COMMAND_TIMEOUT_SECONDS, ) except asyncio.CancelledError: raise except asyncio.TimeoutError: if command_id == command_generation: pending_confirmation = None await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": ( "Command timed out. Hermes Console returned to the prompt." ), "command": line, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "timeout", "command": line, "prompt": _CONSOLE_PROMPT, }, ) except Exception as exc: if command_id == command_generation: pending_confirmation = None _log.exception("console command failed") await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": str(exc) or exc.__class__.__name__, "command": line, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "error", "command": line, "prompt": _CONSOLE_PROMPT, }, ) else: if command_id != command_generation: return pending_confirmation = ( result.command if result.status == "confirm_required" else None ) await _console_send_result( ws, send_lock, result, command_id=command_id, ) if result.status == "exit": await ws.close(code=1000) finally: if command_id == command_generation: active_task = None async def start_command(line: str, *, confirmed: bool = False) -> None: nonlocal active_task, command_generation command_generation += 1 command_id = command_generation active_task = asyncio.create_task( run_command(line, confirmed=confirmed, command_id=command_id) ) try: while True: try: msg = await ws.receive() except RuntimeError: break msg_type = msg.get("type") if msg_type == "websocket.disconnect": break payload, error = _console_json_payload(msg) if error: await _console_send( ws, send_lock, { "type": "error", "message": error, "prompt": _CONSOLE_PROMPT, }, ) continue if payload is None: continue frame_type = str(payload.get("type") or "").strip().lower() if frame_type == "ping": await _console_send( ws, send_lock, { "type": "pong", "prompt": _CONSOLE_PROMPT, }, ) continue if frame_type == "cancel": if active_task and not active_task.done(): command_generation += 1 active_task.cancel() active_task = None pending_confirmation = None await _console_send( ws, send_lock, { "type": "complete", "status": "cancelled", "prompt": _CONSOLE_PROMPT, }, ) elif pending_confirmation: pending_confirmation = None await _console_send( ws, send_lock, { "type": "complete", "status": "cancelled", "prompt": _CONSOLE_PROMPT, }, ) else: await _console_send( ws, send_lock, { "type": "complete", "status": "idle", "prompt": _CONSOLE_PROMPT, }, ) continue if active_task and not active_task.done(): await _console_send( ws, send_lock, { "type": "error", "message": "A console command is already running.", "prompt": _CONSOLE_PROMPT, }, ) continue if frame_type == "confirm": command = str(payload.get("command") or pending_confirmation or "").strip() if not pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": "No command is waiting for confirmation.", "prompt": _CONSOLE_PROMPT, }, ) continue if command != pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": "Confirmation does not match the pending command.", "prompt": _CONSOLE_PROMPT, }, ) continue pending_confirmation = None await start_command(command, confirmed=True) continue if frame_type in {"input", "command"}: line = str(payload.get("line") or payload.get("command") or "").strip() if not line: await _console_send( ws, send_lock, { "type": "complete", "status": "ok", "prompt": _CONSOLE_PROMPT, }, ) continue if pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": ( "Confirm or cancel the pending command before " "running another one." ), "prompt": _CONSOLE_PROMPT, }, ) continue await start_command(line) continue await _console_send( ws, send_lock, { "type": "error", "message": f"Unsupported console frame: {frame_type or '?'}", "prompt": _CONSOLE_PROMPT, }, ) except WebSocketDisconnect: pass finally: if active_task and not active_task.done(): active_task.cancel() try: await active_task except (asyncio.CancelledError, Exception): pass @router.websocket("/api/pty") async def pty_ws(ws: WebSocket) -> None: from hermes_cli.web_server import ( PTY_REGISTRY, PtyBridge, PtyUnavailableError, _DASHBOARD_EMBEDDED_CHAT_ENABLED, _PTY_BRIDGE_AVAILABLE, _RESIZE_RE, ) peer = ws.client.host if ws.client else "?" if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: _log.info("pty refused: embedded chat disabled peer=%s", peer) await ws.close(code=4404, reason="embedded chat disabled") return # --- auth + host/origin/peer check (before accept so we can close # cleanly AND tell the client WHY via the close code + reason). # Each gate maps to a distinct close code so the log and the # browser banner agree on the cause: # 4401 bad credential 4403 host/origin mismatch # 4408 peer not allowed 4404 chat disabled auth_reason, cred = _ws_auth_reason(ws) mode = _ws_auth_mode() if auth_reason is not None: _log.warning( "pty auth rejected reason=%s mode=%s cred=%s peer=%s", auth_reason, mode, cred, peer, ) await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}")) return host_origin_reason = _ws_host_origin_reason(ws) if host_origin_reason is not None: _log.warning("pty refused: %s peer=%s", host_origin_reason, peer) await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason)) return client_reason = _ws_client_reason(ws) if client_reason is not None: _log.warning("pty refused: %s", client_reason) await ws.close(code=4408, reason=_ws_close_reason(client_reason)) return await ws.accept() _log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred) # On native Windows, the POSIX PTY bridge can't be imported. Tell the # client and close cleanly rather than pretending the feature works. if not _PTY_BRIDGE_AVAILABLE: await ws.send_text( "\r\n\x1b[31mChat unavailable: the embedded terminal requires a " "POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n" "\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat " "tab — the rest of the dashboard works here.\x1b[0m\r\n" ) await ws.close(code=1011) return # --- spawn PTY ------------------------------------------------------ raw_resume = ws.query_params.get("resume") or None resume = raw_resume profile = ws.query_params.get("profile") or None channel = _channel_or_close_code(ws) sidecar_url = _build_sidecar_url(channel) if channel else None force_fresh = (ws.query_params.get("fresh") or "").strip().lower() in { "1", "true", "yes", "on", } active_session_file: Optional[Path] = None if channel: active_session_file = _active_session_file_for_channel(ws.app, channel) if force_fresh: resume = None _forget_active_session_file(active_session_file) elif not resume: resume = _read_active_session_file(active_session_file) if resume: # The client only knows to pin the viewport to the bottom # when it requested `?resume=`. Tell it a replay is coming # anyway so the implicit active-session fallback gets the # same follow-scroll treatment as an explicit resume (#93518). await ws.send_json({"type": "resume", "id": resume}) resolve_kwargs = { "resume": resume, "sidecar_url": sidecar_url, "profile": profile, } if active_session_file is not None: resolve_kwargs["active_session_file"] = str(active_session_file) try: argv, cwd, env = await _resolve_chat_argv_async(**resolve_kwargs) except HTTPException as exc: # Unknown/invalid profile from _resolve_profile_dir. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n") await ws.close(code=1011) return except SystemExit as exc: # _make_tui_argv calls sys.exit(1) when node/npm is missing. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return attach_token = ws.query_params.get("attach") or None registry_resume = raw_resume if raw_resume and env: registry_resume = env.get("HERMES_TUI_RESUME") or raw_resume if attach_token is not None and (registry_resume or profile): # Key explicit resumes on their canonical target, never the active-session fallback. attach_token = f"{attach_token}\0{profile or ''}\0{registry_resume or ''}" def _spawn(): return PtyBridge.spawn(argv, cwd=cwd, env=env) if attach_token is None: # Legacy path: 1:1 socket<->PTY, killed on disconnect (unchanged). try: bridge = _spawn() except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return except (FileNotFoundError, OSError) as exc: await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") await ws.close(code=1011) return await _legacy_pump(ws, bridge) return # Keep-alive path: the PTY outlives this socket; reattach by token. try: session, _created = await PTY_REGISTRY.attach_or_spawn( attach_token, spawn=_spawn ) except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return except (FileNotFoundError, OSError, RegistryFull) as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return # A fresh xterm cannot reliably reconstruct the TUI from an arbitrary # bounded tail of alternate-screen, differential ANSI output. Reused PTYs # emit a complete frame after replay so reconnects never reopen blank. await session.attach(ws, force_redraw=not _created) # --- writer loop: WebSocket → PTY master ---------------------------- # No reader task here: the session's drain task (spawned once per PTY, # inside the registry) forwards PTY output to whichever socket is # attached and rings-buffers it while detached. On child EOF the drain # closes the attached socket with 4410, which unparks ``ws.receive()`` # below — same half-open-socket protection the legacy pump has (#54028). try: while True: try: msg = await ws.receive() except RuntimeError: # ws.receive() after the socket is already disconnected # (e.g. closed by the drain task on process exit). break if msg.get("type") == "websocket.disconnect": break raw = msg.get("bytes") if raw is None: text = msg.get("text") raw = text.encode("utf-8") if isinstance(text, str) else b"" if not raw: continue # Resize escape is consumed locally, never written to the PTY. match = _RESIZE_RE.match(raw) if match and match.end() == len(raw): session.bridge.resize(cols=int(match.group(1)), rows=int(match.group(2))) continue session.bridge.write(raw) except WebSocketDisconnect: pass finally: # Detach only — the PTY keeps running for a reattach; the registry # reaper closes it after the TTL (or immediately on process exit). PTY_REGISTRY.detach(attach_token, ws) # --------------------------------------------------------------------------- # /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab. # # Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the # dashboard can render structured metadata (model badge, tool-call sidebar, # slash launcher, session info) alongside the xterm.js terminal that PTY # already paints. Both transports bind to the same session id when one is # active, so a tool.start emitted by the agent fans out to both sinks. # --------------------------------------------------------------------------- @router.websocket("/api/ws") async def gateway_ws(ws: WebSocket) -> None: from hermes_cli.web_server import _DASHBOARD_EMBEDDED_CHAT_ENABLED if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return from tui_gateway.ws import handle_ws # The authenticated identity (ticket / internal credential) was stamped # onto the WS object by _ws_auth_reason; carry it into the gateway # transport where it becomes the identity authority for privileged RPCs # (browser.controller.register). None on the legacy token path. await handle_ws( ws, auth_identity=getattr(ws, "_hermes_auth_identity", None), subprotocol=getattr(ws, "_hermes_ws_subprotocol", None), ) # --------------------------------------------------------------------------- # /api/pub + /api/events — chat-tab event broadcast. # # The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by # HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every # dispatcher emit through it. The dashboard fans those frames out to any # subscriber that opened /api/events on the same channel id. This is what # gives the React sidebar its tool-call feed without breaking the PTY # child's stdio handshake with Ink. # --------------------------------------------------------------------------- @router.websocket("/api/pub") async def pub_ws(ws: WebSocket) -> None: from hermes_cli.web_server import _DASHBOARD_EMBEDDED_CHAT_ENABLED if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() try: while True: await _broadcast_event(ws.app, channel, await ws.receive_text()) except WebSocketDisconnect: pass @router.websocket("/api/events") async def events_ws(ws: WebSocket) -> None: from hermes_cli.web_server import _DASHBOARD_EMBEDDED_CHAT_ENABLED if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() event_channels, event_lock = _get_event_state(ws.app) async with event_lock: event_channels.setdefault(channel, set()).add(ws) try: while True: # Subscribers don't speak — the receive() just blocks until # disconnect so the connection stays open as long as the # browser holds it. await ws.receive_text() except WebSocketDisconnect: pass finally: async with event_lock: subs = event_channels.get(channel) if subs is not None: subs.discard(ws) if not subs: event_channels.pop(channel, None)