Merge remote-tracking branch 'origin/main' into bb/skills-renovate

This commit is contained in:
Brooklyn Nicholson 2026-07-03 13:59:26 -05:00
commit 65415b1a12
119 changed files with 13920 additions and 336 deletions

View file

@ -71,6 +71,7 @@ Examples:
hermes logs errors View errors.log
hermes logs --since 1h Lines from the last hour
hermes debug share Upload debug report for support
hermes console Open the safe Hermes command console
hermes update Update to latest version
hermes dashboard Start web UI dashboard (port 9119)
hermes dashboard --stop Stop running dashboard processes

1876
hermes_cli/console_engine.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -608,7 +608,8 @@ async def api_auth_ws_ticket(request: Request):
Browsers cannot set ``Authorization`` on a WebSocket upgrade, so in
gated mode the SPA POSTs this endpoint to get a ``?ticket=`` value to
append to ``/api/pty``, ``/api/ws``, ``/api/pub``, or ``/api/events``.
append to ``/api/pty``, ``/api/console``, ``/api/ws``, ``/api/pub``, or
``/api/events``.
The ticket has a 30-second TTL and is single-use. Calling this endpoint
multiple times in quick succession (e.g. one ticket per WS) is the

View file

@ -287,6 +287,7 @@ from hermes_cli.subcommands.debug import build_debug_parser
from hermes_cli.subcommands.backup import build_backup_parser
from hermes_cli.subcommands.import_cmd import build_import_cmd_parser
from hermes_cli.subcommands.config import build_config_parser
from hermes_cli.subcommands.console import build_console_parser
from hermes_cli.subcommands.version import build_version_parser
from hermes_cli.subcommands.update import build_update_parser
from hermes_cli.subcommands.uninstall import build_uninstall_parser
@ -8776,6 +8777,193 @@ def _wait_for_windows_update_gateway_exit(
return survivors
def _venv_core_imports_healthy() -> tuple[bool, str]:
"""Probe the project venv for the core imports the backend needs to boot.
Runs a tiny import check inside the venv interpreter (NOT this process
``hermes update`` may be driven by a different Python). Catches the
half-updated-venv state: git checkout current but a dependency sync that
failed or was killed partway (e.g. Windows access-denied on a loaded
.pyd), leaving imports like ``fastapi``'s new transitive deps missing.
Without this probe, ``hermes update`` on a current checkout prints
"Already up to date!" and returns without ever re-syncing dependencies
the user's install stays broken no matter how many times they update
(ryanc's incident, July 2026).
Returns ``(healthy, detail)``. Never raises; unknown states report
healthy so a probe failure can't force needless reinstalls.
"""
venv_dir = PROJECT_ROOT / "venv"
python_name = "python.exe" if _is_windows() else "python"
bin_dir = "Scripts" if _is_windows() else "bin"
venv_python = venv_dir / bin_dir / python_name
if not venv_python.exists():
# No venv interpreter at all. In a dev checkout that's normal (the
# dev may run hermes from any interpreter), so report healthy to
# avoid forcing reinstalls. But on a MANAGED install (the Windows
# installer / desktop bootstrap stamps `.hermes-bootstrap-complete`,
# and an interrupted update leaves `.update-incomplete`), the venv
# IS the install — its absence means a repair got interrupted after
# the old venv was moved aside, and "Already up to date!" would
# gaslight the user while nothing can run.
managed_markers = (
PROJECT_ROOT / ".hermes-bootstrap-complete",
_update_marker_path(),
)
if any(m.exists() for m in managed_markers):
return False, f"venv python missing ({venv_python})"
return True, ""
# Core web/serve imports plus their newest transitive deps. Import (not
# just metadata) — a package can have intact dist-info but a missing
# module after an interrupted uninstall/install cycle.
check = (
"import importlib\n"
"mods = ['fastapi', 'uvicorn', 'pydantic', 'openai', 'yaml']\n"
"missing = []\n"
"for m in mods:\n"
" try: importlib.import_module(m)\n"
" except Exception as e: missing.append(f'{m}: {e}')\n"
"print('\\n'.join(missing))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check],
capture_output=True,
text=True,
timeout=60,
cwd=PROJECT_ROOT,
)
except Exception as exc:
logger.debug("venv health probe failed to run: %s", exc)
return True, ""
missing = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
if result.returncode != 0 and not missing:
# Interpreter itself is broken (e.g. deleted stdlib) — that IS unhealthy.
detail = (result.stderr or "").strip().splitlines()
return False, detail[0] if detail else "venv python failed to run"
if missing:
return False, "; ".join(missing[:4])
return True, ""
def _detect_venv_python_processes(
*, exclude_pids: set[int] | None = None
) -> list[tuple[int, str, str]]:
"""Find live processes running from the project venv's interpreter.
The hermes.exe shim guard misses the biggest lock-holder class on
Windows: the Desktop app's backend (``python.exe -m hermes_cli.main
serve``) and anything else running straight off ``venv\\Scripts\\python
(w).exe``. Those processes keep native ``.pyd`` extensions mapped, so a
dependency sync mid-update dies with access-denied and strands the venv
half-updated (ryanc's brotlicffi/_sodium.pyd incidents, July 2026).
Killing them from here is pointless the Desktop app supervises its
backend and respawns it within seconds so the caller should refuse and
tell the user to close the app instead. Returns ``(pid, name, cmdline)``
tuples; empty off-Windows / without psutil / when nothing matches. The
calling process and its ancestors are always excluded (a CLI ``hermes
update`` itself runs from the venv python). Never raises.
"""
if not _is_windows():
return []
try:
import psutil
except Exception:
return []
venv_dir = PROJECT_ROOT / "venv"
try:
venv_prefix = str(venv_dir.resolve()).lower().rstrip(os.sep) + os.sep
except OSError:
venv_prefix = str(venv_dir).lower().rstrip(os.sep) + os.sep
try:
root_prefix = str(PROJECT_ROOT.resolve()).lower().rstrip(os.sep) + os.sep
except OSError:
root_prefix = str(PROJECT_ROOT).lower().rstrip(os.sep) + os.sep
skip: set[int] = set(exclude_pids or set())
skip.add(os.getpid())
try:
for anc in psutil.Process().parents():
skip.add(int(anc.pid))
except Exception:
pass
matches: list[tuple[int, str, str]] = []
try:
proc_iter = psutil.process_iter(["pid", "exe", "name", "cmdline", "cwd"])
except Exception:
return []
for proc in proc_iter:
try:
info = proc.info
except Exception:
continue
pid = info.get("pid")
exe = info.get("exe")
if not exe or pid is None or int(pid) in skip:
continue
try:
exe_norm = str(Path(exe).resolve()).lower()
except (OSError, ValueError):
exe_norm = str(exe).lower()
cmdline_raw = " ".join(info.get("cmdline") or [])
cmdline_low = cmdline_raw.lower()
cwd_low = str(info.get("cwd") or "").lower().rstrip(os.sep) + os.sep
# Primary match: the executable itself lives under this venv
# (venv\Scripts\python(w).exe — the desktop backend / gateway case).
is_holder = exe_norm.startswith(venv_prefix)
# Fallback: uv/base-interpreter trampolines run a python whose exe is
# OUTSIDE the venv but which still imports from it and holds its .pyd
# files. Catch those by what they're running: a cmdline that references
# this venv's path, or a `-m hermes_cli.main ...` invocation tied to
# this install (install root in the cmdline or as the working dir).
if not is_holder and venv_prefix in cmdline_low:
is_holder = True
if not is_holder and "hermes_cli.main" in cmdline_low:
if root_prefix in cmdline_low or cwd_low.startswith(root_prefix):
is_holder = True
if not is_holder:
continue
name = info.get("name") or Path(exe).name
matches.append((int(pid), str(name), cmdline_raw[:120]))
return matches
def _format_venv_python_holders_message(matches: list[tuple[int, str, str]]) -> str:
"""Explain which venv processes block the update and how to clear them."""
lines = [
"✗ Other Hermes processes are running from this install's venv:",
]
for pid, name, cmdline in matches[:6]:
hint = ""
low = cmdline.lower()
if "serve" in low or "dashboard" in low:
hint = " ← Hermes Desktop backend (close the desktop app)"
elif "gateway" in low:
hint = " ← gateway"
lines.append(f" PID {pid} {name} {cmdline}{hint}")
if len(matches) > 6:
lines.append(f" ... and {len(matches) - 6} more")
lines.append("")
lines.append(
" On Windows these keep native extension files (.pyd) locked, so the"
)
lines.append(
" dependency update would fail partway and leave a broken install."
)
lines.append(
" Close the Hermes desktop app / other Hermes terminals, then re-run:"
)
lines.append(" hermes update")
lines.append(" (or use `hermes update --force-venv` to proceed anyway at your own risk)")
return "\n".join(lines)
def _pause_windows_gateways_for_update() -> dict | None:
"""Stop running Windows gateways before mutating the checkout or venv.
@ -9235,6 +9423,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
_windows_gateway_resume,
)
# With gateways paused, anything still running from the venv interpreter
# (most commonly the Desktop app's `hermes serve` backend) will keep .pyd
# files locked and corrupt the dependency sync below. Refuse rather than
# race: killing the desktop backend is futile (the app supervises and
# respawns it), so the user must close the app. Deliberately NOT bypassed
# by plain --force: the desktop bootstrap updater passes --force to skip
# the hermes.exe shim guard above, but its lock probe only checks the shim
# and app.asar — a non-desktop venv python holding a .pyd would sail
# through and corrupt the sync (the exact failure this guard exists for).
# --force-venv is the explicit escape hatch.
if _is_windows() and not getattr(args, "force_venv", False):
_venv_holders = _detect_venv_python_processes()
if _venv_holders:
print(_format_venv_python_holders_message(_venv_holders))
_resume_windows_gateways_after_update(_windows_gateway_resume)
sys.exit(2)
# Try git-based update first, fall back to ZIP download on Windows
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
use_zip_update = False
@ -9436,7 +9641,57 @@ def _cmd_update_impl(args, gateway_mode: bool):
text=True,
check=False,
)
print("✓ Already up to date!")
# A current checkout does NOT imply a healthy install: a previous
# dependency sync may have failed partway (classic on Windows,
# where a running gateway/desktop backend keeps .pyd files locked
# and uv/pip dies with access-denied, stranding the venv between
# versions). Probe the venv's core imports and repair if broken —
# otherwise "Already up to date!" gaslights the user while their
# install stays bricked.
healthy, detail = _venv_core_imports_healthy()
if not healthy:
print("⚠ Checkout is current, but the venv is unhealthy:")
print(f" {detail}")
print("→ Repairing Python dependencies...")
_write_update_incomplete_marker()
from hermes_cli.managed_uv import ensure_uv
repair_uv = ensure_uv()
# A managed install whose venv is gone entirely (interrupted
# repair after the old venv was moved aside) needs the venv
# recreated before dependencies can be installed into it.
venv_python_missing = not (
PROJECT_ROOT
/ "venv"
/ ("Scripts" if _is_windows() else "bin")
/ ("python.exe" if _is_windows() else "python")
).exists()
if venv_python_missing and repair_uv:
print("→ Recreating virtual environment...")
subprocess.run(
[repair_uv, "venv", "venv"],
cwd=PROJECT_ROOT,
check=False,
)
if repair_uv:
repair_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
_install_python_dependencies_with_optional_fallback(
[repair_uv, "pip"], env=repair_env, group="all"
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"], group="all"
)
_clear_update_incomplete_marker()
healthy_after, detail_after = _venv_core_imports_healthy()
if healthy_after:
print("✓ Dependencies repaired!")
else:
print(f"⚠ Venv still unhealthy after repair: {detail_after}")
print(" Close all Hermes windows/gateways and re-run: hermes update")
else:
print("✓ Already up to date!")
_resume_windows_gateways_after_update(_windows_gateway_resume)
return
@ -11896,6 +12151,13 @@ def cmd_logs(args):
)
def cmd_console(args):
"""Open the safe Hermes command console."""
from hermes_cli.console_engine import run_console_repl
return run_console_repl()
def _build_provider_choices() -> list[str]:
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
try:
@ -11925,7 +12187,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
{
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "cron", "curator", "dashboard", "serve", "debug", "doctor",
"config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
@ -12748,6 +13010,11 @@ def main():
# =========================================================================
build_config_parser(subparsers, cmd_config=cmd_config)
# =========================================================================
# console command (parser built in hermes_cli/subcommands/console.py)
# =========================================================================
build_console_parser(subparsers, cmd_console=cmd_console)
# =========================================================================
# pairing command (parser built in hermes_cli/subcommands/pairing.py)
# =========================================================================

View file

@ -1446,7 +1446,7 @@ def _model_flow_named_custom(config, provider_info):
model = {"default": model} if model else {}
cfg["model"] = model
if provider_key:
model["provider"] = provider_key
model["provider"] = "custom:" + provider_key.strip().lower().replace(" ", "-")
model.pop("base_url", None)
model.pop("api_key", None)
else:

View file

@ -0,0 +1,18 @@
"""``hermes console`` subcommand parser."""
from __future__ import annotations
from typing import Callable
def build_console_parser(subparsers, *, cmd_console: Callable) -> None:
"""Attach the safe Hermes Console REPL subcommand."""
console_parser = subparsers.add_parser(
"console",
help="Open the safe Hermes command console",
description=(
"Open a curated Hermes command REPL. This is not a raw shell and "
"does not expose the full Hermes CLI."
),
)
console_parser.set_defaults(func=cmd_console)

View file

@ -65,6 +65,12 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
"--force",
action="store_true",
default=False,
help="Windows: proceed with the update even when another hermes.exe is detected. The concurrent process will likely cause WinError 32 warnings and may leave a reboot-deferred .exe replacement.",
help="Windows: proceed with the update even when another hermes.exe is detected. The concurrent process will likely cause WinError 32 warnings and may leave a reboot-deferred .exe replacement. Does NOT bypass the venv-process guard (see --force-venv).",
)
update_parser.add_argument(
"--force-venv",
action="store_true",
default=False,
help="Windows: mutate the venv even while other processes are running from its interpreter (desktop backend, gateway, terminals). Those processes keep native .pyd files locked, so the dependency sync will likely fail partway and strand the install half-updated. Use only if you know the detected holders are false positives.",
)
update_parser.set_defaults(func=cmd_update)

View file

@ -12,8 +12,11 @@ Usage:
from contextlib import asynccontextmanager, contextmanager
import asyncio
import atexit
import base64
import binascii
import concurrent.futures
import functools
from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
@ -1186,6 +1189,19 @@ _FS_READDIR_HIDDEN = {
"target",
"venv",
}
# Filenames that must never be listed, read, or downloaded through the
# managed-files API. These typically contain credentials (API keys, tokens)
# and exposing them through the dashboard file browser is a security leak —
# see issue #57505.
def _is_sensitive_filename(name: str) -> bool:
"""Return True for ``.env`` and any ``.env.<suffix>`` variant.
Case-insensitive so ``.ENV`` / ``.Env.local`` on case-insensitive
filesystems (macOS/Windows mounts) can't slip past the guard.
"""
lowered = name.lower()
return lowered == ".env" or lowered.startswith(".env.")
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
@ -1616,7 +1632,11 @@ async def list_managed_files(request: Request, path: Optional[str] = None):
raise HTTPException(status_code=400, detail="Path is not a directory")
try:
entries = [_managed_file_entry(policy, child) for child in target.iterdir()]
entries = [
_managed_file_entry(policy, child)
for child in target.iterdir()
if not _is_sensitive_filename(child.name)
]
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not readable")
except OSError as exc:
@ -1642,6 +1662,8 @@ async def read_managed_file(request: Request, path: str):
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_filename(target.name):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")
try:
size = target.stat().st_size
@ -1684,6 +1706,8 @@ async def download_managed_file(request: Request, path: str):
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if _is_sensitive_filename(target.name):
raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed")
try:
size = target.stat().st_size
@ -12750,6 +12774,582 @@ def _ws_close_reason(text: str) -> str:
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
# 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
def _dashboard_console_context() -> str:
"""Choose local vs hosted command policy for the dashboard console."""
return "hosted" if _default_hermes_root_is_opt_data() else "local"
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
@app.websocket("/api/console")
async def console_ws(ws: WebSocket) -> None:
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)
context = _dashboard_console_context()
send_lock = asyncio.Lock()
try:
from hermes_cli.console_engine import HermesConsoleEngine
engine = HermesConsoleEngine(
output_limit=_CONSOLE_OUTPUT_LIMIT,
context=context, # type: ignore[arg-type]
)
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 context=%s profile=%s",
peer,
mode,
cred,
context,
profile or "current",
)
await _console_send(
ws,
send_lock,
{
"type": "ready",
"context": context,
"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
@app.websocket("/api/pty")
async def pty_ws(ws: WebSocket) -> None:
peer = ws.client.host if ws.client else "?"