diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f21388c2181..47534b36388 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12080,6 +12080,19 @@ def cmd_dashboard(args): print(" Or drop --skip-build to build automatically.") sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + else: + # HERMES_WEB_DIST is set without --skip-build: the build is skipped + # (the env var points at a caller-managed dist), so validate it the + # same way the --skip-build branch does — otherwise the server starts + # and serves 404s with no obvious cause (same failure mode as #23817, + # via the env-var path). + _dist_root = Path(os.environ["HERMES_WEB_DIST"]).expanduser() + if not (_dist_root / "index.html").exists(): + print(f"✗ HERMES_WEB_DIST is set but no web dist found at: {_dist_root}") + print(" Pre-build first: npm install --workspace web && npm run build -w web") + print(" Or unset HERMES_WEB_DIST to build and use the default web UI dist.") + sys.exit(1) + print(f"→ Using web dist from HERMES_WEB_DIST: {_dist_root}") # Discover and load plugins so any DashboardAuthProvider plugin # (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's diff --git a/tests/hermes_cli/test_dashboard_web_dist_validation.py b/tests/hermes_cli/test_dashboard_web_dist_validation.py new file mode 100644 index 00000000000..92c63fe3e35 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_web_dist_validation.py @@ -0,0 +1,113 @@ +"""Regression tests: `hermes dashboard` validates HERMES_WEB_DIST before serving. + +A custom HERMES_WEB_DIST without --skip-build previously skipped BOTH the +build and any validation, so the server started and served 404s with no +obvious cause (same failure mode as issue #23817, reached via the env-var +path instead of --skip-build). The env-var branch must now fail fast when +the dist has no index.html, and proceed when it does. + +Design credit: PR #17845 (@Caelier). +""" + +import sys +import types + +import pytest + + +@pytest.fixture() +def main_mod(): + import hermes_cli.main as main + return main + + +def _args(**over): + base = { + "host": "127.0.0.1", + "port": 0, + "no_open": True, + "open_profile": None, + "skip_build": False, + "headless_backend": False, + "tui": False, + } + base.update(over) + return types.SimpleNamespace(**base) + + +def _wire_common(main_mod, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None) + monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace()) + monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace()) + monkeypatch.setitem( + sys.modules, + "hermes_logging", + types.SimpleNamespace(setup_logging=lambda **_k: None), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace(discover_plugins=lambda: None), + ) + monkeypatch.setattr( + "hermes_cli.mcp_startup.start_background_mcp_discovery", + lambda **_k: None, + ) + + +def test_env_dist_without_index_exits(main_mod, monkeypatch, tmp_path, capsys): + """HERMES_WEB_DIST pointing at a dist with no index.html must exit 1, + not start a server that 404s.""" + _wire_common(main_mod, monkeypatch) + empty_dist = tmp_path / "empty_dist" + empty_dist.mkdir() + monkeypatch.setenv("HERMES_WEB_DIST", str(empty_dist)) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + builds = [] + monkeypatch.setattr( + main_mod, "_build_web_ui", lambda *a, **k: builds.append(a) or True + ) + + with pytest.raises(SystemExit) as exc: + main_mod.cmd_dashboard(_args()) + + assert exc.value.code == 1 + assert started == [] + assert builds == [] # env var set -> build skipped, validation is the gate + out = capsys.readouterr().out + assert "HERMES_WEB_DIST" in out and str(empty_dist) in out + + +def test_env_dist_with_index_starts_server(main_mod, monkeypatch, tmp_path): + """A valid HERMES_WEB_DIST (has index.html) proceeds to start_server + without building.""" + _wire_common(main_mod, monkeypatch) + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_WEB_DIST", str(dist)) + + started = [] + monkeypatch.setitem( + sys.modules, + "hermes_cli.web_server", + types.SimpleNamespace(start_server=lambda **k: started.append(k)), + ) + builds = [] + monkeypatch.setattr( + main_mod, "_build_web_ui", lambda *a, **k: builds.append(a) or True + ) + + main_mod.cmd_dashboard(_args()) + + assert len(started) == 1 + assert builds == []