"""PTY bridge for `hermes dashboard` chat tab. Wraps a child process behind a pseudo-terminal so its ANSI output can be streamed to a browser-side terminal emulator (xterm.js) and typed keystrokes can be fed back in. The only caller today is the ``/api/pty`` WebSocket endpoint in ``hermes_cli.web_server``. * **POSIX-only.** This module depends on ``fcntl``, ``termios``, and ``ptyprocess``, none of which exist on native Windows Python. Native Windows ConPTY is a different API (Windows 10 build 17763+) and would need a separate Windows implementation (``pywinpty``) — that's tracked as a future enhancement. """ from __future__ import annotations import errno import fcntl import os import select import signal import struct import sys import termios import time from typing import Optional, Sequence try: import ptyprocess # type: ignore _PTY_AVAILABLE = not sys.platform.startswith("win") except ImportError: # pragma: no cover - dev env without ptyprocess ptyprocess = None # type: ignore _PTY_AVAILABLE = False __all__ = ["PtyBridge", "PtyUnavailableError"] # ``struct winsize`` packs rows/cols as unsigned short (0..65535). We clamp # well below that ceiling: real terminals never exceed a couple thousand # columns, and a value above this is a broken probe (WSL2 reports # columns=131072) rather than a genuine ultrawide. Lower bound is 1 — a # zero/negative dimension is the classic "no size yet" signal. _MIN_DIMENSION = 1 _MAX_COLS = 2000 _MAX_ROWS = 1000 def _clamp_dimension(value: int, maximum: int) -> int: """Clamp a reported terminal dimension into ``[_MIN_DIMENSION, maximum]``. Non-integer / non-finite values fall back to ``_MIN_DIMENSION`` so a bad probe can never reach ``struct.pack`` and raise ``struct.error``. """ try: n = int(value) except (TypeError, ValueError, OverflowError): return _MIN_DIMENSION return max(_MIN_DIMENSION, min(n, maximum)) class PtyUnavailableError(RuntimeError): """Raised when a PTY cannot be created on this platform. Today this means native Windows (no ConPTY bindings) or a dev environment missing ``ptyprocess``. The dashboard surfaces the message as a chat-tab banner. """ class PtyBridge: """Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. Not thread-safe. A single bridge is owned by the WebSocket handler that spawned it; the reader runs in an executor thread while writes happen on the event-loop thread. """ def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined] self._proc = proc self._fd: int = proc.fd self._closed = False # -- lifecycle -------------------------------------------------------- @classmethod def is_available(cls) -> bool: """True if a PTY can be spawned on this platform.""" return bool(_PTY_AVAILABLE) @classmethod def spawn( cls, argv: Sequence[str], *, cwd: Optional[str] = None, env: Optional[dict] = None, cols: int = 80, rows: int = 24, ) -> "PtyBridge": """Spawn ``argv`` behind a new PTY and return a bridge.""" if not _PTY_AVAILABLE: if sys.platform.startswith("win"): raise PtyUnavailableError( "Pseudo-terminals are unavailable on this platform. " "Hermes Agent supports Windows only via WSL." ) if ptyprocess is None: raise PtyUnavailableError( "The `ptyprocess` package is missing. " "Install with: pip install ptyprocess (or pip install -e '.[pty]')." ) raise PtyUnavailableError("Pseudo-terminals are unavailable.") # PTY-hosted programs expect TERM to describe the terminal type. # CI often runs without TERM in the parent process, which makes # simple terminal probes like `tput cols` fail before winsize reads. # Preserve explicit caller overrides, but backfill a sensible default # when TERM is missing or blank. # env=None fallback: callers own env policy (process_registry already # sanitizes). Build via the factory with exact preservation so the # site stays findable without changing inherited content. from tools.environments.local import build_subprocess_env spawn_env = ( build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) if env is None else env.copy() ) if not spawn_env.get("TERM"): spawn_env["TERM"] = "xterm-256color" proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr] list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols) ) return cls(proc) @property def pid(self) -> int: return int(self._proc.pid) def is_alive(self) -> bool: if self._closed: return False try: return bool(self._proc.isalive()) except Exception: return False # -- I/O -------------------------------------------------------------- def read(self, timeout: float = 0.2) -> Optional[bytes]: """Read up to 64 KiB of raw bytes from the PTY master. Never blocks longer than ``timeout`` seconds. Safe to call after :meth:`close`; returns ``None`` in that case. """ if self._closed: return None try: readable, _, _ = select.select([self._fd], [], [], timeout) except (OSError, ValueError): return None if not readable: return b"" try: data = os.read(self._fd, 65536) except OSError as exc: # EIO on Linux = slave side closed. EBADF = already closed. if exc.errno in {errno.EIO, errno.EBADF}: return None raise return data or None def write(self, data: bytes) -> None: """Write raw bytes to the PTY master (i.e. the child's stdin).""" if self._closed or not data: return # os.write can return a short write under load; loop until drained. view = memoryview(data) while view: try: n = os.write(self._fd, view) except OSError as exc: if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}: return raise if n <= 0: return view = view[n:] def resize(self, cols: int, rows: int) -> None: """Forward a terminal resize to the child via ``TIOCSWINSZ``. Dimensions are clamped first: some hosts (WSL2 via xterm.js) report garbage like ``columns=131072, rows=1``, and ``struct winsize`` packs unsigned shorts, so an unclamped value raises ``struct.error`` (not ``OSError``), breaks resizing and leaves the TUI laid out for a one-row screen — the blank/disappearing-text symptom. """ if self._closed: return # struct winsize: rows, cols, xpixel, ypixel (all unsigned short) winsize = struct.pack( "HHHH", _clamp_dimension(rows, _MAX_ROWS), _clamp_dimension(cols, _MAX_COLS), 0, 0 ) try: fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize) except OSError: pass # -- teardown --------------------------------------------------------- def close(self) -> None: """Terminate the child (SIGTERM → 0.5s grace → SIGKILL) and close fds. Idempotent. Reaping the child is important so we don't leak zombies across the lifetime of the dashboard process. """ if self._closed: return self._closed = True try: pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) except Exception: pgid = None # SIGHUP is the conventional "your terminal went away" signal. # Send it to the whole foreground process group, not just the PTY # leader: the dashboard TUI starts helper children such as the Python # slash worker, and killing only the leader can strand those helpers. for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) if not self._proc.isalive(): break try: if pgid is not None: os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) else: self._proc.kill(sig) except Exception: pass deadline = time.monotonic() + 0.5 while self._proc.isalive() and time.monotonic() < deadline: time.sleep(0.02) try: self._proc.close(force=True) except Exception: pass # Context-manager sugar — handy in tests and ad-hoc scripts. def __enter__(self) -> "PtyBridge": return self def __exit__(self, *_exc) -> None: self.close()