From ccab46ca435a21c103fe5e7c1a8247f4bacaff54 Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Fri, 24 Jul 2026 19:43:44 -0500 Subject: [PATCH] fix(dashboard): add lightweight /api/health liveness endpoint /api/status is the only public liveness route, and its handler loads the gateway config, probes gateway health, and counts sessions before it can answer. That work is wrong for a readiness probe: a caller that only needs to know the process is up pays for a cold plugin import tree. Add /api/health, which returns process liveness, version, and the auth-gate shape and touches nothing else. --- hermes_cli/dashboard_auth/public_paths.py | 5 +++++ hermes_cli/web_server.py | 10 ++++++++++ .../hermes_cli/test_dashboard_auth_middleware.py | 1 + .../test_dashboard_auth_status_endpoint.py | 16 ++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/hermes_cli/dashboard_auth/public_paths.py b/hermes_cli/dashboard_auth/public_paths.py index 349937cffa06..befedb1070a2 100644 --- a/hermes_cli/dashboard_auth/public_paths.py +++ b/hermes_cli/dashboard_auth/public_paths.py @@ -31,6 +31,11 @@ the SPA should bootstrap it after login instead. from __future__ import annotations PUBLIC_API_PATHS: frozenset[str] = frozenset({ + # Minimal process liveness probe for desktop/backend boot handshakes. It + # intentionally avoids gateway config, platform discovery, MCP setup, and + # host-local detail so readiness checks cannot spend their budget inside + # cold plugin imports. + "/api/health", # Liveness probe target. Returns version, gateway state, active # session count, and the dashboard auth-gate shape. No bodies, no # session content, no secrets. Documented as the portal's wildcard diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1e5cf1655178..7b529fefef4b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3036,6 +3036,16 @@ async def get_ssh_ownership(request: Request): return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1} +@app.get("/api/health") +async def get_health(): + """Lightweight process liveness for desktop/backend readiness probes.""" + return { + "ok": True, + "version": __version__, + "auth_required": bool(getattr(app.state, "auth_required", False)), + } + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None diff --git a/tests/hermes_cli/test_dashboard_auth_middleware.py b/tests/hermes_cli/test_dashboard_auth_middleware.py index 7c1d6a9c2b21..7e8cf95d9822 100644 --- a/tests/hermes_cli/test_dashboard_auth_middleware.py +++ b/tests/hermes_cli/test_dashboard_auth_middleware.py @@ -78,6 +78,7 @@ def test_gated_status_is_public(gated_app): @pytest.mark.parametrize("path", [ + "/api/health", "/api/config/defaults", "/api/config/schema", "/api/model/info", diff --git a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py index 4305a2e355f8..0d00b20ed23c 100644 --- a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py +++ b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py @@ -67,6 +67,22 @@ def test_status_reports_auth_required_in_gated_mode(gated_client): assert body["auth_providers"] == ["stub"] +def test_health_reports_liveness_without_loading_gateway_config(gated_client, monkeypatch): + def _boom(): + raise AssertionError("health must not load gateway config") + + monkeypatch.setattr("gateway.config.load_gateway_config", _boom) + + r = gated_client.get("/api/health") + assert r.status_code == 200 + body = r.json() + assert body == { + "ok": True, + "version": web_server.__version__, + "auth_required": True, + } + + def test_status_reports_auth_disabled_in_loopback_mode(loopback_client): r = loopback_client.get("/api/status") assert r.status_code == 200