{"repo":"NousResearch/hermes-agent","public_id":"34091","display_label":"Dashboard and Docker image fixes and UI changes","summary":"These items address bugs and improvements in the dashboard and Docker image, including WebSocket peer checks, asset builds, and a chat UI replacement.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35386,"merged_at":"2026-05-30T14:33:02+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-30T14:33:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":26264,"pr_number":35386,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-30T14:33:02+00:00"},{"issue_number":34091,"pr_number":35386,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-30T14:33:02+00:00"}],"members":[{"kind":"issue","number":34091,"title":"[Bug]: v0.15.0 Docker image:The TUI cannot be used in the dashboard.","url":null,"state":"closed"},{"kind":"pr","number":34242,"title":"fix(dashboard): WS peer-IP check rejects Docker bridge gateway in --insecure mode (#34091)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"234ac0093","number":0},{"kind":"pr","value":"#35386","number":35386},{"kind":"pr","value":"#34091","number":34091}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35386,"title":"fix(dashboard): allow insecure WS peers on explicit non-loopback binds","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/hermes_cli/test_dashboard_auth_ws_auth.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -3375,10 +3375,14 @@ def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     ``?token=[redacted:secret-assignment] path is the only auth we have, so we\n     don't want LAN hosts guessing tokens.\n \n-    All-interfaces insecure bind (``--host 0.0.0.0 --insecure`` or\n-    ``--host :: --insecure``): allow any peer. The operator explicitly\n-    opted into LAN/public exposure in this mode, so the loopback-only peer\n-    restriction should not apply.\n+    Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a\n+    specific address such as a Tailscale/LAN IP, always with\n+    ``--insecure``): allow any peer. The operator explicitly opted into\n+    non-loopback exposure, so the loopback-only peer restriction does not\n+    apply. DNS-rebinding is still blocked by the Host/Origin guard in\n+    :func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and\n+    requires the Host header to match the bound interface — the same\n+    defence ``_is_accepted_host`` applies to non-loopback HTTP requests.\n \n     Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True``\n     (enabled when the OAuth gate is active so cookies can pick up","@@ -3390,8 +3394,13 @@ def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     \"\"\"\n     if getattr(app.state, \"auth_required\", False):\n         return True\n-    bound_host = getattr(app.state, \"bound_host\", \"\")\n-    if bound_host in {\"0.0.0.0\", \"::\"}:\n+    # Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN /\n+    # Tailscale address) means the operator opted into non-loopback\n+    # access via --insecure.  The loopback-only peer gate only applies to\n+    # an actual loopback bind; otherwise the WS handshake is rejected even\n+    # though same-bind HTTP requests pass _is_accepted_host.\n+    bound_host = (getattr(app.state, \"bound_host\", \"\") or \"\").strip().lower()\n+    if bound_host and bound_host not in _LOOPBACK_HOSTS:\n         return True\n     client_host = ws.client.host if ws.client else \"\"\n     if not client_host:"]},{"file":"tests/hermes_cli/test_dashboard_auth_ws_auth.py","hunks":["@@ -162,6 +162,30 @@ def test_get_method_is_not_allowed(self, gated_app):\n # ---------------------------------------------------------------------------\n \n \n[redacted:email]\n+def insecure_explicit_host_app():\n+    \"\"\"web_server.app bound to an explicit non-loopback host (--insecure).\n+\n+    Models `--host 100.64.0.10 --insecure` (e.g. a Tailscale IP behind\n+    `tailscale serve`) — a specific address rather than the all-interfaces\n+    0.0.0.0 wildcard.\n+    \"\"\"\n+    _reset_for_tests()\n+    clear_providers()\n+    prev_host = getattr(web_server.app.state, \"bound_host\", None)\n+    prev_port = getattr(web_server.app.state, \"bound_port\", None)\n+    prev_required = getattr(web_server.app.state, \"auth_required\", None)\n+    web_server.app.state.bound_host = \"100.64.0.10\"\n+    web_server.app.state.bound_port = 9119\n+    web_server.app.state.auth_required = False\n+    client = TestClient(web_server.app, base_url=\"http://100.64.0.10:9119\")\n+    yield client\n+    _reset_for_tests()\n+    web_server.app.state.bound_host = prev_host\n+    web_server.app.state.bound_port = prev_port\n+    web_server.app.state.auth_required = prev_required\n+\n+\n def _fake_ws(*, query: dict, client_host: str = \"127.0.0.1\", path: str = \"/api/pty\"):\n     \"\"\"Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok.\"\"\"\n ","@@ -315,6 +339,33 @@ def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public\n         }\n         assert web_server._ws_request_is_allowed(ws) is True\n \n+    def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app):\n+        \"\"\"`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit\n+        non-loopback opt-in too — not just the 0.0.0.0 wildcard.\n+\n+        Regression coverage: the merged 0.0.0.0/:: fix did not cover binding\n+        directly to a specific tailnet/LAN address, so `/chat` HTML loaded but\n+        WS upgrades were still rejected by the loopback-only peer guard.\n+        \"\"\"\n+        ws = _fake_ws(query={}, client_host=\"100.64.0.99\")\n+        ws.headers = {\n+            \"host\": \"100.64.0.10:9119\",\n+            \"origin\": \"http://100.64.0.10:9119\",\n+        }\n+        assert web_server._ws_request_is_allowed(ws) is True\n+\n+    def test_rebinding_host_rejected_on_explicit_non_loopback_bind(\n+        self, insecure_explicit_host_app\n+    ):\n+        \"\"\"Lifting the peer-IP gate for an explicit bind must NOT lift the\n+        DNS-rebinding Host guard: a mismatched Host header is still rejected,\n+        because an explicit non-loopback bind requires an exact Host match in\n+        `_is_accepted_host` (unlike the 0.0.0.0 wildcard, which accepts any).\n+        \"\"\"\n+        ws = _fake_ws(query={}, client_host=\"100.64.0.99\")\n+        ws.headers = {\"host\": \"evil.example.com\"}\n+        assert web_server._ws_request_is_allowed(ws) is False\n+\n     def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):\n         \"\"\"Bypassing the peer-IP check must not bypass the DNS-rebinding\n         Host header guard — that one still protects against attacker\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}