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 DashboardHermes Lightweight Dashboard
Loading...
+
+Gateway-
State-
Active sessions-
Active agents-
+
+
+
+
+"""
+
+
+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