From d928017742d5cf242998e1667c994a23df85d80e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:34:01 +0530 Subject: [PATCH] fix(dashboard): validate HERMES_WEB_DIST before startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom HERMES_WEB_DIST without --skip-build skipped BOTH the web UI build and any validation: cmd_dashboard fell through the build gate and started the server against a dist that may not exist, serving 404s with no obvious cause. This is the same failure mode issue #23817 fixed for the --skip-build branch — the env-var branch was left unvalidated. Add the missing else-branch: fail fast with actionable guidance when HERMES_WEB_DIST has no index.html, proceed (still without building) when it does. Credit: @Caelier (#17845) originally proposed dist validation for the dashboard startup path; the --skip-build half of that PR's scope has since landed via the #23817 fix, this covers the remaining env-var path on the rewritten cmd_dashboard surface. --- hermes_cli/main.py | 13 ++ .../test_dashboard_web_dist_validation.py | 113 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/hermes_cli/test_dashboard_web_dist_validation.py 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 == []