diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a05678b4e3bc5..1f57a544d531a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -17927,7 +17927,18 @@ def mount_spa(application: FastAPI): ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right auth scheme for /api/pty and /api/ws (ticket vs token). """ - html = _index_path.read_text(encoding="utf-8") + try: + html = _index_path.read_text(encoding="utf-8") + except OSError: + # The dist dir existed at mount time but index.html is missing or + # unreadable now (partial build, wiped dist, permissions). Without + # this guard every request raises FileNotFoundError (500). Return + # the same JSON 404 payload mount_spa uses for a fully-missing + # dist so clients get a clear, consistent signal. + return JSONResponse( + {"error": "Frontend not built. Run: cd web && npm run build"}, + status_code=404, + ) chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" gated = bool(getattr(app.state, "auth_required", False)) gated_js = "true" if gated else "false" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 404a06ae8b841..488f7fd298951 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -9179,3 +9179,56 @@ class TestDesktopCronTicker: with self._client(): assert not called.wait(0.5), "ticker must not run outside the desktop app" + + +class TestServeIndexMissingIndex: + """_serve_index must not raise per-request when index.html vanishes + (partial build, wiped dist) after mount_spa saw an existing dist dir. + It should return the same JSON 404 payload mount_spa emits for a + fully-missing dist.""" + + @staticmethod + def _client_with_dist(tmp_path, monkeypatch, *, write_index: bool): + from fastapi import FastAPI + from starlette.testclient import TestClient + import hermes_cli.web_server as ws + + dist = tmp_path / "web_dist" + (dist / "assets").mkdir(parents=True) + if write_index: + (dist / "index.html").write_text( + "SPA", encoding="utf-8" + ) + monkeypatch.setattr(ws, "WEB_DIST", dist) + monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False) + spa_app = FastAPI() + ws.mount_spa(spa_app) + return TestClient(spa_app), dist + + def test_missing_index_inside_existing_dist_returns_json_404( + self, tmp_path, monkeypatch + ): + client, _dist = self._client_with_dist( + tmp_path, monkeypatch, write_index=False + ) + for route in ("/", "/chat"): + resp = client.get(route) + assert resp.status_code == 404 + assert resp.json()["error"] == ( + "Frontend not built. Run: cd web && npm run build" + ) + + def test_index_deleted_after_mount_returns_json_404(self, tmp_path, monkeypatch): + client, dist = self._client_with_dist(tmp_path, monkeypatch, write_index=True) + assert client.get("/chat").status_code == 200 # healthy first + (dist / "index.html").unlink() + resp = client.get("/chat") + assert resp.status_code == 404 + assert "Frontend not built" in resp.json()["error"] + # And recovers once the index reappears (e.g. a rebuild finished). + (dist / "index.html").write_text( + "SPA-rebuilt", encoding="utf-8" + ) + resp = client.get("/chat") + assert resp.status_code == 200 + assert "SPA-rebuilt" in resp.text