mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
Merge remote-tracking branch 'origin/main' into bb/skills-renovate
This commit is contained in:
commit
65415b1a12
119 changed files with 13920 additions and 336 deletions
|
|
@ -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
1876
hermes_cli/console_engine.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
18
hermes_cli/subcommands/console.py
Normal file
18
hermes_cli/subcommands/console.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "?"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue