fix(doctor): report a dashboard web surface that dies at import

The optional `web` extra pins fastapi/starlette in lockstep, but the standalone
starlette security pin ships in several other extras on its own — so a managed
venv can end up with starlette 1.x beside an older fastapi. `hermes dashboard`
then dies constructing `FastAPI(...)` with `TypeError: Router.__init__() got an
unexpected keyword argument 'on_startup'`: not an ImportError, so the module's
own lazy-install fallback never fires, and the process exits before printing a
single line. `hermes doctor` had no probe covering this surface, so a dead
dashboard stayed invisible until the user needed it as a recovery path.

Add a doctor check that imports `hermes_cli.web_server` in a subprocess with
lazy installs disabled (read-only probe; it must never repair what it is
diagnosing) and reports the failing line plus the repair hint, or a soft warn
when the optional web extra is simply absent.

Fixes #124214
This commit is contained in:
kokhlo
2026-09-26 21:41:07 +03:00
committed by Teknium
parent e992d7ca10
commit cf2fa69eba
3 changed files with 149 additions and 1 deletions

View File

@@ -40,6 +40,7 @@ from hermes_cli.doctor_platform import (
_check_python_environment,
_check_required_packages,
_check_security_advisories,
_check_web_dashboard_import,
)
from hermes_cli.doctor_tools import (
_check_git_and_rg,
@@ -110,7 +111,8 @@ def _check_api_connectivity(should_fix: bool, f: Finding) -> None:
DOCTOR_CHECKS = (
('Security Advisories', _check_security_advisories), ('MCP Server Security', _check_mcp_security),
('Python Environment', _check_python_environment), ('SSL / CA Certificates', _check_certificates),
('Required Packages', _check_required_packages), ('Configuration Files', _check_env_file),
('Required Packages', _check_required_packages), (None, _check_web_dashboard_import),
('Configuration Files', _check_env_file),
(None, _check_config_file), (None, _check_config_drift),
('xAI Model Retirement (May 15, 2026)', _check_xai_retirement),
('Session Reset (timers removed Sep 7, 2026)', _check_retired_session_reset),

View File

@@ -525,6 +525,54 @@ def _check_windows_gateway_autostart(should_fix: bool, f: Finding) -> None:
f.manual_issues.extend(warnings)
@doctor_check()
def _check_web_dashboard_import(should_fix: bool, f: Finding) -> None:
"""Import the dashboard web surface in a subprocess so an import-time crash lands in the report.
When starlette is updated past the fastapi pinned beside it (the CVE starlette pin ships in
several extras on its own), ``hermes dashboard`` dies constructing ``FastAPI(...)`` with a
TypeError — not an ImportError — so the module's own lazy-install fallback never fires and the
process exits before a single log line. Importing in a subprocess keeps a dead web surface
from taking the doctor down with it; lazy installs stay off so the probe never mutates the
environment it is diagnosing.
"""
from hermes_cli.doctor import PROJECT_ROOT
env = dict(os.environ, HERMES_DISABLE_LAZY_INSTALLS="1")
try:
proc = subprocess.run(
[sys.executable, "-c", "import hermes_cli.web_server"],
capture_output=True,
text=True,
timeout=120,
cwd=str(PROJECT_ROOT),
env=env,
)
except subprocess.TimeoutExpired:
_fail_and_issue(
"Dashboard web surface",
"(import probe timed out)",
"Repair the dashboard dependencies: `hermes pm repair`, then restart Hermes",
f.issues,
)
return
stderr = (proc.stderr or "").strip()
if proc.returncode == 0:
return check_ok("Dashboard web surface", "(imports cleanly)")
if "Web UI requires fastapi and uvicorn" in stderr:
# The optional web extra is simply absent; anyone who never opens the dashboard
# should not be told their install is broken.
return check_warn("Dashboard web surface", "(optional web extra not installed)")
detail = (
stderr.splitlines()[-1] if stderr else f"( exited with code {proc.returncode} )"
)
_fail_and_issue(
"Dashboard web surface",
detail,
"Repair the dashboard dependencies: `hermes pm repair`, then restart Hermes",
f.issues,
)
@doctor_check()
def _check_gateway_supervision(should_fix: bool, f: Finding) -> None:
_check_gateway_service_linger(f.issues)

View File

@@ -0,0 +1,98 @@
"""``hermes doctor`` reports a dashboard web surface that dies at import (#124214).
The optional ``web`` extra pins fastapi/starlette in lockstep, but the standalone starlette
security pin ships in several other extras — so a venv can end up with starlette 1.x beside an
older fastapi. ``hermes dashboard`` then dies constructing ``FastAPI(...)`` with a TypeError
(not an ImportError), so the module's own lazy-install fallback never fires, and the process
exits before printing anything. The doctor probe imports the web surface in a subprocess so the
crash lands in the report instead of the user's terminal.
"""
from __future__ import annotations
import subprocess
import sys
import pytest
from hermes_cli import doctor_platform as dp
from hermes_cli.doctor_report import Finding
# doctor_check() replaces the wrapped fn; the undecorated original stays reachable via __wrapped__.
class _FakeCompleted:
def __init__(self, returncode: int, stderr: str = ""):
self.returncode = returncode
self.stderr = stderr
def _run_check(monkeypatch, completed, recorded):
def fake_run(*args, **kwargs):
recorded.append((args, kwargs))
return completed
monkeypatch.setattr(dp.subprocess, "run", fake_run)
finding = Finding()
dp._check_web_dashboard_import.__wrapped__(should_fix=False, f=finding)
return finding
def test_clean_import_reports_ok(capsys, monkeypatch):
recorded: list = []
finding = _run_check(monkeypatch, _FakeCompleted(0, ""), recorded)
out = capsys.readouterr().out
assert "Dashboard web surface" in out and "✓" in out
assert not finding.issues
def test_drifted_pair_is_a_reported_issue(capsys, monkeypatch):
stderr = (
'File ".../fastapi/routing.py", line 835, in __init__\n'
"TypeError: Router.__init__() got an unexpected keyword argument 'on_startup'\n"
)
recorded: list = []
finding = _run_check(monkeypatch, _FakeCompleted(1, stderr), recorded)
out = capsys.readouterr().out
assert "Dashboard web surface" in out and "✗" in out
assert "Router.__init__() got an unexpected keyword argument 'on_startup'" in out
assert any("hermes pm repair" in i for i in finding.issues)
def test_absent_web_extra_warns_without_failing(capsys, monkeypatch):
stderr = "Web UI requires fastapi and uvicorn.\nRun hermes pm repair, then restart Hermes."
recorded: list = []
finding = _run_check(monkeypatch, _FakeCompleted(1, stderr), recorded)
out = capsys.readouterr().out
assert "Dashboard web surface" in out and "⚠" in out
assert not finding.issues
def test_probe_runs_without_lazy_installs(capsys, monkeypatch):
recorded: list = []
_run_check(monkeypatch, _FakeCompleted(0, ""), recorded)
(args, kwargs) = recorded[0]
assert args[0][:2] == [sys.executable, "-c"]
assert args[0][2] == "import hermes_cli.web_server"
env = kwargs["env"]
assert env["HERMES_DISABLE_LAZY_INSTALLS"] == "1"
assert kwargs["timeout"] >= 60
def test_probe_timeout_is_an_issue(capsys, monkeypatch):
def fake_run(*args, **kwargs):
raise dp.subprocess.TimeoutExpired(
cmd="import probe", timeout=kwargs.get("timeout", 120)
)
monkeypatch.setattr(dp.subprocess, "run", fake_run)
finding = Finding()
dp._check_web_dashboard_import.__wrapped__(should_fix=False, f=finding)
out = capsys.readouterr().out
assert "Dashboard web surface" in out and "✗" in out
assert any("hermes pm repair" in i for i in finding.issues)