{"repo":"NousResearch/hermes-agent","public_id":"34227","display_label":"Dashboard WebSocket fixes for reverse-proxy and insecure modes","summary":"These issues and pull requests address regressions in dashboard WebSocket connectivity and action-status polling that affected reverse-proxy deployments and insecure public bind configurations. The work involves opt-in auth handling and restoring access broken around v0.14.0.","subsystem":"dashboard","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35141,"merged_at":"2026-05-30T07:23:45+00:00"},"resolution":{"state":"resolved","by":"pr-35141"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T07:23:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":34227,"pr_number":35141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T07:23:45+00:00"},{"issue_number":34396,"pr_number":35141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T07:23:45+00:00"}],"members":[{"kind":"issue","number":34227,"title":"v0.14.0 dashboard breaks behind reverse proxies — two regressions","url":null,"state":"closed"},{"kind":"pr","number":34307,"title":"fix(dashboard): restore reverse-proxy WS access and action-status polling in --insecure mode","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":34370,"title":"fix(dashboard): WS auth opt-in for reverse-proxy deployments (#34227)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34307","number":34307},{"kind":"pr","value":"#34227","number":34227},{"kind":"pr","value":"#34370","number":34370}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35141,"title":"fix(dashboard): allow chat websockets on insecure public bind (salvage #33278)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","scripts/release.py","tests/hermes_cli/test_dashboard_auth_ws_auth.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -3371,10 +3371,15 @@ class PtyUnavailableError(RuntimeError):  # type: ignore[no-redef]\n def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     \"\"\"Check if the WebSocket client IP is acceptable.\n \n-    Loopback mode: only loopback clients allowed — the legacy\n+    Loopback bind: only loopback clients allowed — the legacy\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+\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\n     ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the","@@ -3385,6 +3390,9 @@ 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+        return True\n     client_host = ws.client.host if ws.client else \"\"\n     if not client_host:\n         return True"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"SeaXen\",\n     \"[redacted:email]\": \"HashClawAI\",\n     \"[redacted:email]\": \"TonyPepeBear\",\n     \"[redacted:email]\": \"nielskaspers\","]},{"file":"tests/hermes_cli/test_dashboard_auth_ws_auth.py","hunks":["@@ -80,6 +80,25 @@ def loopback_app():\n     web_server.app.state.auth_required = prev_required\n \n \n[redacted:email]\n+def insecure_public_app():\n+    \"\"\"web_server.app configured for all-interfaces insecure mode.\"\"\"\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 = \"0.0.0.0\"\n+    web_server.app.state.bound_port = 9120\n+    web_server.app.state.auth_required = False\n+    client = TestClient(web_server.app, base_url=\"http://192.168.0.222:9120\")\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 _logged_in(client: TestClient) -> None:\n     \"\"\"Drive the stub OAuth round trip so the client holds session cookies.\"\"\"\n     r1 = client.get(\"/auth/login?provider=stub\", follow_redirects=False)","@@ -281,6 +300,21 @@ def test_loopback_peer_allowed_in_loopback_mode(self, loopback_app):\n         ws.headers = {\"host\": \"127.0.0.1:8080\"}\n         assert web_server._ws_request_is_allowed(ws) is True\n \n+    def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):\n+        \"\"\"`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.\n+\n+        Regression coverage for the dashboard `/chat` breakage where the\n+        HTML shell loaded on 9120 but every WebSocket upgrade was rejected\n+        with 403 because the loopback-only peer guard still ran even though\n+        the operator intentionally exposed the dashboard on all interfaces.\n+        \"\"\"\n+        ws = _fake_ws(query={}, client_host=\"192.168.0.55\")\n+        ws.headers = {\n+            \"host\": \"192.168.0.222:9120\",\n+            \"origin\": \"http://192.168.0.222:9120\",\n+        }\n+        assert web_server._ws_request_is_allowed(ws) is True\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}}