Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion hermes_cli/web_server_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,13 @@ def _serve_index(prefix: str = ""):
# Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist.
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))
# 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))
token_js = "" if gated else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";'
# Launcher-preselected profile (``--open-profile``): the SPA's fallback scope when the URL
# omits ``?profile=`` (#73085). ``</`` escaped so a hostile name cannot close the script tag.
Expand Down
49 changes: 49 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5744,6 +5744,55 @@ 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(
"<html><head><title>t</title></head><body>SPA</body></html>",
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

class TestSessionPatchUnread:
"""PATCH /api/sessions/{id} with {"unread": bool} marks the session
Expand Down