feat(dashboard): stash auth_required flag on app.state

Phase 0, Task 0.3. start_server now computes should_require_auth(host,
allow_public) and records it on app.state.auth_required BEFORE the
existing legacy SystemExit guard fires. This gives middleware, the SPA
token-injection path, and WS endpoints a consistent read source for
'is the gate active'. The flag is set but no one reads it yet — Phase 3
registers the gate middleware.

Note: 4 pre-existing test failures in tests/hermes_cli/test_web_server.py
(PtyWebSocket) + test_update_hangup_protection.py reproduce on pristine
HEAD and are unrelated to this change (starlette TestClient WS regression).
This commit is contained in:
Ben 2026-05-21 15:05:23 +10:00
parent 2862085920
commit 2346711df9
2 changed files with 67 additions and 0 deletions

View file

@ -4541,6 +4541,12 @@ def start_server(
global _DASHBOARD_EMBEDDED_CHAT_ENABLED
_DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat
# Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token
# injection / WS-auth paths can branch on it consistently. At Phase 0 the
# flag is set but nothing reads it yet — later phases register the gate
# middleware and the gated /auth/* routes.
app.state.auth_required = should_require_auth(host, allow_public)
_LOCALHOST = ("127.0.0.1", "localhost", "::1")
if host not in _LOCALHOST and not allow_public:
raise SystemExit(

View file

@ -91,3 +91,64 @@ def test_loopback_host_header_validation_still_enforced(client_loopback):
def test_should_require_auth_truth_table(host, allow_public, expected):
from hermes_cli.web_server import should_require_auth
assert should_require_auth(host, allow_public) is expected
# ---------------------------------------------------------------------------
# start_server stashes auth_required on app.state (Task 0.3)
# ---------------------------------------------------------------------------
def _stub_uvicorn_run(monkeypatch):
"""Replace uvicorn.run with a no-op recorder so start_server returns
immediately (rather than blocking on the event loop). Returns the dict
that will capture the keyword args."""
import uvicorn
captured: dict = {}
def _fake_run(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
monkeypatch.setattr(uvicorn, "run", _fake_run)
return captured
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
"""Loopback bind: app.state.auth_required is False after start_server."""
_stub_uvicorn_run(monkeypatch)
# Force a fresh state to detect that start_server actually set it.
web_server.app.state.auth_required = None
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is False
def test_start_server_insecure_public_sets_auth_required_false(monkeypatch):
"""``--insecure`` (allow_public=True) on a public host: gate stays OFF."""
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=True,
)
assert web_server.app.state.auth_required is False
def test_start_server_public_without_insecure_records_auth_required(monkeypatch):
"""Public bind without --insecure: the gate is meant to engage.
Until Phase 3 lands, start_server still raises SystemExit on this path
(the legacy "refusing to bind" guard). We must still observe the
auth_required flag being set on app.state BEFORE the exit happens, so
the rest of the system can branch on it consistently.
"""
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
with pytest.raises(SystemExit):
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True