hermes - 💡(How to fix) Fix v0.14.0 dashboard breaks behind reverse proxies — two regressions

Official PRs (…)
ON THIS PAGE

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Root Cause

The dashboard consequently sits stuck on the update-progress modal post-update, polling forever, never able to render the "update complete" state or any other tab. Even closing the modal isn't possible because the SPA is locked into the polling state.

Fix Action

Fix / Workaround

  • (a) Route the action-status poller through the same authed-fetch wrapper that handles /api/sessions and /api/auth/me — bug appears to be a missing interceptor on this specific endpoint, since POST /api/hermes/update (the kickoff that's already on the same flow) does carry the header correctly.
  • (b) Add /api/actions/{name}/status to _PUBLIC_API_PATHS — the response is read-only and only exposes the per-action log file (~/.hermes/logs/<action>.log) which the operator already has shell access to. This is what we did as a local workaround.

Happy to PR either or both fixes if there's a preference between the options above. We're running both as local patches to web_server.py and reapplying after each hermes update for now.

Code Example

hermes dashboard --host 0.0.0.0 --port 9119 --insecure --tui
# Then reach https://your-proxy.example/ from a browser through any
# reverse proxy (Caddy, Traefik, nginx, Pangolin,) whose tunnel
# terminates on a non-loopback IP from the dashboard's POV.

---

GET 401 /api/actions/hermes-update/status?lines=200   (× hundreds, every ~2s)

---

def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:
    if getattr(app.state, \"auth_required\", False):
        return True
    client_host = ws.client.host if ws.client else \"\"
    if not client_host:
        return True
    return client_host in _LOOPBACK_HOSTS

---

GET 403 /api/ws?token=ikFO3d…
GET 403 /api/events?token=ikFO3d…&channel=GET 403 /api/pty?token=ikFO3d…&channel=
RAW_BUFFERClick to expand / collapse

Hi — flagging two regressions that landed in v0.14.0 (HEAD 2d5dcfabc at the time of writing) that together render the dashboard unusable when fronted by a reverse proxy in --insecure mode. In our case the chain is browser → Pangolin SSO → Traefik → newt tunnel pod → hermes dashboard on 192.168.1.207:9119. Direct LAN/loopback access (http://127.0.0.1:9119 and http://192.168.1.207:9119) works fine; both issues are specific to the proxied path.

Both issues live in hermes_cli/web_server.py.

Repro

hermes dashboard --host 0.0.0.0 --port 9119 --insecure --tui
# Then reach https://your-proxy.example/ from a browser through any
# reverse proxy (Caddy, Traefik, nginx, Pangolin, …) whose tunnel
# terminates on a non-loopback IP from the dashboard's POV.

Observed:

  • Dashboard SPA loads, sits on a permanent "events feed disconnected" banner.
  • If the user clicked Update from the dashboard, a post-update modal stays stuck polling forever.
  • Chat tab renders "session ended" before any prompt can be typed.

Regression 1 — /api/actions/{name}/status returns 401 through reverse proxies

The SPA polls this endpoint every ~2 seconds to drive the post-update progress modal. In v0.14.0 it does not attach X-Hermes-Session-Token on these polls when reached through a reverse proxy — verified by:

  • curl -H 'X-Hermes-Session-Token: <token>' http://127.0.0.1:9119/api/actions/hermes-update/status200
  • Same browser session through the proxy → 401 on every poll

Reverse-proxy access log (Traefik):

GET 401 /api/actions/hermes-update/status?lines=200   (× hundreds, every ~2s)

The dashboard consequently sits stuck on the update-progress modal post-update, polling forever, never able to render the "update complete" state or any other tab. Even closing the modal isn't possible because the SPA is locked into the polling state.

Suggested fix (pick one):

  • (a) Route the action-status poller through the same authed-fetch wrapper that handles /api/sessions and /api/auth/me — bug appears to be a missing interceptor on this specific endpoint, since POST /api/hermes/update (the kickoff that's already on the same flow) does carry the header correctly.
  • (b) Add /api/actions/{name}/status to _PUBLIC_API_PATHS — the response is read-only and only exposes the per-action log file (~/.hermes/logs/<action>.log) which the operator already has shell access to. This is what we did as a local workaround.

Regression 2 — _ws_client_is_allowed rejects all non-loopback peers in --insecure mode, even with a valid ?token=

In --insecure mode _ws_client_is_allowed() locks the WS endpoints (/api/ws, /api/events, /api/pty) to client IPs in _LOOPBACK_HOSTS:

def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:
    if getattr(app.state, \"auth_required\", False):
        return True
    client_host = ws.client.host if ws.client else \"\"
    if not client_host:
        return True
    return client_host in _LOOPBACK_HOSTS

The docstring describes this as defense-in-depth ("we don't want LAN hosts guessing tokens"). In practice it also rejects every reverse-proxy deployment — the operator chose --insecure precisely because they're terminating auth at the proxy layer (SSO, mTLS, basic auth, etc.) and the WS arrives at the dashboard from the proxy's IP, not 127.0.0.1.

The constant-time ?token=<_SESSION_TOKEN> check in _ws_auth_ok is the same protection used on every authed HTTP /api/* endpoint, which is not similarly IP-restricted in --insecure mode. So the WS rule is strictly stricter than the HTTP rule, and only the WS rule breaks reverse-proxy deployments. A peer that can reach the WS path can already reach /api/sessions, /api/config, etc. on the same host with the same token check — there's no marginal protection in IP-restricting just the WS.

Symptom in the reverse-proxy access log:

GET 403 /api/ws?token=ikFO3d…
GET 403 /api/events?token=ikFO3d…&channel=…
GET 403 /api/pty?token=ikFO3d…&channel=…

The HTTP 403 is from ws.close(code=4403) running before ws.accept(). The SPA then renders the chat tab as "session ended" because every /api/pty upgrade dies.

Suggested fix (pick one):

  • (a) Drop the IP check in --insecure mode and rely on the same ?token= check used elsewhere on the same dashboard.
  • (b) Gate the IP check on a new flag (e.g. --ws-loopback-only) so operators can opt out when fronting with a proxy. Keeps the defense-in-depth posture for direct binds.
  • (c) Honor a configurable trusted-proxy CIDR list (mirrors what Starlette's ProxyHeadersMiddleware does for client.host rewrites when proxy_headers=True).

Happy to PR either or both fixes if there's a preference between the options above. We're running both as local patches to web_server.py and reapplying after each hermes update for now.

Environment: Hermes Agent v0.14.0 (release 2026.5.16, HEAD 2d5dcfabc), Python 3.11.11, Linux (Kali), behind Pangolin SSO → Traefik → newt tunnel.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING