{"repo":"NousResearch/hermes-agent","public_id":"58993","display_label":"Dashboard WebSocket uses bind host instead of loopback","summary":"These issues and PRs address a bug where the dashboard's in-container WebSocket client connects to the wildcard bind address (e.g. 0.0.0.0) rather than the loopback address, causing failures behind forward proxies. The work involves substituting the bind host with a loopback address for WebSocket URLs.","subsystem":null,"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":60092,"merged_at":"2026-07-07T08:33:28+00:00"},"resolution":{"state":"resolved","by":"pr-60092"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T08:33:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":58993,"pr_number":60092,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T08:33:28+00:00"}],"members":[{"kind":"issue","number":58993,"title":"Dashboard in-container ws client dials bind host (0.0.0.0) instead of loopback, breaks behind a forward proxy","url":null,"state":"closed"},{"kind":"pr","number":59006,"title":"fix(dashboard): substitute wildcard bind host with loopback for WS URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b9d9b205bf44cded07906a67c6c8bbea64d4f73","number":0},{"kind":"pr","value":"#60092","number":60092},{"kind":"pr","value":"#58993","number":58993}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59682,"title":"fix(dashboard): use loopback host for in-container WebSocket client (#58993)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4b9d9b205","number":0},{"kind":"pr","value":"#58993","number":58993},{"kind":"pr","value":"#60092","number":60092}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60092,"title":"fix(dashboard): use loopback host for in-container WebSocket client (#58993) [salvage #59682]","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/dashboard/test_ws_client_host.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -12877,6 +12877,44 @@ def _resolve_chat_argv(\n     return list(argv), str(cwd) if cwd else None, env\n \n \n+# Hosts that mean \"listen on every interface\" — the server should bind to\n+# them, but an in-container client must NOT dial them: dialing 0.0.0.0\n+# resolves to \"any local interface\", which on most platforms routes through\n+# the kernel's wildcard stack and behind a forward proxy (HTTPS_PROXY with\n+# a NO_PROXY that doesn't list 0.0.0.0) gets MITM'd into a failed handshake\n+# (issue #58993).  The fix is to use a loopback address for the client\n+# netloc while leaving the bind host alone.\n+_WILDCARD_HOSTS = frozenset({\"0.0.0.0\", \"::\"})\n+\n+\n+def _resolve_client_ws_host() -> Optional[str]:\n+    \"\"\"Return the host the in-container WS client should dial.\n+\n+    Resolution order:\n+\n+    1. Explicit ``HERMES_DASHBOARD_WS_HOST`` env var — wins always. Operators\n+       running the dashboard behind a forward proxy can pin a routable host\n+       (e.g. ``127.0.0.1``, the container's internal IP, or a sidecar DNS\n+       name) and bypass auto-detection entirely.\n+    2. The configured bind host — if it's a wildcard (``0.0.0.0`` / ``::``),\n+       substitute ``127.0.0.1`` since both the dashboard and its TUI child\n+       run in the same container.\n+    3. Any other bind host (loopback or LAN IP) — preserved verbatim.\n+    \"\"\"\n+    explicit = os.environ.get(\"HERMES_DASHBOARD_WS_HOST\", \"\").strip()\n+    if explicit:\n+        return explicit\n+\n+    host = getattr(app.state, \"bound_host\", None)\n+    if not host:\n+        return None\n+\n+    if host in _WILDCARD_HOSTS:\n+        return \"127.0.0.1\"\n+\n+    return host\n+\n+\n def _build_gateway_ws_url() -> Optional[str]:\n     \"\"\"ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.\n ","@@ -12888,7 +12926,7 @@ def _build_gateway_ws_url() -> Optional[str]:\n     the child reads this URL once at startup and reuses it on every reconnect,\n     and a 30s-TTL ticket can expire before a slow cold boot even dials.\n     \"\"\"\n-    host = getattr(app.state, \"bound_host\", None)\n+    host = _resolve_client_ws_host()\n     port = getattr(app.state, \"bound_port\", None)\n \n     if not host or not port:","@@ -12955,7 +12993,7 @@ def _build_sidecar_url(channel: str) -> Optional[str]:\n     Connections authenticated this way are recorded under the\n     ``server-internal`` identity in the audit log.\n     \"\"\"\n-    host = getattr(app.state, \"bound_host\", None)\n+    host = _resolve_client_ws_host()\n     port = getattr(app.state, \"bound_port\", None)\n \n     if not host or not port:"]},{"file":"scripts/release.py","hunks":["@@ -1902,6 +1902,7 @@\n     \"[redacted:email]\": \"Eji4h\",  # direct email match\n     \"[redacted:email]\": \"designnotdrum\",  # PR #56480 salvage (toolset static-inference fix)\n     \"[redacted:email]\": \"LavyaTandel\",  # PR #57893 salvage local git identity (envelope-layout cache markers on tool/empty-assistant messages; #57845)\n+    \"[redacted:email]\": \"SquabbyZ\",  # PR #59682 salvage (in-container dashboard WS loopback host; #58993)\n }\n \n "]},{"file":"tests/dashboard/test_ws_client_host.py","hunks":["@@ -0,0 +1,319 @@\n+\"\"\"Regression tests for the in-container WebSocket client host resolution.\n+\n+Issue #58993: when the dashboard binds to a wildcard (``0.0.0.0`` / ``::``),\n+the in-container WS clients built by ``_build_gateway_ws_url`` and\n+``_build_sidecar_url`` used the bind host verbatim, so the child TUI\n+dialed ``ws://0.0.0.0:9119/api/ws``. Behind a forward proxy whose\n+``NO_PROXY`` does not list ``0.0.0.0`` that wildcard dial is routed through\n+the proxy and fails the handshake.\n+\n+The contract these tests pin down:\n+\n+  * Wildcard bind (``0.0.0.0`` / ``::``) → client dials ``127.0.0.1``.\n+  * Loopback bind (``127.0.0.1``) → client dials ``127.0.0.1`` (unchanged).\n+  * LAN / non-wildcard bind (``192.168.1.5``) → client dials that exact\n+    address (no rewrite to loopback — the bind was deliberate).\n+  * Explicit ``HERMES_DASHBOARD_WS_HOST`` env var → wins always, regardless\n+    of the bind host.\n+  * ``app.state.bound_host`` is left untouched — the bind address used by\n+    the listener doesn't change.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+\n+import pytest\n+\n+from hermes_cli import web_server\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fixtures\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+def saved_app_state():\n+    \"\"\"Snapshot and restore the bits of ``app.state`` the tests mutate.\n+\n+    The dashboard's WS URL builders read three things off ``app.state``:\n+    ``bound_host``, ``bound_port``, ``auth_required``. We capture them so\n+    the suite doesn't leak state into other tests, then yield control.\n+    \"\"\"\n+    saved = {\n+        \"bound_host\": getattr(web_server.app.state, \"bound_host\", None),\n+        \"bound_port\": getattr(web_server.app.state, \"bound_port\", None),\n+        \"auth_required\": getattr(web_server.app.state, \"auth_required\", None),\n+    }\n+    yield saved\n+    for key, value in saved.items():\n+        setattr(web_server.app.state, key, value)\n+\n+\n[redacted:email]\n+def clear_ws_host_env(monkeypatch):\n+    \"\"\"Ensure no ``HERMES_DASHBOARD_WS_HOST`` leaks in from the test shell.\"\"\"\n+    monkeypatch.delenv(\"HERMES_DASHBOARD_WS_HOST\", raising=False)\n+    yield monkeypatch\n+\n+\n+def _set_bound(saved_app_state, host: str, port: int = 9119):\n+    web_server.app.state.bound_host = host\n+    web_server.app.state.bound_port = port\n+    web_server.app.state.auth_required = False\n+\n+\n+def _netloc(ws_url: str) -> str:\n+    \"\"\"Pull the ``host:port`` (or ``[host]:port``) segment out of a ws URL.\"\"\"\n+    assert ws_url is not None, \"expected a URL, got None\"\n+    # ws://host:port/path?qs — strip the scheme, then take netloc up to \"/\".\n+    after_scheme = ws_url.split(\"://\", 1)[1]\n+    netloc = after_scheme.split(\"/\", 1)[0]\n+    return netloc\n+\n+\n+# ---------------------------------------------------------------------------\n+# _resolve_client_ws_host — direct unit tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResolveClientWsHost:\n+    def test_wildcard_ipv4_uses_loopback(self, saved_app_state, clear_ws_host_env):\n+        _set_bound(saved_app_state, \"0.0.0.0\")\n+        assert web_server._resolve_client_ws_host() == \"127.0.0.1\"\n+\n+    def test_wildcard_ipv6_uses_loopback(self, saved_app_state, clear_ws_host_env):\n+        _set_bound(saved_app_state, \"::\")\n+        assert web_server._resolve_client_ws_host() == \"127.0.0.1\"\n+\n+    def test_loopback_bind_unchanged(self, saved_app_state, clear_ws_host_env):\n+        _set_bound(saved_app_state, \"127.0.0.1\")\n+        assert web_server._resolve_client_ws_host() == \"127.0.0.1\"\n+\n+    def test_lan_bind_preserved(self, saved_app_state, clear_ws_host_env):\n+        \"\"\"A non-loopback, non-wildcard bind must NOT be rewritten — the\n+        operator chose that address deliberately (e.g. bridge networking in\n+        a sidecar topology) and rewriting it to 127.0.0.1 would break their\n+        setup.\"\"\"\n+        _set_bound(saved_app_state, \"192.168.1.5\")\n+        assert web_server._resolve_client_ws_host() == \"192.168.1.5\"\n+\n+    def test_public_dns_bind_preserved(self, saved_app_state, clear_ws_host_env):\n+        _set_bound(saved_app_state, \"fly-app.example.dev\")\n+        assert web_server._resolve_client_ws_host() == \"fly-app.example.dev\"\n+\n+    def test_explicit_env_wins_over_wildcard(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"0.0.0.0\")\n+        assert web_server._resolve_client_ws_host() == \"10.0.0.7\"\n+\n+    def test_explicit_env_wins_over_lan_bind(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        \"\"\"Even when the bind is a routable address, the explicit override\n+        still wins — operators may want to bypass the bind address\n+        altogether (e.g. to dial a different sidecar replica).\"\"\"\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"192.168.1.5\")\n+        assert web_server._resolve_client_ws_host() == \"10.0.0.7\"\n+\n+    def test_explicit_env_wins_over_loopback(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"127.0.0.1\")\n+        assert web_server._resolve_client_ws_host() == \"10.0.0.7\"\n+\n+    def test_blank_env_falls_back_to_bind(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        \"\"\"An explicitly empty override (e.g. ``HERMES_DASHBOARD_WS_HOST=``)\n+        must NOT silently pin to loopback — it's an unset-by-accident, not\n+        an intent. Treat whitespace-only as absent and fall through.\"\"\"\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"   \")\n+        _set_bound(saved_app_state, \"0.0.0.0\")\n+        assert web_server._resolve_client_ws_host() == \"127.0.0.1\"\n+\n+    def test_no_bound_host_returns_none(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        web_server.app.state.bound_host = None\n+        web_server.app.state.bound_port = None\n+        assert web_server._resolve_client_ws_host() is None\n+\n+    def test_bind_host_unchanged_after_wildcard_resolution(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        \"\"\"Resolution only affects the client netloc — ``bound_host`` on\n+        ``app.state`` (used by the listener and host-header middleware) is\n+        NOT mutated.\"\"\"\n+        _set_bound(saved_app_state, \"0.0.0.0\")\n+        web_server._resolve_client_ws_host()\n+        assert web_server.app.state.bound_host == \"0.0.0.0\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _build_gateway_ws_url — end-to-end URL contract\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestGatewayWsUrlHost:\n+    def test_wildcard_bind_dials_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"0.0.0.0\", port=9119)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        # ws://127.0.0.1:9119/api/ws?token=…\n+        assert url.startswith(\"ws://127.0.0.1:9119/api/ws\")\n+        assert \"0.0.0.0\" not in url\n+\n+    def test_ipv6_wildcard_bind_dials_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"::\", port=9119)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert url.startswith(\"ws://127.0.0.1:9119/api/ws\")\n+        # The ``::`` must not leak into the client URL.\n+        assert \"::\" not in url\n+\n+    def test_loopback_bind_uses_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"127.0.0.1\", port=8080)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert url.startswith(\"ws://127.0.0.1:8080/api/ws\")\n+\n+    def test_lan_bind_preserved(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"192.168.1.5\", port=9120)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert url.startswith(\"ws://192.168.1.5:9120/api/ws\")\n+\n+    def test_explicit_env_overrides_wildcard(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"0.0.0.0\", port=9119)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert url.startswith(\"ws://10.0.0.7:9119/api/ws\")\n+        assert \"0.0.0.0\" not in url\n+\n+    def test_explicit_env_overrides_lan(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"192.168.1.5\", port=9120)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert url.startswith(\"ws://10.0.0.7:9120/api/ws\")\n+\n+    def test_wildcard_keeps_query_string(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        \"\"\"Regression-guard: rewriting the host must not drop the\n+        ``?token=`` or ``?internal=`` credential.\"\"\"\n+        _set_bound(saved_app_state, \"0.0.0.0\", port=9119)\n+        url = web_server._build_gateway_ws_url()\n+        assert url is not None\n+        assert \"?\" in url\n+        # Loopback / ``--insecure`` path uses the session token.\n+        assert f\"token={web_server._SESSION_TOKEN}\" in url\n+\n+    def test_no_bound_host_returns_none(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        web_server.app.state.bound_host = None\n+        web_server.app.state.bound_port = None\n+        assert web_server._build_gateway_ws_url() is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _build_sidecar_url — end-to-end URL contract\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSidecarUrlHost:\n+    def test_wildcard_bind_dials_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"0.0.0.0\", port=9119)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://127.0.0.1:9119/api/pub\")\n+        assert \"0.0.0.0\" not in url\n+        assert \"channel=ch-1\" in url\n+\n+    def test_ipv6_wildcard_bind_dials_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"::\", port=9119)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://127.0.0.1:9119/api/pub\")\n+        assert \"::\" not in url\n+\n+    def test_loopback_bind_uses_loopback(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"127.0.0.1\", port=8080)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://127.0.0.1:8080/api/pub\")\n+\n+    def test_lan_bind_preserved(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        _set_bound(saved_app_state, \"192.168.1.5\", port=9120)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://192.168.1.5:9120/api/pub\")\n+\n+    def test_explicit_env_overrides_wildcard(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"0.0.0.0\", port=9119)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://10.0.0.7:9119/api/pub\")\n+        assert \"0.0.0.0\" not in url\n+\n+    def test_explicit_env_overrides_lan(\n+        self, saved_app_state, monkeypatch\n+    ):\n+        monkeypatch.setenv(\"HERMES_DASHBOARD_WS_HOST\", \"10.0.0.7\")\n+        _set_bound(saved_app_state, \"192.168.1.5\", port=9120)\n+        url = web_server._build_sidecar_url(\"ch-1\")\n+        assert url is not None\n+        assert url.startswith(\"ws://10.0.0.7:9120/api/pub\")\n+\n+    def test_no_bound_host_returns_none(\n+        self, saved_app_state, clear_ws_host_env\n+    ):\n+        web_server.app.state.bound_host = None\n+        web_server.app.state.bound_port = None\n+        assert web_server._build_sidecar_url(\"ch-1\") is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _netloc helper is exposed only because it's useful for tests; if the\n+# production code ever changes the URL shape the tests catch the regression\n+# above without needing to assert on the full string.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_netloc_helper_handles_ipv6_bracket_form():\n+    \"\"\"The IPv6 netloc path is exercised by the production ``[host]:port``\n+    branch when ``HERMES_DASHBOARD_WS_HOST`` points at an IPv6 address.\n+    Verify the helper doesn't choke on the bracket form.\"\"\"\n+    assert _netloc(\"ws://[::1]:9119/api/ws?x=1\") == \"[::1]:9119\"\n+    assert _netloc(\"ws://127.0.0.1:9119/api/ws?x=1\") == \"127.0.0.1:9119\"\n\\ No newline at end of file\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}