779 lines
33 KiB
Python
779 lines
33 KiB
Python
"""Chat/terminal WebSocket plumbing: PTY bridge selection and registry, WS client/origin/auth gates, chat argv resolution, gateway/sidecar URL building.
|
|
|
|
Split out of ``hermes_cli.web_server``; every externally used name is re-imported
|
|
there, so ``web_server.<name>`` keeps resolving (and monkeypatching) as before.
|
|
Helpers that tests patch on ``web_server`` are reached lazily through it.
|
|
"""
|
|
|
|
import logging
|
|
import asyncio
|
|
import atexit
|
|
import concurrent.futures
|
|
import hmac
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import urllib.request
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from hermes_cli.pty_session import PtySessionRegistry
|
|
|
|
# Same logger the code used before extraction (record parity).
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
|
|
#
|
|
# The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind
|
|
# a POSIX pseudo-terminal, and forwards bytes + resize escapes across a
|
|
# WebSocket. The browser renders the ANSI through xterm.js (see
|
|
# web/src/pages/ChatPage.tsx).
|
|
#
|
|
# Auth: ``?token=<session_token>`` query param (browsers can't set
|
|
# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
|
|
# REST. Localhost-only — we defensively reject non-loopback clients even
|
|
# though uvicorn binds to 127.0.0.1.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
|
|
# uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both
|
|
# expose the same public surface — spawn/read/write/resize/close/is_available —
|
|
# so the /api/pty WebSocket handler needs no platform guards.
|
|
if sys.platform.startswith("win"):
|
|
try:
|
|
from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError
|
|
_PTY_BRIDGE_AVAILABLE = True
|
|
except ImportError: # pragma: no cover - pywinpty missing
|
|
PtyBridge = None # type: ignore[assignment]
|
|
_PTY_BRIDGE_AVAILABLE = False
|
|
|
|
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
|
|
"""Stub when win_pty_bridge cannot be imported."""
|
|
pass
|
|
else:
|
|
try:
|
|
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
|
|
_PTY_BRIDGE_AVAILABLE = True
|
|
except ImportError: # pragma: no cover - dev env without ptyprocess
|
|
PtyBridge = None # type: ignore[assignment]
|
|
_PTY_BRIDGE_AVAILABLE = False
|
|
|
|
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
|
|
"""Stub on platforms where pty_bridge can't be imported."""
|
|
pass
|
|
_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
|
|
_PTY_READ_CHUNK_TIMEOUT = 0.2
|
|
|
|
# Back-off delay between idle PTY reads so a quiet terminal does not spin
|
|
# the event loop. A positive sleep lets other coroutines run and keeps
|
|
# dashboard idle CPU low (#42627).
|
|
_PTY_IDLE_BACKOFF = 0.05
|
|
PTY_REGISTRY = PtySessionRegistry(
|
|
ttl=30 * 60,
|
|
max_sessions=16,
|
|
buffer_cap=1 * 1024 * 1024,
|
|
read_timeout=_PTY_READ_CHUNK_TIMEOUT,
|
|
)
|
|
|
|
|
|
async def _legacy_pump(ws: "WebSocket", bridge) -> None:
|
|
"""Original 1:1 socket<->PTY pump: stream until disconnect, then close the
|
|
bridge. Used when no ``?attach=`` token is supplied (keep-alive opt-in).
|
|
|
|
Behavior is identical to the pre-keep-alive ``pty_ws`` body, including the
|
|
#54028 half-open-socket protection (reader EOF → close the WS so the
|
|
writer's ``ws.receive()`` unparks) and the #53227 ``to_thread`` offloads
|
|
for the blocking ``bridge.close()``.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# --- reader task: PTY master → WebSocket ----------------------------
|
|
async def pump_pty_to_ws() -> None:
|
|
try:
|
|
while True:
|
|
chunk = await loop.run_in_executor(
|
|
None, bridge.read, _PTY_READ_CHUNK_TIMEOUT
|
|
)
|
|
if chunk is None: # EOF
|
|
return
|
|
if not chunk: # no data this tick; yield control and retry
|
|
await asyncio.sleep(_PTY_IDLE_BACKOFF)
|
|
continue
|
|
try:
|
|
await ws.send_bytes(chunk)
|
|
except Exception:
|
|
return
|
|
finally:
|
|
# The child has exited (EOF) or the send side broke. Close the
|
|
# WebSocket so the writer loop's ``ws.receive()`` returns instead
|
|
# of blocking forever — otherwise, when the browser's socket is
|
|
# half-open (no FIN delivered, common on macOS/launchd) the
|
|
# handler never reaches its ``finally`` and the PTY's fds leak.
|
|
# With dashboard auto-reconnect (#52962) every dropped socket then
|
|
# stacks a fresh PTY on top of the orphaned one, exhausting fds.
|
|
#
|
|
# Reap the bridge here too (close() is idempotent): on child EOF the
|
|
# writer loop's ``finally`` is the usual closer, but if the handler
|
|
# task is cancelled the instant we close the WS, that ``finally``
|
|
# can be skipped, leaking the PTY. Closing from the EOF path makes
|
|
# the reap independent of that cancellation race (#54028).
|
|
try:
|
|
await asyncio.to_thread(bridge.close)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
|
|
reader_task = asyncio.create_task(pump_pty_to_ws())
|
|
|
|
# --- writer loop: WebSocket → PTY master ----------------------------
|
|
try:
|
|
while True:
|
|
try:
|
|
msg = await ws.receive()
|
|
except RuntimeError:
|
|
# Raised when ws.receive() is called after the socket is
|
|
# already disconnected (e.g. closed by the reader task above).
|
|
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):
|
|
bridge.resize(cols=int(match.group(1)), rows=int(match.group(2)))
|
|
continue
|
|
bridge.write(raw)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
reader_task.cancel()
|
|
try:
|
|
await reader_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
await asyncio.to_thread(bridge.close)
|
|
|
|
|
|
# Starlette's TestClient reports the peer as "testclient"; treat it as
|
|
# loopback so tests don't need to rewrite request scope.
|
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
|
|
|
|
|
def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
|
|
"""Return a rejection reason for the client IP, or None when allowed.
|
|
|
|
Reasons are short machine-parseable tokens logged on the rejection path
|
|
so a "WS keeps closing" report can be diagnosed from agent.log without a
|
|
repro. ``None`` means the peer IP passed this gate.
|
|
|
|
See :func:`_ws_client_is_allowed` for the full policy rationale.
|
|
"""
|
|
from hermes_cli.web_server import app
|
|
if getattr(app.state, "auth_required", False):
|
|
return None
|
|
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
|
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
|
return None
|
|
client_host = ws.client.host if ws.client else ""
|
|
if not client_host:
|
|
# Fail-closed: a loopback-bound dashboard with auth disabled must
|
|
# not accept a WebSocket with no identifiable peer. ASGI servers
|
|
# behind a misconfigured proxy or unix socket can deliver
|
|
# ws.client == None or "" — treating that as "allowed" would let
|
|
# an unidentified peer reach a loopback-only surface.
|
|
return f"missing_or_empty_peer bound={bound_host or '?'}"
|
|
if client_host in _LOOPBACK_HOSTS:
|
|
return None
|
|
return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
|
|
|
|
|
|
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
|
"""Check if the WebSocket client IP is acceptable.
|
|
|
|
Loopback bind: only loopback clients allowed — the legacy
|
|
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
|
don't want LAN hosts guessing tokens.
|
|
|
|
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
|
|
specific address such as a Tailscale/LAN IP, always with
|
|
``--insecure``): allow any peer. The operator explicitly opted into
|
|
non-loopback exposure, so the loopback-only peer restriction does not
|
|
apply. DNS-rebinding is still blocked by the Host/Origin guard in
|
|
:func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and
|
|
requires the Host header to match the bound interface — the same
|
|
defence ``_is_accepted_host`` applies to non-loopback HTTP requests.
|
|
|
|
Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``
|
|
(enabled when the OAuth gate is active so cookies can pick up
|
|
``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the
|
|
X-Forwarded-For value, which is the real internet client IP. The
|
|
OAuth gate + single-use ``?ticket=`` is the auth at that point; the
|
|
Host/Origin guard in :func:`_ws_host_origin_is_allowed` is what
|
|
blocks DNS-rebinding here, not the peer IP.
|
|
"""
|
|
from hermes_cli.web_server import app
|
|
if getattr(app.state, "auth_required", False):
|
|
return True
|
|
# Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /
|
|
# Tailscale address) means the operator opted into non-loopback
|
|
# access via --insecure. The loopback-only peer gate only applies to
|
|
# an actual loopback bind; otherwise the WS handshake is rejected even
|
|
# though same-bind HTTP requests pass _is_accepted_host.
|
|
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
|
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
|
return True
|
|
client_host = ws.client.host if ws.client else ""
|
|
if not client_host:
|
|
# Fail-closed: see _ws_client_reason for rationale. An empty
|
|
# client_host on a loopback-bound dashboard with auth disabled
|
|
# must be rejected, not accepted as a default-allow.
|
|
return False
|
|
return client_host in _LOOPBACK_HOSTS
|
|
|
|
|
|
def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
|
|
"""Return a Host/Origin rejection reason, or None when allowed.
|
|
|
|
Mirrors :func:`_ws_host_origin_is_allowed` but yields a short
|
|
machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``)
|
|
on rejection so the close path can log *why* the upgrade was refused.
|
|
"""
|
|
from hermes_cli.web_server import _is_accepted_host, app
|
|
bound_host = getattr(app.state, "bound_host", None)
|
|
if not bound_host:
|
|
return None
|
|
|
|
trusted_public_hosts = getattr(
|
|
app.state, "trusted_public_hosts", frozenset()
|
|
)
|
|
|
|
host_header = ws.headers.get("host", "")
|
|
if not _is_accepted_host(
|
|
host_header, bound_host, trusted_public_hosts
|
|
):
|
|
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
|
|
|
|
origin = ws.headers.get("origin", "")
|
|
if not origin:
|
|
return None
|
|
|
|
parsed = urllib.parse.urlparse(origin)
|
|
if parsed.scheme not in {"http", "https"}:
|
|
# Non-web origin (packaged Electron: file://, null, app://). The
|
|
# upstream credential check is the real auth boundary; trust it.
|
|
# See _ws_host_origin_is_allowed for the full rationale.
|
|
return None
|
|
|
|
if not parsed.netloc:
|
|
return f"origin_mismatch origin={origin} bound={bound_host}"
|
|
|
|
if not _is_accepted_host(
|
|
parsed.netloc, bound_host, trusted_public_hosts
|
|
):
|
|
return f"origin_mismatch origin={origin} bound={bound_host}"
|
|
return None
|
|
|
|
|
|
def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
|
"""Apply the dashboard Host/Origin guard to WebSocket upgrades.
|
|
|
|
FastAPI HTTP middleware does not run for WebSocket routes, so the
|
|
DNS-rebinding Host check used for normal dashboard HTTP requests must be
|
|
repeated here before accepting the upgrade. Browsers also send an Origin
|
|
header on WebSocket handshakes; when present, require it to target the
|
|
same bound dashboard host.
|
|
"""
|
|
from hermes_cli.web_server import _ws_host_origin_reason
|
|
return _ws_host_origin_reason(ws) is None
|
|
|
|
|
|
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
|
|
"""Return True when the WebSocket upgrade matches dashboard boundaries."""
|
|
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
|
|
|
|
|
|
_GATEWAY_WS_PROTOCOL = "hermes-gateway-v1"
|
|
_GATEWAY_WS_TICKET_PROTOCOL_PREFIX = "hermes-gateway-ticket."
|
|
|
|
|
|
def _gateway_ws_ticket_from_subprotocol(ws: "WebSocket") -> tuple[str, str]:
|
|
"""Return ``(ticket, reason)`` from an unambiguous gateway protocol set."""
|
|
raw = str(ws.headers.get("sec-websocket-protocol", "") or "")
|
|
protocols = [value.strip() for value in raw.split(",") if value.strip()]
|
|
ticket_protocols = [
|
|
value for value in protocols
|
|
if value.startswith(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX)
|
|
]
|
|
if not ticket_protocols:
|
|
return "", "none"
|
|
if _GATEWAY_WS_PROTOCOL not in protocols or len(ticket_protocols) != 1:
|
|
return "", "invalid"
|
|
ticket = ticket_protocols[0][len(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX):]
|
|
return (ticket, "ok") if ticket else ("", "invalid")
|
|
|
|
|
|
def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
|
"""Validate WS-upgrade auth; return ``(reason, credential)``.
|
|
|
|
``reason`` is None when the credential is accepted, else a short
|
|
machine-parseable token explaining the rejection (``no_credential``,
|
|
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
|
|
``credential`` names which credential type was presented (``ticket``,
|
|
``internal``, ``token``, or ``none``) so the accepted path can log *how*
|
|
a peer authed, not just that it did.
|
|
|
|
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
|
parameter, constant-time compared.
|
|
|
|
Gated (public bind, no ``--insecure``): one of two credentials —
|
|
|
|
* ``?ticket=<single-use>`` — a browser-minted, single-use, 30s-TTL ticket
|
|
consumed against the dashboard-auth ticket store. This is what the SPA
|
|
(and native clients) use.
|
|
* ``?internal=<process-credential>`` — the process-lifetime internal
|
|
credential, used only by WS clients the server spawns itself (the
|
|
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
|
|
is multi-use and never expires so the child can reconnect, and is never
|
|
injected into the SPA — see ``dashboard_auth.ws_tickets`` for the
|
|
threat model.
|
|
|
|
The legacy ``?token=`` path is unconditionally rejected in gated mode
|
|
(the SPA bundle isn't carrying the token any longer, and a leaked
|
|
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
|
|
|
Audit-logs the rejection so operators can debug "WS keeps closing"
|
|
issues from the log.
|
|
"""
|
|
from hermes_cli.web_server import _SESSION_TOKEN, app
|
|
auth_required = bool(getattr(app.state, "auth_required", False))
|
|
if auth_required:
|
|
# Lazy import — keeps this function importable in test harnesses
|
|
# that don't bring in the dashboard_auth layer.
|
|
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
|
from hermes_cli.dashboard_auth.ws_tickets import (
|
|
TicketInvalid,
|
|
consume_internal_credential,
|
|
consume_ticket,
|
|
)
|
|
|
|
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
|
|
# multi-use internal credential rather than a single-use ticket, so
|
|
# they survive reconnects and slow cold boots.
|
|
internal = ws.query_params.get("internal", "")
|
|
if internal:
|
|
try:
|
|
info = consume_internal_credential(internal)
|
|
# Stamp the server-minted identity onto the WS object so the
|
|
# connection (and any transport built from it) can never be
|
|
# impersonated by RPC params. Internal peers are marked
|
|
# ``server-internal`` and are excluded from privileged
|
|
# controller registration downstream.
|
|
ws._hermes_auth_identity = {
|
|
"user_id": info.get("user_id"),
|
|
"provider": info.get("provider"),
|
|
}
|
|
return None, "internal"
|
|
except TicketInvalid as exc:
|
|
audit_log(
|
|
AuditEvent.WS_TICKET_REJECTED,
|
|
reason=f"internal: {exc}",
|
|
ip=(ws.client.host if ws.client else ""),
|
|
path=ws.url.path,
|
|
)
|
|
return "internal_invalid", "internal"
|
|
|
|
protocol_ticket, protocol_reason = _gateway_ws_ticket_from_subprotocol(ws)
|
|
if protocol_reason == "invalid":
|
|
return "ticket_invalid", "ticket-subprotocol"
|
|
ticket = protocol_ticket or ws.query_params.get("ticket", "")
|
|
if not ticket:
|
|
return "no_credential", "none"
|
|
|
|
try:
|
|
info = consume_ticket(ticket)
|
|
# The ticket binds a server-minted {user_id, provider}; stamp it
|
|
# onto the WS object so ``gateway_ws`` can hand it to the gateway
|
|
# transport, where it is the sole identity authority for
|
|
# browser-controller registration. A client can never supply or
|
|
# spoof this value through RPC params. Only the two identity
|
|
# fields are carried — bookkeeping (e.g. ``minted_at``) is not
|
|
# part of the identity contract.
|
|
ws._hermes_auth_identity = {
|
|
"user_id": info.get("user_id"),
|
|
"provider": info.get("provider"),
|
|
}
|
|
if protocol_ticket:
|
|
# Select only the stable public protocol during accept. The
|
|
# ticket-bearing protocol is a credential and must never be
|
|
# reflected back to the browser or retained after admission.
|
|
ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL
|
|
return None, "ticket-subprotocol"
|
|
return None, "ticket"
|
|
except TicketInvalid as exc:
|
|
audit_log(
|
|
AuditEvent.WS_TICKET_REJECTED,
|
|
reason=str(exc),
|
|
ip=(ws.client.host if ws.client else ""),
|
|
path=ws.url.path,
|
|
)
|
|
return "ticket_invalid", "ticket"
|
|
|
|
token = ws.query_params.get("token", "")
|
|
if not token:
|
|
return "no_credential", "none"
|
|
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
|
return None, "token"
|
|
return "token_mismatch", "token"
|
|
|
|
|
|
def _ws_auth_ok(ws: "WebSocket") -> bool:
|
|
"""True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
|
|
from hermes_cli.web_server import _ws_auth_reason
|
|
return _ws_auth_reason(ws)[0] is None
|
|
|
|
|
|
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
|
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
|
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
|
# drops AND the publisher has disconnected.
|
|
# (Channel state and the chat-argv lock are initialised in _lifespan on app
|
|
# startup — see _get_event_state / _get_chat_argv_lock above.)
|
|
|
|
|
|
def _resolve_chat_argv(
|
|
resume: Optional[str] = None,
|
|
sidecar_url: Optional[str] = None,
|
|
profile: Optional[str] = None,
|
|
active_session_file: Optional[str] = None,
|
|
) -> tuple[list[str], Optional[str], Optional[dict]]:
|
|
"""Resolve the argv + cwd + env for the chat PTY.
|
|
|
|
Default: whatever ``hermes --tui`` would run. Tests monkeypatch this
|
|
function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``)
|
|
so nothing has to build Node or the TUI bundle.
|
|
|
|
Session resume is propagated via the ``HERMES_TUI_RESUME`` env var —
|
|
matching what ``hermes_cli.main._launch_tui`` does for the CLI path.
|
|
Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does
|
|
not parse its argv.
|
|
|
|
``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to
|
|
this process's in-memory ``tui_gateway`` instance instead of spawning
|
|
its own Python gateway subprocess.
|
|
|
|
`sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so
|
|
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
|
|
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).
|
|
|
|
`active_session_file` (when set) is forwarded as
|
|
``HERMES_TUI_ACTIVE_SESSION_FILE``. The TUI writes the current session id
|
|
there whenever it creates/resumes/switches sessions, giving the dashboard a
|
|
small cross-process breadcrumb for reconnecting after an unexpected browser
|
|
WebSocket close.
|
|
|
|
`profile` (when set) scopes the ENTIRE chat to that profile by pointing
|
|
``HERMES_HOME`` at the profile dir in the child env. Every spawned
|
|
process (the TUI and the ``tui_gateway.entry`` it launches) resolves
|
|
``get_hermes_home()`` from that env var at its own import, so the child
|
|
binds the profile's config, skills, memory, and state.db from the start
|
|
— the same propagation ``hermes -p <name>`` performs. The in-process
|
|
``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the
|
|
dashboard's in-memory gateway runs under the dashboard's own profile,
|
|
so a profile-scoped chat must spawn its own gateway subprocess.
|
|
"""
|
|
from hermes_cli.web_server import (
|
|
_config_profile_scope,
|
|
_open_session_db_for_profile,
|
|
_resolve_profile_dir,
|
|
_session_latest_descendant,
|
|
)
|
|
from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv
|
|
|
|
profile_dir: Optional[Path] = None
|
|
requested = (profile or "").strip()
|
|
if requested and requested.lower() != "current":
|
|
profile_dir = _resolve_profile_dir(requested)
|
|
|
|
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
|
# Hermes TUI child: build via the single spawn-env factory (profile-home
|
|
# contract applied; secrets kept — the spawned agent needs provider creds).
|
|
# An explicit profile scope still overrides HERMES_HOME before config is
|
|
# bridged into the child environment.
|
|
from tools.environments.local import build_subprocess_env
|
|
env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True)
|
|
if profile_dir is not None:
|
|
env["HERMES_HOME"] = str(profile_dir)
|
|
try:
|
|
from hermes_cli.config import (
|
|
apply_terminal_config_to_env,
|
|
read_raw_config,
|
|
terminal_config_owned_env_vars,
|
|
)
|
|
|
|
if profile_dir is not None:
|
|
# The dashboard process already bridged its own terminal config
|
|
# into os.environ at startup. Remove only keys explicitly owned by
|
|
# that launch profile before applying the selected profile. Values
|
|
# exported by the operator for keys omitted from the launch profile
|
|
# remain valid fallbacks, matching apply_terminal_config_to_env().
|
|
raw_launch_terminal = read_raw_config().get("terminal")
|
|
for env_var in terminal_config_owned_env_vars(raw_launch_terminal):
|
|
env.pop(env_var, None)
|
|
with _config_profile_scope(requested):
|
|
apply_terminal_config_to_env(env=env)
|
|
else:
|
|
apply_terminal_config_to_env(env=env)
|
|
except Exception:
|
|
_log.warning("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
|
|
_apply_tui_python_env(env)
|
|
env.setdefault("NODE_ENV", "production")
|
|
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
|
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
|
# events are consumed by the TUI and forwarded as terminal input, which
|
|
# makes browser-side transcript scrolling feel broken. Keep the terminal
|
|
# build unchanged for native CLI usage; only disable mouse tracking for
|
|
# the dashboard PTY path.
|
|
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
|
env.setdefault("HERMES_TUI_INLINE", "1")
|
|
# The dashboard terminal is xterm.js, which always renders 24-bit RGB.
|
|
# But chalk inside the TUI child decides its color depth from the
|
|
# SERVER process env — and hosted/cloud deploys run the dashboard under
|
|
# a process manager (container init, systemd) with no COLORTERM, so
|
|
# chalk downgrades every hex color to the xterm 256 palette. The skin's
|
|
# bronze border #CD7F32 snaps to palette 173 (#D7875F, salmon-red) and
|
|
# the banner reads red/yellow instead of gold. Local launches dodge
|
|
# this only because the operator's interactive terminal leaks
|
|
# COLORTERM=truecolor into os.environ. Backfill it for the PTY child;
|
|
# setdefault so an explicit operator value still wins.
|
|
env.setdefault("COLORTERM", "truecolor")
|
|
env["HERMES_TUI_DASHBOARD"] = "1"
|
|
|
|
if resume:
|
|
_resume_db = _open_session_db_for_profile(
|
|
requested if profile_dir is not None else None,
|
|
read_only=True,
|
|
)
|
|
try:
|
|
latest_resume, _latest_path = _session_latest_descendant(resume, _resume_db)
|
|
finally:
|
|
_resume_db.close()
|
|
if latest_resume:
|
|
resume = latest_resume
|
|
env["HERMES_TUI_RESUME"] = resume
|
|
|
|
if sidecar_url:
|
|
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
|
|
|
if active_session_file:
|
|
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
|
|
|
|
# Profile-scoped chats must NOT attach to the dashboard's in-memory
|
|
# gateway — it runs under the dashboard's own profile. Without the
|
|
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
|
|
# inherits the profile HERMES_HOME set above.
|
|
if profile_dir is None:
|
|
if gateway_ws_url := _build_gateway_ws_url():
|
|
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
|
|
|
return list(argv), str(cwd) if cwd else None, env
|
|
|
|
|
|
# Hosts that mean "listen on every interface" — the server should bind to
|
|
# them, but an in-container client must NOT dial them: dialing 0.0.0.0
|
|
# resolves to "any local interface", which on most platforms routes through
|
|
# the kernel's wildcard stack and behind a forward proxy (HTTPS_PROXY with
|
|
# a NO_PROXY that doesn't list 0.0.0.0) gets MITM'd into a failed handshake
|
|
# (issue #58993). The fix is to use a loopback address for the client
|
|
# netloc while leaving the bind host alone.
|
|
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"})
|
|
|
|
|
|
def _resolve_client_ws_host() -> Optional[str]:
|
|
"""Return the host the in-container WS client should dial.
|
|
|
|
Resolution order:
|
|
|
|
1. Explicit ``HERMES_DASHBOARD_WS_HOST`` env var — wins always. Operators
|
|
running the dashboard behind a forward proxy can pin a routable host
|
|
(e.g. ``127.0.0.1``, the container's internal IP, or a sidecar DNS
|
|
name) and bypass auto-detection entirely.
|
|
2. The configured bind host — if it's a wildcard (``0.0.0.0`` / ``::``),
|
|
substitute ``127.0.0.1`` since both the dashboard and its TUI child
|
|
run in the same container.
|
|
3. Any other bind host (loopback or LAN IP) — preserved verbatim.
|
|
"""
|
|
from hermes_cli.web_server import app
|
|
explicit = os.environ.get("HERMES_DASHBOARD_WS_HOST", "").strip()
|
|
if explicit:
|
|
return explicit
|
|
|
|
host = getattr(app.state, "bound_host", None)
|
|
if not host:
|
|
return None
|
|
|
|
if host in _WILDCARD_HOSTS:
|
|
return "127.0.0.1"
|
|
|
|
return host
|
|
|
|
|
|
def _build_gateway_ws_url() -> Optional[str]:
|
|
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
|
|
|
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
|
|
|
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
|
server-spawned PTY child authenticates with the process-lifetime internal
|
|
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
|
the child reads this URL once at startup and reuses it on every reconnect,
|
|
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
|
"""
|
|
from hermes_cli.web_server import _SESSION_TOKEN, app
|
|
host = _resolve_client_ws_host()
|
|
port = getattr(app.state, "bound_port", None)
|
|
|
|
if not host or not port:
|
|
return None
|
|
|
|
netloc = (
|
|
f"[{host}]:{port}"
|
|
if ":" in host and not host.startswith("[")
|
|
else f"{host}:{port}"
|
|
)
|
|
|
|
if getattr(app.state, "auth_required", False):
|
|
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
|
|
|
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
|
else:
|
|
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
|
|
|
return f"ws://{netloc}/api/ws?{qs}"
|
|
|
|
|
|
async def _resolve_chat_argv_async(
|
|
resume: Optional[str] = None,
|
|
sidecar_url: Optional[str] = None,
|
|
profile: Optional[str] = None,
|
|
active_session_file: Optional[str] = None,
|
|
) -> tuple[list[str], Optional[str], Optional[dict]]:
|
|
"""Resolve chat argv without blocking the dashboard event loop.
|
|
|
|
``_resolve_chat_argv`` may run ``npm install`` / ``npm run build`` through
|
|
``_make_tui_argv``. Keep that synchronous work off the WebSocket event
|
|
loop so reverse proxies and existing dashboard connections can continue
|
|
to exchange keepalives while the TUI launch command is prepared. The
|
|
async lock preserves the previous one-build-at-a-time behavior when
|
|
multiple browser tabs connect at once without occupying worker threads
|
|
while queued connections wait.
|
|
"""
|
|
from hermes_cli.web_server import _get_chat_argv_lock, _resolve_chat_argv, app
|
|
kwargs = {
|
|
"resume": resume,
|
|
"sidecar_url": sidecar_url,
|
|
"profile": profile,
|
|
}
|
|
if active_session_file is not None:
|
|
kwargs["active_session_file"] = active_session_file
|
|
|
|
async with _get_chat_argv_lock(app):
|
|
return await asyncio.to_thread(
|
|
_resolve_chat_argv,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def _build_sidecar_url(channel: str) -> Optional[str]:
|
|
"""ws:// URL the PTY child should publish events to, or None when unbound.
|
|
|
|
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
|
|
|
Gated mode: authenticates with the process-lifetime internal credential
|
|
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
|
child is a server-spawned process we trust; the credential is multi-use
|
|
and never expires, so the child can reconnect ``/api/pub`` without a new
|
|
URL. (This previously minted a single-use 30s ticket, which meant the
|
|
child could not reconnect and could miss the window on a slow cold boot.)
|
|
Connections authenticated this way are recorded under the
|
|
``server-internal`` identity in the audit log.
|
|
"""
|
|
from hermes_cli.web_server import _SESSION_TOKEN, app
|
|
host = _resolve_client_ws_host()
|
|
port = getattr(app.state, "bound_port", None)
|
|
|
|
if not host or not port:
|
|
return None
|
|
|
|
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
|
|
|
|
if getattr(app.state, "auth_required", False):
|
|
# Gated mode — use the internal credential so the WS upgrade survives
|
|
# _ws_auth_ok and the child can reconnect.
|
|
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
|
|
|
qs = urllib.parse.urlencode(
|
|
{"internal": internal_ws_credential(), "channel": channel}
|
|
)
|
|
else:
|
|
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
|
|
|
return f"ws://{netloc}/api/pub?{qs}"
|
|
|
|
|
|
def _active_session_file_for_channel(app: "FastAPI", channel: str) -> Path:
|
|
"""Return the per-channel file where a dashboard TUI writes its active sid."""
|
|
from hermes_cli.web_server import _get_pty_active_session_files
|
|
files = _get_pty_active_session_files(app)
|
|
existing = files.get(channel)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
fd, raw_path = tempfile.mkstemp(prefix="hermes-pty-active-", suffix=".json")
|
|
os.close(fd)
|
|
path = Path(raw_path)
|
|
files[channel] = path
|
|
return path
|
|
|
|
|
|
# Console commands run in a worker thread. On a timeout, asyncio.wait_for cancels
|
|
# the *awaitable*, but Python threads aren't preemptible, so a genuinely stuck
|
|
# worker keeps running to completion. To keep that from exhausting the shared
|
|
# default thread pool (asyncio.to_thread), we run console commands on a small
|
|
# dedicated, bounded pool: a leaked worker is capped, and concurrent console
|
|
# execution is bounded to a fixed number of threads regardless of reconnects.
|
|
_CONSOLE_EXECUTOR_MAX_WORKERS = 4
|
|
_console_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
|
|
_console_executor_lock = threading.Lock()
|
|
|
|
|
|
def _get_console_executor() -> concurrent.futures.ThreadPoolExecutor:
|
|
"""Lazily create the bounded console worker pool (once per process)."""
|
|
global _console_executor
|
|
if _console_executor is None:
|
|
with _console_executor_lock:
|
|
if _console_executor is None:
|
|
_console_executor = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=_CONSOLE_EXECUTOR_MAX_WORKERS,
|
|
thread_name_prefix="hermes-console",
|
|
)
|
|
# Ensure the pool is torn down on interpreter exit. Don't wait on
|
|
# in-flight workers: a stuck 60s console command must not block
|
|
# shutdown (cancel_futures drops anything not yet started).
|
|
atexit.register(
|
|
lambda: _console_executor
|
|
and _console_executor.shutdown(wait=False, cancel_futures=True)
|
|
)
|
|
return _console_executor
|