diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 07e51f36d9..71abfac22f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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), diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 4533df0c44..557717d59b 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -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) diff --git a/tests/hermes_cli/test_doctor_web_dashboard_import.py b/tests/hermes_cli/test_doctor_web_dashboard_import.py new file mode 100644 index 0000000000..de1a15c5ce --- /dev/null +++ b/tests/hermes_cli/test_doctor_web_dashboard_import.py @@ -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)