Two parallel public-path allowlists drifted: _PUBLIC_API_PATHS in
hermes_cli/web_server.py (legacy _SESSION_TOKEN middleware) and
_GATE_PUBLIC_PREFIXES in hermes_cli/dashboard_auth/middleware.py
(OAuth gate). The legacy list included /api/status (documented as a
non-sensitive read-only liveness target); the OAuth gate's list did not.
Effect: every wildcard-subdomain agent surfaced as STARTING/down to the
portal even though the dashboard was serving correctly. Nous account
service (src/server/agents/fly-provider.ts
getInstanceRuntimeStatus) fetches ``/api/status`` without a cookie
as its sole liveness probe; the OAuth gate's 401 looked identical to
'agent dead' on the portal side.
Fix: lift the allowlist into hermes_cli/dashboard_auth/public_paths.py
and have both middlewares import it. _path_is_public now consults
the shared frozenset first, then falls back to the gate's
auth-bootstrap/static prefix list. Future additions to the public list
hit both gates automatically.
Endpoint inventory (verified safe to remain public):
* /api/status — version, gateway state, active session count,
auth-gate shape. Portal liveness probe target.
* /api/config/defaults — config-defaults feed for the SPA's Config page
* /api/config/schema — config schema for the SPA's Config page
* /api/model/info — model catalogue metadata (context windows)
* /api/dashboard/themes — theme manifests for the skin engine
* /api/dashboard/plugins — plugin manifests for the dashboard
No user data, no session content, no secrets. Same shape an external
monitoring agent would hit on /healthz.
Tests:
* New: test_gated_status_is_public (regression guard with the NAS
fly-provider.ts liveness-probe rationale spelled out in the docstring)
* New: test_other_public_api_paths_are_public_under_gate (parametrised
over the rest of PUBLIC_API_PATHS — proves 401 / 302-to-login is
never the response)
* New: docker integration check #3 in
test_dashboard_oauth_gate_engaged_by_default — /api/status
remains 200 under the gate AND reports auth_required=True so the
portal can distinguish modes
* Updated: test_full_login_round_trip_unlocks_gated_api now probes
/api/sessions instead of /api/status (status is public, so it
can no longer distinguish 'logged in' from 'gate accidentally
disabled')
* Updated: TestApi401Envelope (the no-cookie / invalid-cookie /
dead-cookie tests) probes /api/sessions for the same reason
* Updated: docker integration check #2 in
test_dashboard_oauth_gate_engaged_by_default probes
/api/sessions to prove the gate is intercepting
* Removed: dead _login() helper in
test_dashboard_auth_status_endpoint.py (no longer needed since
/api/status is reachable cold)
Companion to docs/handover/hermes-agent-dashboard-s6-insecure-fix.md
(the --insecure flag fix that shipped earlier).
Phase 7 surfaces the OAuth gate state to users.
web/src/components/AuthWidget.tsx (new):
Sidebar widget that fetches /api/auth/me on mount and renders a
compact 'Logged in as <user_id…> via <provider>' row with a logout
icon. Contract V1 (Nous Portal) emits no email/display_name claims,
so user_id is the display value (truncated to 14 chars + ellipsis);
display_name and email fallthroughs are forward-compat for OQ-C1.
Renders nothing on 401 from /api/auth/me — that's the signal the
gate isn't engaged (loopback mode), in which case the widget would
be confusing.
Logout POSTs /auth/logout (which clears cookies + redirects to
/login) then full-page-navigates to /login itself; the SPA's fetch
wrapper doesn't follow that redirect, so the navigation is explicit.
web/src/App.tsx: mounts <AuthWidget /> above <SidebarFooter />.
Component is self-hiding in loopback mode so there's no need for a
conditional mount.
web/src/lib/api.ts:
- getAuthMe() + logout() helpers
- AuthMeResponse type
- StatusResponse gets optional auth_required + auth_providers fields
so the existing StatusPage can render a gated/loopback badge.
hermes_cli/web_server.py: /api/status payload now includes
- auth_required: bool — whether app.state.auth_required is True
- auth_providers: list[str] — registered DashboardAuthProvider names
Lazy-imports list_providers so early-startup status calls don't
crash if the dashboard_auth module is still being set up.
tests/hermes_cli/test_dashboard_auth_status_endpoint.py: 3 new tests
covering the new status fields in both gated and loopback modes plus
a regression that no existing field got dropped from the payload.
The hermes status CLI is unchanged in this commit — that command
tracks model providers + OAuth credentials, not running-dashboard
state. The /api/status endpoint is the canonical place to query
dashboard auth-gate state, consumed by the React StatusPage already.