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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
98
tests/hermes_cli/test_doctor_web_dashboard_import.py
Normal file
98
tests/hermes_cli/test_doctor_web_dashboard_import.py
Normal 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)
|
||||
Reference in New Issue
Block a user