NousResearch/hermes-agent
Dashboard and Docker image fixes and UI changes
Cluster 34091 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #35386 on 30 May 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35386. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address bugs and improvements in the dashboard and Docker image, including WebSocket peer checks, asset builds, and a chat UI replacement.
Open issues0
Open PRs0
Fixes on record
-
#35386 fix(dashboard): allow insecure WS peers on explicit non-loopback binds
files:
hermes_cli/web_server.py,tests/hermes_cli/test_dashboard_auth_ws_auth.pyShow the diff
-
hermes_cli/web_server.py@@ -3375,10 +3375,14 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: ``?token=[redacted:secret-assignment] path is the only auth we have, so we don't want LAN hosts guessing tokens. - All-interfaces insecure bind (``--host 0.0.0.0 --insecure`` or - ``--host :: --insecure``): allow any peer. The operator explicitly - opted into LAN/public exposure in this mode, so the loopback-only peer - restriction should not apply. + Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a + specific address such as a Tailscale/LAN IP, always with + ``--insecure``): allow any peer. The operator explicitly opted into + non-loopback exposure, so the loopback-only peer restriction does not + apply. DNS-rebinding is still blocked by the Host/Origin guard in + :func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and + requires the Host header to match the bound interface — the same + defence ``_is_accepted_host`` applies to non-loopback HTTP requests. Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True`` (enabled when the OAuth gate is active so cookies can pick up @@ -3390,8 +3394,13 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool: """ if getattr(app.state, "auth_required", False): return True - bound_host = getattr(app.state, "bound_host", "") - if bound_host in {"0.0.0.0", "::"}: + # Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN / + # Tailscale address) means the operator opted into non-loopback + # access via --insecure. The loopback-only peer gate only applies to + # an actual loopback bind; otherwise the WS handshake is rejected even + # though same-bind HTTP requests pass _is_accepted_host. + bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() + if bound_host and bound_host not in _LOOPBACK_HOSTS: return True client_host = ws.client.host if ws.client else "" if not client_host: -
tests/hermes_cli/test_dashboard_auth_ws_auth.py@@ -162,6 +162,30 @@ def test_get_method_is_not_allowed(self, gated_app): # --------------------------------------------------------------------------- [redacted:email] +def insecure_explicit_host_app(): + """web_server.app bound to an explicit non-loopback host (--insecure). + + Models `--host 100.64.0.10 --insecure` (e.g. a Tailscale IP behind + `tailscale serve`) — a specific address rather than the all-interfaces + 0.0.0.0 wildcard. + """ + _reset_for_tests() + clear_providers() + prev_host = getattr(web_server.app.state, "bound_host", None) + prev_port = getattr(web_server.app.state, "bound_port", None) + prev_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.bound_host = "100.64.0.10" + web_server.app.state.bound_port = 9119 + web_server.app.state.auth_required = False + client = TestClient(web_server.app, base_url="http://100.64.0.10:9119") + yield client + _reset_for_tests() + web_server.app.state.bound_host = prev_host + web_server.app.state.bound_port = prev_port + web_server.app.state.auth_required = prev_required + + def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/pty"): """Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok.""" @@ -315,6 +339,33 @@ def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public } assert web_server._ws_request_is_allowed(ws) is True + def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app): + """`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit + non-loopback opt-in too — not just the 0.0.0.0 wildcard. + + Regression coverage: the merged 0.0.0.0/:: fix did not cover binding + directly to a specific tailnet/LAN address, so `/chat` HTML loaded but + WS upgrades were still rejected by the loopback-only peer guard. + """ + ws = _fake_ws(query={}, client_host="100.64.0.99") + ws.headers = { + "host": "100.64.0.10:9119", + "origin": "http://100.64.0.10:9119", + } + assert web_server._ws_request_is_allowed(ws) is True + + def test_rebinding_host_rejected_on_explicit_non_loopback_bind( + self, insecure_explicit_host_app + ): + """Lifting the peer-IP gate for an explicit bind must NOT lift the + DNS-rebinding Host guard: a mismatched Host header is still rejected, + because an explicit non-loopback bind requires an exact Host match in + `_is_accepted_host` (unlike the 0.0.0.0 wildcard, which accepts any). + """ + ws = _fake_ws(query={}, client_host="100.64.0.99") + ws.headers = {"host": "evil.example.com"} + assert web_server._ws_request_is_allowed(ws) is False + def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app): """Bypassing the peer-IP check must not bypass the DNS-rebinding Host header guard — that one still protects against attacker
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#34242 fix(dashboard): WS peer-IP check rejects Docker bridge gateway in --insecure mode (#34091) · superseded by another PR →
234ac00,#35386,#34091
Closed issues · 1
- Closed#34091[Bug]: v0.15.0 Docker image:The TUI cannot be used in the dashboard.
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #35386
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 30 May 2026