diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 2049b4426a54..774c92447db5 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1406,6 +1406,11 @@ updates: # (the ~/.hermes/.env file is reserved for API keys and secrets). # # dashboard: +# # full: complete admin dashboard (default) +# # lightweight: bounded read-only dashboard; loopback-only unless the +# # launch explicitly passes --insecure +# mode: full +# # oauth: # client_id: "" # agent:{instance_id}; Portal provisions this at deploy # portal_url: "" # blank → default https://portal.nousresearch.com diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7d83e09267c..f864af9e58d4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2036,6 +2036,9 @@ def _ensure_hermes_home_managed(home: Path): # Web dashboard settings "dashboard": { + # full: complete FastAPI/React admin dashboard. lightweight: bounded, + # read-only stdlib dashboard for memory-constrained hosts. + "mode": "full", "theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose" # Process-isolation rollout controls. Runtime reads these through the # raw config loader, so tui_gateway.server also owns explicit defaults. diff --git a/hermes_cli/lightweight_dashboard.py b/hermes_cli/lightweight_dashboard.py new file mode 100644 index 000000000000..00e41463b807 --- /dev/null +++ b/hermes_cli/lightweight_dashboard.py @@ -0,0 +1,729 @@ +"""Memory-bounded read-only dashboard built on the Python standard library.""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import socket +import tempfile +import threading +import time +import webbrowser +from contextlib import contextmanager +from dataclasses import dataclass +from functools import cached_property +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Iterator +from urllib.parse import parse_qs, quote, unquote, urlsplit + +from hermes_cli import __release_date__, __version__ + +logger = logging.getLogger(__name__) + +_LOOPBACK_NAMES = frozenset({"localhost", "127.0.0.1", "::1"}) +_SESSION_BLOB_FIELDS = frozenset({"system_prompt", "model_config"}) +_TRANSCRIPT_FIELD_LIMIT = 64 * 1024 +_FILE_PREVIEW_LIMIT = 512 * 1024 +_DIRECTORY_ENTRY_LIMIT = 500 +_LOG_WINDOW_LIMIT = 512 * 1024 +_LOG_NAMES = { + "agent": "agent.log", + "desktop": "desktop.log", + "errors": "errors.log", + "gateway": "gateway.log", + "gui": "gui.log", + "mcp": "mcp-stderr.log", +} +_PRIVATE_FILES = frozenset({ + ".git-credentials", + ".netrc", + "auth.json", + "auth.lock", + "config.yaml", + "credentials", + "google_oauth.json", + "google_oauth_pending.json", + "google_token.json", + "webhook_subscriptions.json", +}) +_PRIVATE_DIRECTORIES = frozenset({ + ".aws", + ".git", + ".gnupg", + ".ssh", + "mcp-tokens", + "pairing", +}) +_CONFIG_VIEW = { + "agent": frozenset({"max_iterations", "reasoning_effort"}), + "dashboard": frozenset({"mode", "theme"}), + "delegation": frozenset({ + "child_timeout_seconds", + "max_concurrent_children", + "max_iterations", + "max_spawn_depth", + "orchestrator_enabled", + }), + "logging": frozenset({"level"}), + "memory": frozenset({"provider"}), + "terminal": frozenset({"backend", "cwd", "timeout"}), +} + + +class DashboardProblem(Exception): + def __init__(self, status: HTTPStatus, detail: str): + super().__init__(detail) + self.status = status + self.detail = detail + + +def _limited_int(raw: str | None, *, default: int, minimum: int, maximum: int) -> int: + try: + value = default if raw is None else int(raw) + except (TypeError, ValueError): + value = default + return max(minimum, min(value, maximum)) + + +def _compact_value(value: Any) -> Any: + if value is None: + return None + if isinstance(value, str): + if len(value) <= _TRANSCRIPT_FIELD_LIMIT: + return value + return value[:_TRANSCRIPT_FIELD_LIMIT] + "\n...[truncated]" + try: + rendered = json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + rendered = str(value) + if len(rendered) <= _TRANSCRIPT_FIELD_LIMIT: + return value + return { + "preview": rendered[:_TRANSCRIPT_FIELD_LIMIT] + "...[truncated]", + "truncated": True, + } + + +def _private_path(path: Path) -> bool: + name = path.name.lower() + if name == ".env" or name == ".envrc" or name.startswith(".env."): + return True + if name in _PRIVATE_FILES: + return True + return any(part.lower() in _PRIVATE_DIRECTORIES for part in path.parts) + + +@dataclass(frozen=True) +class ProfileView: + requested_name: str | None = None + + @cached_property + def identity(self) -> tuple[str, Path]: + from hermes_cli import profiles + + raw = (self.requested_name or "default").strip() or "default" + name = profiles.normalize_profile_name(raw) + try: + profiles.validate_profile_name(name) + except ValueError as exc: + raise DashboardProblem(HTTPStatus.BAD_REQUEST, str(exc)) from exc + if not profiles.profile_exists(name): + raise DashboardProblem( + HTTPStatus.NOT_FOUND, f"Profile {name!r} does not exist" + ) + return name, profiles.get_profile_dir(name) + + @property + def name(self) -> str: + return self.identity[0] + + @property + def home(self) -> Path: + return self.identity[1] + + @contextmanager + def session_db(self) -> Iterator[Any | None]: + path = self.home / "state.db" + if not path.exists(): + yield None + return + from hermes_state import SessionDB + + database = SessionDB(db_path=path, read_only=True) + try: + yield database + finally: + database.close() + + def raw_config(self) -> dict[str, Any]: + path = self.home / "config.yaml" + if not path.exists(): + return {} + try: + import yaml + + config = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as exc: + raise DashboardProblem( + HTTPStatus.BAD_REQUEST, "Profile config could not be parsed" + ) from exc + if not isinstance(config, dict): + raise DashboardProblem( + HTTPStatus.BAD_REQUEST, "Profile config must contain a mapping" + ) + return config + + def status(self) -> dict[str, Any]: + from gateway.status import ( + derive_gateway_busy, + derive_gateway_drainable, + get_runtime_status_running_pid, + get_running_pid_cached, + parse_active_agents, + read_runtime_status, + ) + + runtime = read_runtime_status(self.home / "gateway_state.json") or {} + pid = get_running_pid_cached(self.home / "gateway.pid", cleanup_stale=False) + if pid is None: + pid = get_runtime_status_running_pid(runtime, expected_home=self.home) + running = pid is not None + state = runtime.get("gateway_state") + if not running and state != "startup_failed": + state = "stopped" + agents = parse_active_agents(runtime.get("active_agents", 0)) + recent = self.sessions(limit=50, offset=0, order="recent")["sessions"] + active = sum(1 for row in recent if row["is_active"]) + return { + "active_agents": agents, + "active_sessions": active, + "gateway_busy": derive_gateway_busy( + gateway_running=running, + gateway_state=state, + active_agents=agents, + ), + "gateway_drainable": derive_gateway_drainable( + gateway_running=running, + gateway_state=state, + ), + "gateway_pid": pid, + "gateway_platforms": runtime.get("platforms") or {}, + "gateway_running": running, + "gateway_state": state, + "mode": "lightweight", + "profile": self.name, + "release_date": __release_date__, + "version": __version__, + } + + def sessions(self, *, limit: int, offset: int, order: str) -> dict[str, Any]: + if order not in {"created", "recent"}: + raise DashboardProblem( + HTTPStatus.BAD_REQUEST, "order must be created or recent" + ) + with self.session_db() as database: + if database is None: + return { + "limit": limit, + "offset": offset, + "sessions": [], + "total": 0, + } + rows = database.list_sessions_rich( + compact_rows=True, + limit=limit, + offset=offset, + order_by_last_active=order == "recent", + ) + total = database.session_count(exclude_children=True) + now = time.time() + sessions: list[dict[str, Any]] = [] + for source in rows: + row = dict(source) + for key in _SESSION_BLOB_FIELDS: + row.pop(key, None) + last_active = row.get("last_active", row.get("started_at", 0)) + row["archived"] = bool(row.get("archived")) + row["is_active"] = bool( + row.get("ended_at") is None and now - last_active < 300 + ) + sessions.append(row) + return { + "limit": limit, + "offset": offset, + "sessions": sessions, + "total": total, + } + + def session_metadata(self, session_id: str) -> dict[str, Any]: + with self.session_db() as database: + resolved = database.resolve_session_id(session_id) if database else None + row = database.get_session(resolved) if database and resolved else None + if row is None: + raise DashboardProblem(HTTPStatus.NOT_FOUND, "Session not found") + result = dict(row) + for key in _SESSION_BLOB_FIELDS: + result.pop(key, None) + result["archived"] = bool(result.get("archived")) + result["profile"] = self.name + return result + + def transcript(self, session_id: str, *, limit: int, offset: int) -> dict[str, Any]: + with self.session_db() as database: + resolved = database.resolve_session_id(session_id) if database else None + if database is None or resolved is None: + raise DashboardProblem(HTTPStatus.NOT_FOUND, "Session not found") + resumed = database.resolve_resume_session_id(resolved) + rows = database.get_messages(resumed, limit=limit, offset=offset) + messages = [] + bounded_fields = { + "codex_message_items", + "codex_reasoning_items", + "content", + "reasoning", + "reasoning_content", + "reasoning_details", + "tool_calls", + } + for source in rows: + message = dict(source) + for key in bounded_fields.intersection(message): + message[key] = _compact_value(message[key]) + messages.append(message) + return { + "messages": messages, + "pagination": { + "limit": limit, + "offset": offset, + "returned": len(messages), + }, + "session_id": resumed, + } + + def files_root(self) -> Path: + configured = os.environ.get("HERMES_DASHBOARD_FILES_ROOT", "").strip() + if configured: + candidate = Path(configured).expanduser() + else: + terminal = self.raw_config().get("terminal") or {} + cwd = ( + str(terminal.get("cwd") or "").strip() + if isinstance(terminal, dict) + else "" + ) + candidate = ( + Path(cwd).expanduser() + if cwd not in {"", ".", "auto", "cwd"} + else Path.cwd() + ) + try: + root = candidate.resolve(strict=True) + except (FileNotFoundError, OSError, RuntimeError) as exc: + raise DashboardProblem( + HTTPStatus.NOT_FOUND, "Managed files root is unavailable" + ) from exc + if not root.is_dir(): + raise DashboardProblem( + HTTPStatus.NOT_FOUND, "Managed files root is unavailable" + ) + return root + + def resolve_file(self, requested: str | None) -> tuple[Path, Path]: + root = self.files_root() + raw = str(requested or "").strip() + if "\x00" in raw: + raise DashboardProblem(HTTPStatus.BAD_REQUEST, "Invalid path") + candidate = Path(raw).expanduser() if raw else root + if not candidate.is_absolute(): + candidate = root / candidate + try: + target = candidate.resolve(strict=True) + except (FileNotFoundError, OSError, RuntimeError) as exc: + raise DashboardProblem(HTTPStatus.NOT_FOUND, "Path not found") from exc + if target != root and root not in target.parents: + raise DashboardProblem( + HTTPStatus.FORBIDDEN, "Path is outside the managed files root" + ) + if _private_path(target): + raise DashboardProblem( + HTTPStatus.FORBIDDEN, "Access to sensitive files is not allowed" + ) + return root, target + + def directory(self, requested: str | None) -> dict[str, Any]: + root, target = self.resolve_file(requested) + if not target.is_dir(): + raise DashboardProblem(HTTPStatus.BAD_REQUEST, "Path is not a directory") + try: + children = sorted( + (child for child in target.iterdir() if not _private_path(child)), + key=lambda child: (not child.is_dir(), child.name.lower()), + ) + except PermissionError as exc: + raise DashboardProblem( + HTTPStatus.FORBIDDEN, "Directory is not readable" + ) from exc + entries = [] + for child in children[:_DIRECTORY_ENTRY_LIMIT]: + try: + resolved = child.resolve(strict=True) + if resolved != root and root not in resolved.parents: + continue + stat_result = resolved.stat() + except (FileNotFoundError, OSError, RuntimeError): + continue + entries.append({ + "is_directory": resolved.is_dir(), + "mtime": stat_result.st_mtime, + "name": child.name, + "path": str(resolved.relative_to(root)), + "size": None if resolved.is_dir() else stat_result.st_size, + }) + return { + "entries": entries, + "parent": None if target == root else str(target.parent.relative_to(root)), + "path": "." if target == root else str(target.relative_to(root)), + "read_only": True, + "root": str(root), + "truncated": len(children) > _DIRECTORY_ENTRY_LIMIT, + } + + def file_preview(self, requested: str) -> dict[str, Any]: + root, target = self.resolve_file(requested) + if not target.is_file(): + raise DashboardProblem(HTTPStatus.BAD_REQUEST, "Path is not a file") + size = target.stat().st_size + if size > _FILE_PREVIEW_LIMIT: + raise DashboardProblem( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + f"File exceeds the {_FILE_PREVIEW_LIMIT // 1024} KiB preview limit", + ) + try: + data = target.read_bytes() + except PermissionError as exc: + raise DashboardProblem( + HTTPStatus.FORBIDDEN, "File is not readable" + ) from exc + if b"\x00" in data: + raise DashboardProblem( + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + "Binary files cannot be previewed", + ) + return { + "content": data.decode("utf-8", errors="replace"), + "name": target.name, + "path": str(target.relative_to(root)), + "read_only": True, + "size": size, + } + + def logs(self, *, name: str, lines: int, search: str) -> dict[str, Any]: + filename = _LOG_NAMES.get(name) + if filename is None: + raise DashboardProblem(HTTPStatus.BAD_REQUEST, f"Unknown log file: {name}") + path = self.home / "logs" / filename + if not path.exists(): + return {"file": name, "lines": [], "truncated": False} + try: + with path.open("rb") as handle: + handle.seek(0, os.SEEK_END) + size = handle.tell() + handle.seek(max(0, size - _LOG_WINDOW_LIMIT)) + content = handle.read(_LOG_WINDOW_LIMIT) + except PermissionError as exc: + raise DashboardProblem( + HTTPStatus.FORBIDDEN, "Log file is not readable" + ) from exc + result = content.decode("utf-8", errors="replace").splitlines() + if search: + needle = search.lower() + result = [line for line in result if needle in line.lower()] + return { + "file": name, + "lines": result[-lines:], + "truncated": size > _LOG_WINDOW_LIMIT, + } + + def safe_config(self) -> dict[str, Any]: + source = self.raw_config() + result: dict[str, Any] = {} + for key in ("api_mode", "model", "provider"): + value = source.get(key) + if isinstance(value, (str, int, float, bool, type(None))) and key in source: + result[key] = value + for section, fields in _CONFIG_VIEW.items(): + values = source.get(section) + if not isinstance(values, dict): + continue + visible = {key: values[key] for key in fields if key in values} + if visible: + result[section] = visible + result.setdefault("dashboard", {})["mode"] = "lightweight" + return {"config": result, "profile": self.name, "read_only": True} + + +_PAGE = b""" + +Hermes Lightweight Dashboard

Hermes Lightweight Dashboard

Loading...
+ +
Gateway-
State-
Active sessions-
Active agents-

Recent Sessions

+

Sessions

Session Detail

Select a session
+
.

File Preview

Select a file
+
Loading...
+
Loading...
+
""" + + +def _normal_host(value: str | None) -> str: + raw = (value or "").strip().lower() + if raw.startswith("["): + closing = raw.find("]") + return raw[: closing + 1] if closing >= 0 else raw + return raw.rsplit(":", 1)[0] + + +def _ip_host(value: str) -> bool: + candidate = value[1:-1] if value.startswith("[") and value.endswith("]") else value + try: + ipaddress.ip_address(candidate) + except ValueError: + return False + return True + + +def _loopback(host: str) -> bool: + lowered = (host or "127.0.0.1").strip().lower() + if lowered in _LOOPBACK_NAMES: + return True + try: + return ipaddress.ip_address(lowered).is_loopback + except ValueError: + return False + + +class LightweightHandler(BaseHTTPRequestHandler): + server_version = "HermesLightweight/1" + + def log_message(self, format: str, *args: Any) -> None: + logger.debug("lightweight dashboard: " + format, *args) + + def reply(self, status: HTTPStatus, body: bytes, content_type: str) -> None: + self.send_response(status.value) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + ) + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.end_headers() + self.wfile.write(body) + + def json_reply(self, status: HTTPStatus, payload: Any) -> None: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode() + self.reply(status, body, "application/json; charset=utf-8") + + def do_GET(self) -> None: # noqa: N802 + host = _normal_host(self.headers.get("Host")) + exact_hosts = getattr(self.server, "accepted_hosts", set()) + accept_ip = bool(getattr(self.server, "accept_ip_hosts", False)) + if host not in exact_hosts and not (accept_ip and _ip_host(host)): + self.json_reply(HTTPStatus.BAD_REQUEST, {"detail": "Invalid Host header"}) + return + parsed = urlsplit(self.path) + query = parse_qs(parsed.query) + profile = (query.get("profile") or [None])[0] + view = ProfileView(profile) + try: + if parsed.path in {"", "/"}: + self.reply(HTTPStatus.OK, _PAGE, "text/html; charset=utf-8") + elif parsed.path == "/api/status": + self.json_reply(HTTPStatus.OK, view.status()) + elif parsed.path == "/api/sessions": + self.json_reply( + HTTPStatus.OK, + view.sessions( + limit=_limited_int( + (query.get("limit") or [None])[0], + default=20, + minimum=1, + maximum=100, + ), + offset=_limited_int( + (query.get("offset") or [None])[0], + default=0, + minimum=0, + maximum=100000, + ), + order=(query.get("order") or ["recent"])[0], + ), + ) + elif parsed.path.startswith("/api/sessions/"): + suffix = parsed.path.removeprefix("/api/sessions/") + if suffix.endswith("/messages"): + session_id = unquote(suffix.removesuffix("/messages")) + payload = view.transcript( + session_id, + limit=_limited_int( + (query.get("limit") or [None])[0], + default=30, + minimum=1, + maximum=50, + ), + offset=_limited_int( + (query.get("offset") or [None])[0], + default=0, + minimum=0, + maximum=100000, + ), + ) + else: + payload = view.session_metadata(unquote(suffix)) + self.json_reply(HTTPStatus.OK, payload) + elif parsed.path == "/api/files/read": + self.json_reply( + HTTPStatus.OK, + view.file_preview((query.get("path") or [""])[0]), + ) + elif parsed.path == "/api/files": + self.json_reply( + HTTPStatus.OK, + view.directory((query.get("path") or [None])[0]), + ) + elif parsed.path == "/api/logs": + self.json_reply( + HTTPStatus.OK, + view.logs( + name=(query.get("file") or ["agent"])[0], + lines=_limited_int( + (query.get("lines") or [None])[0], + default=100, + minimum=1, + maximum=500, + ), + search=(query.get("search") or [""])[0], + ), + ) + elif parsed.path == "/api/config": + self.json_reply(HTTPStatus.OK, view.safe_config()) + else: + self.json_reply(HTTPStatus.NOT_FOUND, {"detail": "Not found"}) + except DashboardProblem as exc: + self.json_reply(exc.status, {"detail": exc.detail}) + except Exception: + logger.exception("lightweight dashboard request failed") + self.json_reply( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"detail": "Internal server error"}, + ) + + +class IPv6LightweightServer(ThreadingHTTPServer): + address_family = socket.AF_INET6 + + +def _ready_file(port: int) -> None: + destination = os.environ.get("HERMES_DESKTOP_READY_FILE", "").strip() + if not destination: + return + path = Path(destination) + temporary = "" + try: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, delete=False + ) as handle: + json.dump({"port": port}, handle, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + temporary = handle.name + os.replace(temporary, path) + except Exception as exc: + if temporary: + Path(temporary).unlink(missing_ok=True) + logger.warning("Could not write dashboard ready file: %s", exc) + + +def _browser_url(host: str, port: int, initial_profile: str) -> str: + display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host + if ":" in display_host and not display_host.startswith("["): + display_host = f"[{display_host}]" + url = f"http://{display_host}:{port}/" + return f"{url}?profile={quote(initial_profile)}" if initial_profile else url + + +def run_lightweight_dashboard( + *, + host: str, + port: int, + open_browser: bool, + initial_profile: str, + allow_remote: bool, +) -> None: + """Run the lightweight dashboard without importing the full web backend.""" + if not _loopback(host) and not allow_remote: + raise SystemExit( + "Lightweight dashboard refuses non-loopback binds by default. " + "Use a tunnel or pass --insecure on a trusted private network." + ) + if allow_remote and not _loopback(host): + print( + "WARNING: lightweight dashboard remote access is unauthenticated and read-only.", + flush=True, + ) + server_type = IPv6LightweightServer if ":" in host else ThreadingHTTPServer + server = server_type((host, port), LightweightHandler) + normalized = host.strip().lower() + bracketed = f"[{normalized}]" if ":" in normalized else normalized + server.accepted_hosts = {normalized, bracketed} + server.accept_ip_hosts = bool(allow_remote and not _loopback(host)) + actual_port = int(server.server_address[1]) + _ready_file(actual_port) + url = _browser_url(host, actual_port, initial_profile) + print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True) + print(f" Hermes Lightweight Dashboard -> {url}") + if open_browser: + threading.Thread( + target=lambda: (time.sleep(0.8), webbrowser.open(url)), daemon=True + ).start() + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1525041c111a..fb42fe7a1d16 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12348,6 +12348,22 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None: print() +def _use_lightweight_dashboard(args, *, headless_backend: bool) -> bool: + """Resolve the dashboard-only backend choice without touching the web stack.""" + if headless_backend: + return False + if bool(getattr(args, "light_dashboard", False)): + return True + try: + from hermes_cli.config import read_raw_config + + dashboard = read_raw_config().get("dashboard") or {} + mode = str(dashboard.get("mode") or "full").strip().lower() + except Exception: + return False + return mode in {"light", "legacy", "lightweight"} + + def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" # --status: report running dashboards and exit, no deps needed. @@ -12372,6 +12388,9 @@ def cmd_dashboard(args): # ready sentinel. Resolved once and threaded through the re-exec, the # build gate, and start_server. _headless_backend = getattr(args, "headless_backend", False) + _lightweight_backend = _use_lightweight_dashboard( + args, headless_backend=_headless_backend + ) # ── Unified profile launch routing ──────────────────────────────── # The dashboard is a MACHINE management surface: it can read/write any @@ -12431,6 +12450,8 @@ def cmd_dashboard(args): reexec_argv.append("--insecure") if getattr(args, "skip_build", False): reexec_argv.append("--skip-build") + if _lightweight_backend: + reexec_argv.append("--light") env = os.environ.copy() # Pin the child to the machine ROOT, not the launching profile's # HERMES_HOME. We must resolve the root explicitly instead of just @@ -12469,6 +12490,18 @@ def cmd_dashboard(args): except Exception: pass + if _lightweight_backend: + from hermes_cli.lightweight_dashboard import run_lightweight_dashboard + + run_lightweight_dashboard( + host=args.host, + port=args.port, + open_browser=not args.no_open, + initial_profile=getattr(args, "open_profile", "") or "", + allow_remote=bool(getattr(args, "insecure", False)), + ) + return + try: import fastapi # noqa: F401 import uvicorn # noqa: F401 diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index a345a9d9d599..b9d147bf0fb2 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -33,10 +33,9 @@ def _add_server_runtime_args(parser) -> None: "--insecure", action="store_true", help=( - "DEPRECATED / NO-OP. Formerly bypassed auth on a non-loopback " - "bind. As of the June 2026 hardening it no longer disables " - "authentication — a public bind always requires an auth provider " - "(password or OAuth). Bind 127.0.0.1 + tunnel to keep it local." + "For --light only, permit an unauthenticated non-loopback bind. " + "It remains a no-op for the full dashboard/serve backend, where " + "public binds always require an auth provider." ), ) parser.add_argument( @@ -107,6 +106,18 @@ def build_dashboard_parser( dashboard_parser.add_argument( "--no-open", action="store_true", help="Don't open browser automatically" ) + dashboard_parser.add_argument( + "--light", + dest="light_dashboard", + action="store_true", + help="Use the memory-bounded read-only dashboard", + ) + dashboard_parser.add_argument( + "--legacy", + dest="light_dashboard", + action="store_true", + help=argparse.SUPPRESS, + ) # Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the # backend as `hermes dashboard --no-open --tui --host ... --port ...`. The # `--tui` flag was removed from this subcommand in cae6b5486 (embedded chat is diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5b3f4482a4d8..79778eb06439 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -688,6 +688,11 @@ def _memory_provider_options() -> List[str]: "description": "Web dashboard visual theme", "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], }, + "dashboard.mode": { + "type": "select", + "description": "Dashboard backend mode", + "options": ["full", "lightweight"], + }, "display.resume_display": { "type": "select", "description": "How resumed sessions display history", diff --git a/scripts/benchmark_dashboard_memory.py b/scripts/benchmark_dashboard_memory.py new file mode 100644 index 000000000000..63f5f54cbee2 --- /dev/null +++ b/scripts/benchmark_dashboard_memory.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Measure dashboard startup, request latency, and process-tree RSS. + +Run from the repository root with its development environment active: + + python scripts/benchmark_dashboard_memory.py --mode full --runs 3 + python scripts/benchmark_dashboard_memory.py --mode light --runs 3 + +The benchmark drives the real CLI entrypoint and exercises the first page plus +status, session metadata/transcript, files, logs, and config. This catches lazy +imports and request-time growth that a startup-only RSS sample would miss. +""" + +from __future__ import annotations + +import argparse +from contextlib import suppress +import json +import math +import os +from pathlib import Path +import queue +import re +import signal +import statistics +import subprocess +import sys +import tempfile +import threading +import time +from typing import Any +from urllib.request import Request, urlopen + +import psutil + + +READY_RE = re.compile(r"HERMES_(?:DASHBOARD|BACKEND)_READY port=(\d+)") +MIB = 1024 * 1024 + + +def _process_tree_rss(pid: int) -> int: + try: + root = psutil.Process(pid) + processes = [root, *root.children(recursive=True)] + except (psutil.NoSuchProcess, psutil.AccessDenied): + return 0 + + total = 0 + for process in processes: + with suppress(psutil.NoSuchProcess, psutil.AccessDenied): + total += process.memory_info().rss + return total + + +def _request(url: str, *, timeout: float, token: str) -> tuple[float, bytes]: + started = time.perf_counter() + request = Request(url, headers={"X-Hermes-Session-Token": token}) + with urlopen(request, timeout=timeout) as response: # noqa: S310 - loopback benchmark + body = response.read() + if response.status != 200: + raise RuntimeError(f"{url} returned HTTP {response.status}") + return (time.perf_counter() - started) * 1000, body + + +def _p95(values: list[float]) -> float: + ordered = sorted(values) + return ordered[max(0, math.ceil(len(ordered) * 0.95) - 1)] + + +def _terminate(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + with suppress(ProcessLookupError): + if os.name == "nt": + process.terminate() + else: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + with suppress(ProcessLookupError): + if os.name == "nt": + process.kill() + else: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def _run_once(args: argparse.Namespace) -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="hermes-dashboard-bench-") as tmp: + root = Path(tmp) + home = root / "home" + web_dist = root / "web-dist" + workspace = root / "workspace" + home.mkdir() + web_dist.mkdir() + workspace.mkdir() + (web_dist / "assets").mkdir() + (web_dist / "index.html").write_text( + "Hermes benchmark", encoding="utf-8" + ) + (workspace / "benchmark.txt").write_text( + "Hermes dashboard memory benchmark\n", encoding="utf-8" + ) + (home / "logs").mkdir() + (home / "logs" / "agent.log").write_text( + "INFO dashboard benchmark fixture\n", encoding="utf-8" + ) + (home / "config.yaml").write_text( + f"model: benchmark/model\nterminal:\n cwd: {workspace}\n", + encoding="utf-8", + ) + from hermes_state import SessionDB + + database = SessionDB(db_path=home / "state.db") + database.create_session( + "dashboard-benchmark-session", "cli", model="benchmark/model" + ) + database.append_message( + "dashboard-benchmark-session", + "user", + "benchmark transcript message", + ) + database.close() + + command = [ + args.python, + "-m", + "hermes_cli.main", + "dashboard", + "--host", + "127.0.0.1", + "--port", + "0", + "--no-open", + ] + if args.mode == "light": + command.append("--light") + + env = { + **os.environ, + "HERMES_HOME": str(home), + "HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-benchmark-token", + "HERMES_DASHBOARD_FILES_ROOT": str(workspace), + "HERMES_WEB_DIST": str(web_dist), + "PYTHONUNBUFFERED": "1", + } + token = env["HERMES_DASHBOARD_SESSION_TOKEN"] + process = subprocess.Popen( + command, + cwd=args.repo_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=os.name != "nt", + ) + lines: queue.Queue[str | None] = queue.Queue() + output: list[str] = [] + stop_sampling = threading.Event() + peak_rss = 0 + + def read_output() -> None: + assert process.stdout is not None + for line in process.stdout: + output.append(line.rstrip()) + lines.put(line) + lines.put(None) + + def sample_memory() -> None: + nonlocal peak_rss + while not stop_sampling.wait(0.05): + peak_rss = max(peak_rss, _process_tree_rss(process.pid)) + + threading.Thread(target=read_output, daemon=True).start() + sampler = threading.Thread(target=sample_memory, daemon=True) + sampler.start() + started = time.perf_counter() + + try: + deadline = started + args.startup_timeout + port = None + while time.perf_counter() < deadline: + if process.poll() is not None: + raise RuntimeError( + f"dashboard exited with {process.returncode}: {' | '.join(output[-12:])}" + ) + try: + line = lines.get(timeout=min(0.25, deadline - time.perf_counter())) + except queue.Empty: + continue + if line is None: + continue + match = READY_RE.search(line) + if match: + port = int(match.group(1)) + break + if port is None: + raise TimeoutError( + f"dashboard did not announce readiness: {' | '.join(output[-12:])}" + ) + + startup_ms = (time.perf_counter() - started) * 1000 + time.sleep(args.settle_seconds) + ready_rss = _process_tree_rss(process.pid) + base_url = f"http://127.0.0.1:{port}" + + root_ms, root_body = _request( + f"{base_url}/", timeout=args.request_timeout, token=token + ) + time.sleep(args.settle_seconds) + root_rss = _process_tree_rss(process.pid) + + status_ms, status_body = _request( + f"{base_url}/api/status", timeout=args.request_timeout, token=token + ) + json.loads(status_body) + time.sleep(args.settle_seconds) + status_rss = _process_tree_rss(process.pid) + + sessions_ms, sessions_body = _request( + f"{base_url}/api/sessions?limit=20", + timeout=args.request_timeout, + token=token, + ) + json.loads(sessions_body) + time.sleep(args.settle_seconds) + sessions_rss = _process_tree_rss(process.pid) + + workflow_started = time.perf_counter() + daily_urls = ( + f"{base_url}/api/sessions/dashboard-benchmark-session", + f"{base_url}/api/sessions/dashboard-benchmark-session/messages?limit=20", + f"{base_url}/api/files", + f"{base_url}/api/files/read?path=benchmark.txt", + f"{base_url}/api/logs?file=agent&lines=100", + f"{base_url}/api/config", + ) + for url in daily_urls: + _elapsed, body = _request( + url, + timeout=args.request_timeout, + token=token, + ) + json.loads(body) + workflow_ms = (time.perf_counter() - workflow_started) * 1000 + time.sleep(args.settle_seconds) + daily_use_rss = _process_tree_rss(process.pid) + + repeated: list[float] = [] + for _ in range(args.requests): + elapsed, _body = _request( + f"{base_url}/api/status", + timeout=args.request_timeout, + token=token, + ) + repeated.append(elapsed) + + peak_rss = max(peak_rss, _process_tree_rss(process.pid)) + return { + "mode": args.mode, + "startup_ms": round(startup_ms, 2), + "root_ms": round(root_ms, 2), + "status_ms": round(status_ms, 2), + "sessions_ms": round(sessions_ms, 2), + "workflow_ms": round(workflow_ms, 2), + "status_p95_ms": round(_p95(repeated), 2), + "ready_rss_mib": round(ready_rss / MIB, 2), + "root_rss_mib": round(root_rss / MIB, 2), + "status_rss_mib": round(status_rss / MIB, 2), + "sessions_rss_mib": round(sessions_rss / MIB, 2), + "daily_use_rss_mib": round(daily_use_rss / MIB, 2), + "peak_rss_mib": round(peak_rss / MIB, 2), + "request_growth_mib": round((daily_use_rss - ready_rss) / MIB, 2), + "root_bytes": len(root_body), + } + finally: + stop_sampling.set() + sampler.join(timeout=1) + _terminate(process) + + +def _summary(results: list[dict[str, Any]]) -> dict[str, float]: + numeric_keys = [ + key for key, value in results[0].items() if isinstance(value, int | float) + ] + return { + key: round(statistics.median(float(result[key]) for result in results), 2) + for key in numeric_keys + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=("full", "light"), required=True) + parser.add_argument("--runs", type=int, default=3) + parser.add_argument("--requests", type=int, default=10) + parser.add_argument("--startup-timeout", type=float, default=30) + parser.add_argument("--request-timeout", type=float, default=15) + parser.add_argument("--settle-seconds", type=float, default=0.25) + parser.add_argument("--python", default=sys.executable) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.runs < 1 or args.requests < 1: + parser.error("--runs and --requests must be positive") + + results = [_run_once(args) for _ in range(args.runs)] + payload = { + "commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=args.repo_root, text=True + ).strip(), + "platform": sys.platform, + "python": sys.version.split()[0], + "runs": results, + "median": _summary(results), + } + rendered = json.dumps(payload, indent=2, sort_keys=True) + print(rendered) + if args.output: + args.output.write_text(rendered + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py index 2c46d29c99c0..17a48320b158 100644 --- a/tests/hermes_cli/test_dashboard_unified_launch.py +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -121,6 +121,24 @@ def fake_exec(exe, argv, env): # and the .install_method stamp actually live. assert env.get("HERMES_HOME") == "/opt/data" + def test_profile_reroute_preserves_light_dashboard_mode(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False) + execs = [] + + def fake_exec(exe, argv, env): + execs.append((exe, argv, env)) + raise SystemExit(0) + + monkeypatch.setattr(main_mod.os, "execvpe", fake_exec) + + with pytest.raises(SystemExit): + main_mod.cmd_dashboard(_args(light_dashboard=True)) + + assert "--light" in execs[0][1] + def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch): """A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT reroute into the machine dashboard. The reroute re-execs as the default @@ -232,3 +250,22 @@ def test_dashboard_starts_mcp_discovery_for_ws_backend(self, main_mod, monkeypat "thread_name": "dashboard-mcp-discovery", } ] + + def test_light_dashboard_skips_mcp_discovery(self, main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + calls = [] + monkeypatch.setattr( + "hermes_cli.mcp_startup.start_background_mcp_discovery", + lambda **kwargs: calls.append(kwargs), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.lightweight_dashboard", + types.SimpleNamespace(run_lightweight_dashboard=lambda **_kwargs: None), + ) + + main_mod.cmd_dashboard(_args(light_dashboard=True)) + + assert calls == [] diff --git a/tests/hermes_cli/test_lightweight_dashboard.py b/tests/hermes_cli/test_lightweight_dashboard.py new file mode 100644 index 000000000000..c98d8b23eefd --- /dev/null +++ b/tests/hermes_cli/test_lightweight_dashboard.py @@ -0,0 +1,301 @@ +"""Behavior and integration coverage for the stdlib lightweight dashboard.""" + +from __future__ import annotations + +import argparse +import builtins +import json +import threading +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli.main import cmd_dashboard + + +def _arguments(**overrides): + defaults = { + "headless_backend": False, + "host": "127.0.0.1", + "insecure": False, + "isolated": False, + "light_dashboard": False, + "no_open": True, + "open_profile": "", + "port": 0, + "skip_build": False, + "status": False, + "stop": False, + } + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def test_lightweight_branch_precedes_full_web_imports(): + imported = builtins.__import__ + starts = [] + + def guarded_import(name, *args, **kwargs): + if name in {"fastapi", "uvicorn", "hermes_cli.web_server"}: + raise AssertionError(f"full dashboard import reached: {name}") + return imported(name, *args, **kwargs) + + with ( + patch("builtins.__import__", side_effect=guarded_import), + patch( + "hermes_cli.lightweight_dashboard.run_lightweight_dashboard", + side_effect=lambda **kwargs: starts.append(kwargs), + ), + ): + cmd_dashboard(_arguments(light_dashboard=True)) + + assert starts == [ + { + "allow_remote": False, + "host": "127.0.0.1", + "initial_profile": "", + "open_browser": False, + "port": 0, + } + ] + + +def test_config_mode_selects_lightweight_only_for_dashboard(): + starts = [] + with ( + patch( + "hermes_cli.config.read_raw_config", + return_value={"dashboard": {"mode": "lightweight"}}, + ), + patch( + "hermes_cli.lightweight_dashboard.run_lightweight_dashboard", + side_effect=lambda **kwargs: starts.append(kwargs), + ), + ): + cmd_dashboard(_arguments()) + assert len(starts) == 1 + + +def test_profile_view_resolves_named_profile(monkeypatch, tmp_path): + from hermes_cli.lightweight_dashboard import ProfileView + + home = tmp_path / "worker" + home.mkdir() + monkeypatch.setattr( + "hermes_cli.profiles.profile_exists", lambda name: name == "worker" + ) + monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda name: home) + + view = ProfileView("Worker") + + assert view.name == "worker" + assert view.home == home + + +def test_sessions_use_compact_read_only_database(monkeypatch, tmp_path): + from hermes_cli import lightweight_dashboard as light + + home = tmp_path / "profile" + home.mkdir() + (home / "state.db").touch() + observed = {} + + class FakeDatabase: + def __init__(self, *, db_path, read_only): + observed["open"] = (db_path, read_only) + + def list_sessions_rich(self, **kwargs): + observed["list"] = kwargs + return [ + { + "archived": 0, + "ended_at": None, + "id": "s1", + "last_active": 1, + "model_config": "large", + "system_prompt": "large", + } + ] + + def session_count(self, **kwargs): + observed["count"] = kwargs + return 1 + + def close(self): + observed["closed"] = True + + monkeypatch.setattr(light.ProfileView, "identity", ("default", home)) + monkeypatch.setattr("hermes_state.SessionDB", FakeDatabase) + + payload = light.ProfileView().sessions(limit=5, offset=2, order="recent") + + assert observed["open"] == (home / "state.db", True) + assert observed["list"] == { + "compact_rows": True, + "limit": 5, + "offset": 2, + "order_by_last_active": True, + } + assert observed["count"] == {"exclude_children": True} + assert observed["closed"] is True + assert "system_prompt" not in payload["sessions"][0] + assert "model_config" not in payload["sessions"][0] + + +def test_file_view_is_confined_and_hides_credentials(monkeypatch, tmp_path): + from hermes_cli.lightweight_dashboard import DashboardProblem, ProfileView + + root = tmp_path / "workspace" + root.mkdir() + (root / "README.md").write_text("hello", encoding="utf-8") + (root / ".env").write_text("SECRET=value", encoding="utf-8") + (root / "config.yaml").write_text("api_key: value", encoding="utf-8") + outside = tmp_path / "outside.txt" + outside.write_text("outside", encoding="utf-8") + (root / "escape.txt").symlink_to(outside) + view = ProfileView() + monkeypatch.setattr(ProfileView, "files_root", lambda self: root) + + listing = view.directory(None) + preview = view.file_preview("README.md") + + assert [entry["name"] for entry in listing["entries"]] == ["README.md"] + assert preview["content"] == "hello" + with pytest.raises(DashboardProblem) as exc: + view.file_preview(str(outside)) + assert exc.value.status.value == 403 + + +def test_config_view_is_allowlisted(monkeypatch): + from hermes_cli.lightweight_dashboard import ProfileView + + view = ProfileView() + monkeypatch.setattr(ProfileView, "identity", ("default", Path("/tmp/profile"))) + monkeypatch.setattr( + ProfileView, + "raw_config", + lambda self: { + "api_key": "must-not-leak", + "gateway": {"telegram": {"token": "must-not-leak"}}, + "model": "example/model", + "provider": "example", + "terminal": {"backend": "docker", "cwd": "/work", "secret": "no"}, + }, + ) + + payload = view.safe_config() + + assert payload["config"] == { + "dashboard": {"mode": "lightweight"}, + "model": "example/model", + "provider": "example", + "terminal": {"backend": "docker", "cwd": "/work"}, + } + assert "must-not-leak" not in json.dumps(payload) + + +def test_remote_bind_requires_explicit_override(): + from hermes_cli.lightweight_dashboard import run_lightweight_dashboard + + with pytest.raises(SystemExit, match="non-loopback"): + run_lightweight_dashboard( + host="100.64.0.10", + port=0, + open_browser=False, + initial_profile="", + allow_remote=False, + ) + + +def test_http_server_reads_real_profile_state(monkeypatch, tmp_path): + from hermes_cli import lightweight_dashboard as light + from hermes_state import SessionDB + + home = tmp_path / "profile" + workspace = tmp_path / "workspace" + logs = home / "logs" + home.mkdir() + workspace.mkdir() + logs.mkdir() + (workspace / "notes.txt").write_text("workspace preview", encoding="utf-8") + (logs / "agent.log").write_text("INFO lightweight ready\n", encoding="utf-8") + (home / "config.yaml").write_text( + "model: example/model\n" + "api_key: must-not-leak\n" + "terminal:\n" + f" cwd: {workspace}\n", + encoding="utf-8", + ) + database = SessionDB(db_path=home / "state.db") + database.create_session("session-http", "cli", model="example/model") + database.append_message("session-http", "user", "hello from sqlite") + database.close() + + monkeypatch.setattr("hermes_cli.profiles.profile_exists", lambda name: True) + monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda name: home) + + server = ThreadingHTTPServer(("127.0.0.1", 0), light.LightweightHandler) + server.accepted_hosts = {"127.0.0.1"} + server.accept_ip_hosts = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + + def get(path): + with urllib.request.urlopen(base + path, timeout=5) as response: + return response.status, json.loads(response.read()) + + try: + sessions = get("/api/sessions")[1] + detail = get("/api/sessions/session-http")[1] + transcript = get("/api/sessions/session-http/messages")[1] + files = get("/api/files")[1] + preview = get("/api/files/read?path=notes.txt")[1] + log_tail = get("/api/logs?file=agent")[1] + config = get("/api/config")[1] + request = urllib.request.Request(base, headers={"Host": "evil.example"}) + with pytest.raises(urllib.error.HTTPError) as bad_host: + urllib.request.urlopen(request, timeout=5) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + assert sessions["sessions"][0]["id"] == "session-http" + assert detail["profile"] == "default" + assert transcript["messages"][0]["content"] == "hello from sqlite" + assert files["entries"][0]["name"] == "notes.txt" + assert preview["content"] == "workspace preview" + assert log_tail["lines"] == ["INFO lightweight ready"] + assert config["config"]["model"] == "example/model" + assert "must-not-leak" not in json.dumps(config) + assert bad_host.value.code == 400 + + +def test_handler_forwards_profile_query(monkeypatch): + from hermes_cli import lightweight_dashboard as light + + received = {} + handler = object.__new__(light.LightweightHandler) + handler.path = "/api/config?profile=Worker" + handler.headers = {"Host": "127.0.0.1:9119"} + handler.server = SimpleNamespace( + accepted_hosts={"127.0.0.1"}, accept_ip_hosts=False + ) + handler.json_reply = lambda status, payload: received.update( + status=status, payload=payload + ) + monkeypatch.setattr( + light.ProfileView, + "safe_config", + lambda self: {"requested": self.requested_name}, + ) + + handler.do_GET() + + assert received["payload"] == {"requested": "Worker"} diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py index 911b0db95834..33232f2dd0fb 100644 --- a/tests/hermes_cli/test_serve_command.py +++ b/tests/hermes_cli/test_serve_command.py @@ -13,6 +13,8 @@ import argparse +import pytest + from hermes_cli.subcommands.dashboard import build_dashboard_parser @@ -58,6 +60,11 @@ def test_serve_takes_the_same_runtime_flags_as_dashboard(): assert getattr(serve, field) == getattr(dash, field) +def test_serve_does_not_accept_dashboard_light_mode(): + with pytest.raises(SystemExit): + _parser().parse_args(["serve", "--light"]) + + def test_serve_supports_the_lifecycle_flags(): for flag in ("--stop", "--status"): assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True @@ -68,3 +75,8 @@ def test_serve_is_a_headless_backend_but_dashboard_is_not(): # build; only `serve` carries it. assert getattr(_parser().parse_args(["serve"]), "headless_backend", False) is True assert getattr(_parser().parse_args(["dashboard"]), "headless_backend", False) is False + + +def test_dashboard_accepts_lightweight_aliases(): + assert _parser().parse_args(["dashboard", "--light"]).light_dashboard is True + assert _parser().parse_args(["dashboard", "--legacy"]).light_dashboard is True diff --git a/tests/hermes_cli/test_subcommands_batch.py b/tests/hermes_cli/test_subcommands_batch.py index d4ec37b6f3c7..9227d05f4922 100644 --- a/tests/hermes_cli/test_subcommands_batch.py +++ b/tests/hermes_cli/test_subcommands_batch.py @@ -114,6 +114,8 @@ def test_dashboard_builder_two_handlers(): assert parser.parse_args(["dashboard"]).func is dash # dashboard register -> register handler assert parser.parse_args(["dashboard", "register"]).func is reg + assert parser.parse_args(["dashboard", "--light"]).light_dashboard is True + assert parser.parse_args(["dashboard", "--legacy"]).light_dashboard is True # ── deprecated `hermes login` fails gracefully, not with argparse error ──── diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 3f370f14ecf1..ad5966715798 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3782,6 +3782,8 @@ def test_overrides_applied(self): assert entry["type"] == "select" assert "options" in entry assert "local" in entry["options"] + assert CONFIG_SCHEMA["dashboard.mode"]["type"] == "select" + assert CONFIG_SCHEMA["dashboard.mode"]["options"] == ["full", "lightweight"] def test_memory_provider_field_present_as_select(self): """memory.provider must stay in the config schema. @@ -3890,6 +3892,10 @@ def test_get_config_no_internal_keys(self): internal = [k for k in config if k.startswith("_")] assert not internal, f"Internal keys leaked to frontend: {internal}" + def test_get_config_includes_dashboard_mode_default(self): + config = self.client.get("/api/config").json() + assert config.get("dashboard", {}).get("mode") == "full" + def test_get_config_model_is_string(self): """GET /api/config should normalize model dict to a string.""" config = self.client.get("/api/config").json() diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index bcd43951f372..0a5fb7fba422 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -27,7 +27,8 @@ This starts a local web server and opens `http://127.0.0.1:9119` in your browser | `--port` | `9119` | Port to run the web server on | | `--host` | `127.0.0.1` | Bind address | | `--no-open` | — | Don't auto-open the browser | -| `--insecure` | off | Allow binding to non-localhost hosts (**DANGEROUS** — exposes API keys on the network; pair with a firewall and strong auth) | +| `--light` | off | Start the memory-bounded read-only dashboard | +| `--insecure` | off | In light mode, permit unauthenticated access on a non-loopback address; it does not disable full-dashboard authentication | | `--isolated` | off | When launched from a named profile (`worker dashboard`), run a dedicated per-profile server instead of routing to the machine dashboard | ```bash @@ -39,8 +40,47 @@ hermes dashboard --host 0.0.0.0 # Start without opening browser hermes dashboard --no-open + +# Memory-bounded mode for small self-hosted servers +hermes dashboard --light --port 9119 + +# Trusted private IP only; light mode has no authentication +hermes dashboard --light --host 100.64.0.10 --insecure +``` + +### Lightweight mode + +Lightweight mode branches to a standard-library HTTP server before importing +FastAPI, Pydantic, the full route table, dashboard plugins, or MCP discovery. +It retains bounded read-only views for gateway status, sessions and paginated +transcripts, workspace files, profile logs, and an allow-listed configuration +summary. File previews are capped at 512 KiB, directory results and transcript +pages are bounded, and credential files are excluded. + +The full dashboard remains the default. Chat, credentials, file/config writes, +session mutation, plugins, channels, MCP, cron, and other administration stay in +the full dashboard. The `hermes serve` backend used by Desktop is unchanged. + +You can enable it for one launch: + +```bash +hermes dashboard --light +``` + +Or persist it in `~/.hermes/config.yaml`: + +```yaml +dashboard: + mode: lightweight ``` +Set `dashboard.mode: full` or omit the field for the complete admin dashboard. + +Lightweight mode binds to loopback by default because it has no authentication. +Use a tunnel for remote access. `--insecure` permits a non-loopback bind for a +trusted private network and prints a warning; every client that can reach that +address can read the exposed sessions, files, logs, and safe config fields. + ## Managing multiple profiles The dashboard is a **machine-level** management surface: one server manages