From 4e3a1d8779b5ac3fc96e8ac4af842ffc50b40512 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Sat, 25 Jul 2026 12:37:18 +1000 Subject: [PATCH] fix(dashboard): read SPA gating from the mounted app, not the module global Every route in mount_spa(application) registers on the "application" parameter, but _serve_index decided the auth scheme from the module-level "app". Production calls mount_spa(app), so the two are the same object and behaviour is unchanged - but the SPA's auth scheme was being read from an object it was not mounted on. That matters because this flag decides whether the long-lived _SESSION_TOKEN is injected into index.html. Gated mode withholds it and the browser uses a cookie session instead; ungated mode injects it. A second mount (embedded host, test harness, any future sub-app) inherits the global's gating, so a gated app whose global is ungated would emit the very token gated mode exists to withhold. Tests pin both directions with the app and the global deliberately disagreeing: a gated app stays gated when the global is not, and an ungated app stays ungated when the global is. 508 passed in test_web_server.py. Co-Authored-By: Claude Opus 5 --- hermes_cli/web_server.py | 8 ++++- tests/hermes_cli/test_web_server.py | 48 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 767882ba9d1aa..474750288c3ce 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -15815,7 +15815,13 @@ def _serve_index(prefix: str = ""): status_code=404, ) chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" - gated = bool(getattr(app.state, "auth_required", False)) + # Read gating from the app this SPA was mounted on, not the module + # global. Production calls ``mount_spa(app)`` so the two are the same + # object and behaviour is unchanged, but every route above registers + # on ``application`` — deciding the auth scheme from a different app + # than the one serving the request is how a gated mount could end up + # emitting the long-lived token that gated mode exists to withhold. + gated = bool(getattr(application.state, "auth_required", False)) gated_js = "true" if gated else "false" if gated: bootstrap_script = ( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 436d00cde09e5..3a66af4fc268c 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3517,4 +3517,52 @@ async def get(self, *args, **kwargs): assert self.ws.DASHBOARD_HEALTH.selftest_http_status == 500 assert self.ws.DASHBOARD_HEALTH.snapshot()["status"] == "degraded" +class TestMountSpaGatingIsAppScoped: + """`mount_spa(application)` must read gating from the app it is given. + + Every route in `mount_spa` registers on the `application` parameter, but + `_serve_index` read `auth_required` from the MODULE-LEVEL `app`. Production + passes the global (`mount_spa(app)`), so the two coincide and nothing + breaks today - but the SPA's auth scheme is then decided by an object it + was not mounted on. Any second mount (an embedded/test/sub-app) silently + inherits the global's gating, and picking the wrong branch here decides + whether the long-lived session token is injected into the HTML. + """ + + @staticmethod + def _mount(tmp_path, monkeypatch, *, app_gated: bool, global_gated: 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) + (dist / "index.html").write_text( + "tSPA", + encoding="utf-8", + ) + monkeypatch.setattr(ws, "WEB_DIST", dist) + monkeypatch.setattr(ws, "load_config", lambda: {"dashboard": {"theme": "default"}}) + monkeypatch.setattr(ws, "_SESSION_TOKEN", "scope-probe-token") + # The global says one thing, the mounted app says another. + monkeypatch.setattr(ws.app.state, "auth_required", global_gated, raising=False) + + spa_app = FastAPI() + spa_app.state.auth_required = app_gated + ws.mount_spa(spa_app) + return TestClient(spa_app).get("/chat") + def test_gated_app_is_gated_even_when_global_is_not(self, tmp_path, monkeypatch): + resp = self._mount(tmp_path, monkeypatch, app_gated=True, global_gated=False) + + assert resp.status_code == 200 + # Gated: cookie auth, so the long-lived token must NOT be in the HTML. + assert "scope-probe-token" not in resp.text + assert "window.__HERMES_AUTH_REQUIRED__=true" in resp.text + + def test_ungated_app_is_ungated_even_when_global_is_gated(self, tmp_path, monkeypatch): + resp = self._mount(tmp_path, monkeypatch, app_gated=False, global_gated=True) + + assert resp.status_code == 200 + assert "scope-probe-token" in resp.text + assert "window.__HERMES_AUTH_REQUIRED__=false" in resp.text