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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35386 fix(dashboard): allow insecure WS peers on explicit non-loopback binds

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_dashboard_auth_ws_auth.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
hermes cli
Merged PRs
#35386
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.