# NousResearch/hermes-agent · Dashboard and Docker image fixes and UI changes

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #35386. Verify it addresses your case.
- **Recommended:** read PR #35386 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #34091: \[Bug\]: v0.15.0 Docker image:The TUI cannot be used in the dashboard.

## Prior & competing PRs

- **#35386: fix(dashboard): allow insecure WS peers on explicit non-loopback binds**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/web_server.py`, `tests/hermes_cli/test_dashboard_auth_ws_auth.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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."""
     
    ```

    ```diff
    @@ -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
    ```
- **#34242: fix(dashboard): WS peer-IP check rejects Docker bridge gateway in --insecure mode (#34091)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `234ac0093`, `#35386`, `#34091`

## Fix status

- Merged to main in PR #35386 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-30`.
- Subsystem: `hermes_cli`
