{"repo":"NousResearch/hermes-agent","public_id":"37399","display_label":"Desktop remote gateway connection stability and auth","summary":"These items concern recurring instability when the Hermes Desktop app connects to remote gateways, including WebSocket flapping, crash/boot loops, stale backend exits, and authentication state not being recognized or persisted correctly. Several fixes and features address WebSocket readiness checks, origin/host trust rules, and username/password login flows for remote gateways.","subsystem":"apps/desktop","counts":{"members":43,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":23,"merged_prs":6,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":37747},"resolution":{"state":"partially_resolved","by":"pr-37747,pr-65885,pr-71714,pr-123008"},"freshness":{"state":"verified","last_verified_at":"2026-09-27T00:47:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.038582502768549264},"fix_edges":[{"issue_number":37399,"pr_number":37747,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":37399,"pr_number":38743,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38266,"pr_number":65885,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43640,"pr_number":83567,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48434,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61457,"pr_number":123008,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":71305,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":71369,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":71491,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":71514,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":71515,"pr_number":71714,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":81290,"pr_number":83567,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":37399,"title":"Hermes Desktop remote mode rejects Electron WebSocket origins on non-loopback dashboard binds","url":null,"state":"closed"},{"kind":"issue","number":37869,"title":"[Bug]: Desktop app shows remote connection failure while logs say remote backend is ready","url":null,"state":"closed"},{"kind":"issue","number":38061,"title":"[Bug]: Can't connect to Remote Gateway via Tailscale for Hermes Desktop","url":null,"state":"closed"},{"kind":"issue","number":38115,"title":"Remote gateway (macOS): desktop session won't hold — gateway SIGTERM → WebSocket close 1012 loop; \"Test remote\" false-passes; auto-updater corrupts install","url":null,"state":"closed"},{"kind":"issue","number":38266,"title":"[Bug]: Hermes Desktop 0.15.1 remote gateway enters reconnect loop after successful connection","url":null,"state":"closed"},{"kind":"issue","number":38332,"title":"Hermes Desktop remote gateway reaches backend but UI still shows startup error / falls back to local backend unless env-forced","url":null,"state":"closed"},{"kind":"issue","number":38412,"title":"[Bug]: Desktop \"Remote gateway\" can't connect over WebSocket — /api/ws always rejected (4403) for packaged Electron client","url":null,"state":"closed"},{"kind":"issue","number":38873,"title":"Hermes Desktop remote gateway mode flaps back to local backend after successful remote readiness","url":null,"state":"closed"},{"kind":"issue","number":38930,"title":"Desktop \"Remote gateway\" reconnects in loop against external `hermes dashboard`","url":null,"state":"closed"},{"kind":"issue","number":40083,"title":"Desktop: Electron renderer crash loop on remote gateway connection (v0.15.1)","url":null,"state":"closed"},{"kind":"issue","number":40391,"title":"[Bug]: Hermes Desktop Remote Gateway — connects to REST but fails WebSocket / flaps back to local","url":null,"state":"closed"},{"kind":"issue","number":41566,"title":"[Bug]: Desktop still shows 'Could not connect to Hermes gateway' after successful remote HTTPS/WSS verification","url":null,"state":"closed"},{"kind":"issue","number":43640,"title":"Desktop App: Renderer crashes with exitCode=15 causing boot loop","url":null,"state":"closed"},{"kind":"issue","number":43904,"title":"[Desktop v0.16.0] Remote backend \"ready\" but UI stuck on red error dialog, loops in reset/repair cycle","url":null,"state":"closed"},{"kind":"issue","number":46689,"title":"macOS Desktop auto-terminates after ~4 minutes; renderer killed with exitCode=15","url":null,"state":"open"},{"kind":"issue","number":48434,"title":"Windows Desktop remote gateway fails after successful password-auth sign-in / Save and reconnect","url":null,"state":"closed"},{"kind":"issue","number":51873,"title":"Hermes Desktop never shows sign-in UI when connecting to remote backend with basic auth","url":null,"state":"closed"},{"kind":"issue","number":52255,"title":"Desktop remote mode fails against authenticated remote gateway: backend ready, but WebSocket/API auth rejected","url":null,"state":"closed"},{"kind":"issue","number":53352,"title":"Desktop: renderer crash  boot loop on Windows + build overwrites source","url":null,"state":"open"},{"kind":"issue","number":56888,"title":"setup.runtime_check missing from _LONG_HANDLERS — blocks WS read loop under GIL pressure, causing false \"gateway disconnect / runtime not ready\" (#50005 residual)","url":null,"state":"closed"},{"kind":"issue","number":58875,"title":"Desktop: React stack overflow when connecting to remote gateway backen","url":null,"state":"closed"},{"kind":"issue","number":61457,"title":"Desktop: remote gateway session cookie never persists after basic-auth login — immediate 401 no_cookie loop","url":null,"state":"closed"},{"kind":"issue","number":65115,"title":"Desktop: after a username/password gateway sign-in, the app keeps reporting \"not signed in\" until restart","url":null,"state":"closed"},{"kind":"issue","number":66521,"title":"[Bug]: Desktop Remote gateway shows legacy session-token field, not username/password Sign In — can't connect to basic-auth backend","url":null,"state":"closed"},{"kind":"issue","number":68517,"title":"Desktop treats a basic-auth remote gateway as OAuth and never re-probes /api/auth/providers; sign-in window blank, Settings unreachable (follow-up to #51873 on a build containing f3af066b)","url":null,"state":"closed"},{"kind":"pr","number":17887,"title":"fix(cli): honor --insecure for dashboard WebSocket connections","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":37405,"title":"fix(dashboard): allow desktop websocket origins on remote binds","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38743","number":38743},{"kind":"pr","value":"#37399","number":37399},{"kind":"pr","value":"#37399","number":37399}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37747,"title":"fix(dashboard): allow desktop websocket origins on remote binds","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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":["@@ -6603,13 +6603,16 @@ def _ws_host_origin_is_allowed(ws: \"WebSocket\") -> bool:\n \n     parsed = urllib.parse.urlparse(origin)\n     if parsed.scheme not in {\"http\", \"https\"}:\n-        # Packaged Electron loads the desktop renderer over file://, so its\n-        # WebSocket handshake carries a non-web Origin such as file:// or null.\n-        # DNS-rebinding attacks originate from an http(s) site; they cannot\n-        # forge a file:// origin and still hold the loopback session token.\n-        # Public/gated binds have no legitimate non-web client, so keep\n-        # rejecting these origins there.\n-        return bound_host.lower() in _LOOPBACK_HOST_VALUES\n+        # Packaged Electron loads the desktop renderer over a non-web origin\n+        # such as file:// or null. This helper is called only after _ws_auth_ok\n+        # has accepted the WS credential; in non-gated mode that credential is\n+        # the legacy dashboard session token, including for explicit Tailscale /\n+        # LAN binds opened with --insecure. Real DNS-rebinding attacks arrive\n+        # from http(s) origins and still have to match the bound host below.\n+        #\n+        # OAuth-gated public dashboards authenticate with cookies/tickets and\n+        # have no legitimate file:// client, so keep them strict.\n+        return not getattr(app.state, \"auth_required\", False)\n \n     if not parsed.netloc:\n         return False"]},{"file":"scripts/release.py","hunks":["@@ -1425,6 +1425,7 @@\n     \"[redacted:email]\": \"x1am1\",  # PR #35098 (chown root-owned top-level HERMES_HOME state files)\n     \"[redacted:email]\": \"polnikale\",  # PR #35717 (discover Playwright headless_shell browser)\n     \"[redacted:email]\": \"whyhkzk\",  # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)\n+    \"[redacted:email]\": \"leonardsellem\",  # PR #37405 (desktop WS origin guard on remote/Tailscale binds)\n }\n \n "]},{"file":"tests/hermes_cli/test_dashboard_auth_ws_auth.py","hunks":["@@ -381,8 +381,9 @@ class TestWsHostOriginGuardOrigins:\n     Electron loads the packaged renderer over ``file://``, so its WebSocket\n     handshake carries ``Origin: file://`` (or the opaque ``null``). The\n     DNS-rebinding guard only needs to block cross-site http(s) origins. On a\n-    loopback bind these non-web origins are trusted because the session token\n-    is the real gate. Public/gated binds keep rejecting them.\n+    loopback or explicit non-loopback insecure bind these non-web origins are\n+    trusted because the session token is the real gate. OAuth-gated public\n+    binds keep rejecting them.\n     \"\"\"\n \n     def _ws(self, *, origin, host):","@@ -413,8 +414,29 @@ def test_loopback_cross_site_http_origin_rejected(self, loopback_app):\n         ws = self._ws(origin=\"http://evil.test\", host=\"127.0.0.1:8080\")\n         assert web_server._ws_host_origin_is_allowed(ws) is False\n \n+    def test_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app):\n+        \"\"\"Packaged Hermes Desktop also uses file:// when connecting to a\n+        Tailscale/LAN dashboard bind.\n+\n+        The WebSocket route calls _ws_auth_ok before this guard, so in\n+        non-gated mode the legacy session token remains the auth boundary.\n+        \"\"\"\n+        ws = self._ws(origin=\"file://\", host=\"100.64.0.10:9119\")\n+        assert web_server._ws_host_origin_is_allowed(ws) is True\n+\n+    def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app):\n+        ws = self._ws(origin=\"null\", host=\"100.64.0.10:9119\")\n+        assert web_server._ws_host_origin_is_allowed(ws) is True\n+\n+    def test_explicit_non_loopback_cross_site_http_origin_rejected(\n+        self, insecure_explicit_host_app\n+    ):\n+        ws = self._ws(origin=\"http://localhost:9119\", host=\"100.64.0.10:9119\")\n+        assert web_server._ws_host_origin_is_allowed(ws) is False\n+\n     def test_gated_file_origin_rejected(self, gated_app):\n-        # A public/gated bind has no legitimate file:// client.\n+        # OAuth-gated public dashboards authenticate with cookies/tickets,\n+        # not the legacy desktop session token.\n         ws = self._ws(origin=\"file://\", host=\"fly-app.fly.dev\")\n         assert web_server._ws_host_origin_is_allowed(ws) is False\n \n"]}]},{"kind":"pr","number":38292,"title":"fix(desktop): ignore stale backend exits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6feb40e702829639c22aae2ae29d63e9ac5e8508","number":0},{"kind":"commit","value":"39d09453f95e8aefc0c97e5d9b30ff341cae9ed8","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38743,"title":"fix(dashboard): explain WHY a chat WS connection was refused","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/web_server.py","tests/hermes_cli/test_config.py","tui_gateway/ws.py","web/src/pages/ChatPage.tsx"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -17,11 +17,13 @@\n import os\n import platform\n import re\n+import shutil\n import stat\n import subprocess\n import sys\n import tempfile\n import threading\n+import time\n from dataclasses import dataclass\n from pathlib import Path\n from typing import Dict, Any, Optional, List, Tuple","@@ -36,6 +38,60 @@\n _CONFIG_PARSE_WARNED: set = set()\n \n \n+def _backup_corrupt_config(config_path: Path) -> Optional[Path]:\n+    \"\"\"Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.\n+\n+    When the YAML can't be parsed, ``load_config()`` silently falls back to\n+    ``DEFAULT_CONFIG`` and the user's broken file stays on disk untouched.\n+    That file is still the user's only copy of their intended overrides — if\n+    they re-run the setup wizard or ``hermes config set`` (which rewrites\n+    ``config.yaml``), the broken-but-recoverable content is gone for good.\n+\n+    This snapshots the corrupted file to ``config.yaml.corrupt.<ts>.bak`` so\n+    the user can diff/repair it. Unlike Gemini CLI's policy-file recovery\n+    (which resets the live file to a clean state), we deliberately leave\n+    ``config.yaml`` in place: hermes never silently mutates the user's config,\n+    and leaving it means a hand-fixed file is re-read on the next load. The\n+    backup is best-effort — any failure (permissions, symlink, disk full) is\n+    swallowed so config loading is never blocked by backup problems.\n+\n+    Returns the backup path on success, else ``None``. Symlinks are not\n+    followed/copied (mirrors the Gemini #21541 lstat guard) to avoid\n+    clobbering whatever a malicious/misconfigured symlink points at.\n+    \"\"\"\n+    try:\n+        if config_path.is_symlink():\n+            return None\n+        st = config_path.stat()\n+        if st.st_size == 0:\n+            # Empty file isn't worth preserving and yaml.safe_load returns {}\n+            # for it anyway (so it wouldn't reach here), but guard regardless.\n+            return None\n+        ts = time.strftime(\"%Y%m%d-%H%M%S\")\n+        backup_path = config_path.with_name(f\"{config_path.name}.corrupt.{ts}.bak\")\n+        # Don't clobber an existing backup from the same second; if there's\n+        # already a corrupt backup for this exact mtime, assume we've snapshotted\n+        # this corruption already and skip (the dedup cache normally prevents a\n+        # second call, but a process restart can clear it).\n+        sibling_baks = list(\n+            config_path.parent.glob(f\"{config_path.name}.corrupt.*.bak\")\n+        )\n+        for existing in sibling_baks:\n+            try:\n+                if existing.stat().st_size == st.st_size:\n+                    # Same size as the current broken file — likely the same\n+                    # corruption already preserved. Avoid backup churn.\n+                    return None\n+            except OSError:\n+                continue\n+        if backup_path.exists():\n+            return None\n+        shutil.copy2(config_path, backup_path)\n+        return backup_path\n+    except Exception:\n+        return None\n+\n+\n def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n     \"\"\"Surface a config.yaml parse failure to user, log, and stderr.\n ","@@ -48,7 +104,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n     Now: warn once per (path, mtime_ns, size) on stderr **and** in\n     ``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``\n     surfaces it. Re-warns automatically if the file changes (different\n-    mtime/size), so users editing the config see the next failure.\n+    mtime/size), so users editing the config see the next failure. On the\n+    first warning for a given broken file we also snapshot it to a\n+    timestamped ``.bak`` (best-effort) so the user's recoverable content\n+    survives any later rewrite of ``config.yaml`` by the setup wizard or\n+    ``hermes config set``.\n     \"\"\"\n     try:\n         st = config_path.stat()","@@ -59,12 +119,16 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n         return\n     _CONFIG_PARSE_WARNED.add(key)\n \n+    backup_path = _backup_corrupt_config(config_path)\n+\n     msg = (\n         f\"Failed to parse {config_path}: {exc}. \"\n         f\"Falling back to default config — every user override \"\n         f\"(auxiliary providers, fallback chain, model settings) is being IGNORED. \"\n         f\"Fix the YAML and restart.\"\n     )\n+    if backup_path is not None:\n+        msg += f\" A copy of the corrupted file was saved to {backup_path}.\"\n     logger.warning(msg)\n     try:\n         sys.stderr.write(f\"⚠️  hermes config: {msg}\\n\")"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -6919,6 +6919,28 @@ class PtyUnavailableError(RuntimeError):  # type: ignore[no-redef]\n _LOOPBACK_HOSTS = frozenset({\"127.0.0.1\", \"::1\", \"localhost\", \"testclient\"})\n \n \n+def _ws_client_reason(ws: \"WebSocket\") -> Optional[str]:\n+    \"\"\"Return a rejection reason for the client IP, or None when allowed.\n+\n+    Reasons are short machine-parseable tokens logged on the rejection path\n+    so a \"WS keeps closing\" report can be diagnosed from agent.log without a\n+    repro. ``None`` means the peer IP passed this gate.\n+\n+    See :func:`_ws_client_is_allowed` for the full policy rationale.\n+    \"\"\"\n+    if getattr(app.state, \"auth_required\", False):\n+        return None\n+    bound_host = (getattr(app.state, \"bound_host\", \"\") or \"\").strip().lower()\n+    if bound_host and bound_host not in _LOOPBACK_HOSTS:\n+        return None\n+    client_host = ws.client.host if ws.client else \"\"\n+    if not client_host:\n+        return None\n+    if client_host in _LOOPBACK_HOSTS:\n+        return None\n+    return f\"peer_not_loopback peer={client_host} bound={bound_host or '?'}\"\n+\n+\n def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     \"\"\"Check if the WebSocket client IP is acceptable.\n ","@@ -6959,54 +6981,55 @@ def _ws_client_is_allowed(ws: \"WebSocket\") -> bool:\n     return client_host in _LOOPBACK_HOSTS\n \n \n-def _ws_host_origin_is_allowed(ws: \"WebSocket\") -> bool:\n-    \"\"\"Apply the dashboard Host/Origin guard to WebSocket upgrades.\n+def _ws_host_origin_reason(ws: \"WebSocket\") -> Optional[str]:\n+    \"\"\"Return a Host/Origin rejection reason, or None when allowed.\n \n-    FastAPI HTTP middleware does not run for WebSocket routes, so the\n-    DNS-rebinding Host check used for normal dashboard HTTP requests must be\n-    repeated here before accepting the upgrade.  Browsers also send an Origin\n-    header on WebSocket handshakes; when present, require it to target the\n-    same bound dashboard host.\n+    Mirrors :func:`_ws_host_origin_is_allowed` but yields a short\n+    machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``)\n+    on rejection so the close path can log *why* the upgrade was refused.\n     \"\"\"\n     bound_host = getattr(app.state, \"bound_host\", None)\n     if not bound_host:\n-        return True\n+        return None\n \n     host_header = ws.headers.get(\"host\", \"\")\n     if not _is_accepted_host(host_header, bound_host):\n-        return False\n+        return f\"host_mismatch host={host_header or '?'} bound={bound_host}\"\n \n     origin = ws.headers.get(\"origin\", \"\")\n     if not origin:\n-        return True\n+        return None\n \n     parsed = urllib.parse.urlparse(origin)\n     if parsed.scheme not in {\"http\", \"https\"}:\n-        # Packaged Electron loads the desktop renderer over a non-web origin\n-        # such as file://, null, or a custom app:// scheme. This helper is\n-        # called only AFTER _ws_auth_ok has already accepted the WS credential,\n-        # which is the real auth boundary in every mode:\n-        #   * loopback bind          → legacy dashboard session token\n-        #   * non-loopback --insecure → legacy session token (Tailscale / LAN)\n-        #   * OAuth-gated public bind → single-use, 30s-TTL, identity-bound\n-        #     ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket\n-        # A non-web origin can only be produced by a native client (the desktop\n-        # shell); a DNS-rebinding attack always arrives from an http(s) origin\n-        # and is still match-checked against the bound host below. So once the\n-        # credential check upstream has passed, the Origin guard adds nothing\n-        # for a non-web origin — trust it in every mode.\n-        #\n-        # (Earlier revisions restricted this to loopback, then to non-gated\n-        # binds; both excluded the packaged desktop talking to a remote\n-        # OAuth-gated gateway, whose file:// renderer origin then got rejected\n-        # at the WS upgrade even with a valid ticket. The ticket is the gate,\n-        # not the origin.)\n-        return True\n+        # Non-web origin (packaged Electron: file://, null, app://). The\n+        # upstream credential check is the real auth boundary; trust it.\n+        # See _ws_host_origin_is_allowed for the full rationale.\n+        return None\n \n     if not parsed.netloc:\n-        return False\n+        return f\"origin_mismatch origin={origin} bound={bound_host}\"\n+\n+    if not _is_accepted_host(parsed.netloc, bound_host):\n+        return f\"origin_mismatch origin={origin} bound={bound_host}\"\n+    return None\n \n-    return _is_accepted_host(parsed.netloc, bound_host)\n+\n+def _ws_host_origin_is_allowed(ws: \"WebSocket\") -> bool:\n+    \"\"\"Apply the dashboard Host/Origin guard to WebSocket upgrades.\n+\n+    FastAPI HTTP middleware does not run for WebSocket routes, so the\n+    DNS-rebinding Host check used for normal dashboard HTTP requests must be\n+    repeated here before accepting the upgrade.  Browsers also send an Origin\n+    header on WebSocket handshakes; when present, require it to target the\n+    same bound dashboard host.\n+    \"\"\"\n+    return _ws_host_origin_reason(ws) is None\n+\n+\n+def _ws_request_reason(ws: \"WebSocket\") -> Optional[str]:\n+    \"\"\"First Host/Origin or peer-IP rejection reason, or None when allowed.\"\"\"\n+    return _ws_host_origin_reason(ws) or _ws_client_reason(ws)\n \n \n def _ws_request_is_allowed(ws: \"WebSocket\") -> bool:","@@ -7014,8 +7037,25 @@ def _ws_request_is_allowed(ws: \"WebSocket\") -> bool:\n     return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)\n \n \n-def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n-    \"\"\"Validate WS-upgrade auth in either loopback or gated mode.\n+def _ws_auth_mode() -> str:\n+    \"\"\"Short label for the active WS auth mode — logged on every connection.\"\"\"\n+    if getattr(app.state, \"auth_required\", False):\n+        return \"gated\"\n+    bound_host = (getattr(app.state, \"bound_host\", \"\") or \"\").strip().lower()\n+    if bound_host and bound_host not in _LOOPBACK_HOSTS:\n+        return \"insecure\"\n+    return \"loopback\"\n+\n+\n+def _ws_auth_reason(ws: \"WebSocket\") -> tuple[Optional[str], str]:\n+    \"\"\"Validate WS-upgrade auth; return ``(reason, credential)``.\n+\n+    ``reason`` is None when the credential is accepted, else a short\n+    machine-parseable token explaining the rejection (``no_credential``,\n+    ``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).\n+    ``credential`` names which credential type was presented (``ticket``,\n+    ``internal``, ``token``, or ``none``) so the accepted path can log *how*\n+    a peer authed, not just that it did.\n \n     Loopback / ``--insecure``: legacy ``?token=[redacted:secret-assignment] query\n     parameter, constant-time compared.","@@ -7036,9 +7076,8 @@ def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n     (the SPA bundle isn't carrying the token any longer, and a leaked\n     ``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).\n \n-    Returns True if the WS should be accepted; callers close with the\n-    appropriate WS code (4401) on False. Audit-logs the rejection so\n-    operators can debug \"WS keeps closing\" issues from the log.\n+    Audit-logs the rejection so operators can debug \"WS keeps closing\"\n+    issues from the log.\n     \"\"\"\n     auth_required = bool(getattr(app.state, \"auth_required\", False))\n     if auth_required:","@@ -7058,7 +7097,7 @@ def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n         if internal:\n             try:\n                 consume_internal_credential(internal)\n-                return True\n+                return None, \"internal\"\n             except TicketInvalid as exc:\n                 audit_log(\n                     AuditEvent.WS_TICKET_REJECTED,","@@ -7066,15 +7105,15 @@ def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n                     ip=(ws.client.host if ws.client else \"\"),\n                     path=ws.url.path,\n                 )\n-                return False\n+                return \"internal_invalid\", \"internal\"\n \n         ticket = ws.query_params.get(\"ticket\", \"\")\n         if not ticket:\n-            return False\n+            return \"no_credential\", \"none\"\n \n         try:\n             consume_ticket(ticket)\n-            return True\n+            return None, \"ticket\"\n         except TicketInvalid as exc:\n             audit_log(\n                 AuditEvent.WS_TICKET_REJECTED,","@@ -7082,10 +7121,19 @@ def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n                 ip=(ws.client.host if ws.client else \"\"),\n                 path=ws.url.path,\n             )\n-            return False\n+            return \"ticket_invalid\", \"ticket\"\n \n     token = [redacted:secret-assignment]\"token\", \"\")\n-    return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())\n+    if not token:\n+        return \"no_credential\", \"none\"\n+    if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):\n+        return None, \"token\"\n+    return \"token_mismatch\", \"token\"\n+\n+\n+def _ws_auth_ok(ws: \"WebSocket\") -> bool:\n+    \"\"\"True when the WS-upgrade credential is accepted. See _ws_auth_reason.\"\"\"\n+    return _ws_auth_reason(ws)[0] is None\n \n # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)\n # and /api/events (dashboard → browser sidebar).  Keyed by an opaque channel id","@@ -7237,22 +7285,58 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:\n     return channel if _VALID_CHANNEL_RE.match(channel) else None\n \n \n+def _ws_close_reason(text: str) -> str:\n+    \"\"\"Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.\n+\n+    RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a\n+    longer string is passed. Our reasons embed an attacker-controlled origin,\n+    so truncate defensively rather than crash the close handler.\n+    \"\"\"\n+    encoded = text.encode(\"utf-8\", \"replace\")\n+    if len(encoded) <= 123:\n+        return text\n+    return encoded[:120].decode(\"utf-8\", \"ignore\") + \"...\"\n+\n+\n @app.websocket(\"/api/pty\")\n async def pty_ws(ws: WebSocket) -> None:\n+    peer = ws.client.host if ws.client else \"?\"\n+\n     if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:\n-        await ws.close(code=4403)\n+        _log.info(\"pty refused: embedded chat disabled peer=%s\", peer)\n+        await ws.close(code=4404, reason=\"embedded chat disabled\")\n         return\n \n-    # --- auth + loopback check (before accept so we can close cleanly) ---\n-    if not _ws_auth_ok(ws):\n-        await ws.close(code=4401)\n+    # --- auth + host/origin/peer check (before accept so we can close\n+    #     cleanly AND tell the client WHY via the close code + reason).\n+    #     Each gate maps to a distinct close code so the log and the\n+    #     browser banner agree on the cause:\n+    #       4401 bad credential   4403 host/origin mismatch\n+    #       4408 peer not allowed  4404 chat disabled\n+    auth_reason, cred = _ws_auth_reason(ws)\n+    mode = _ws_auth_mode()\n+    if auth_reason is not None:\n+        _log.warning(\n+            \"pty auth rejected reason=%s mode=%s cred=%s peer=%s\",\n+            auth_reason, mode, cred, peer,\n+        )\n+        await ws.close(code=4401, reason=_ws_close_reason(f\"auth: {auth_reason}\"))\n         return\n \n-    if not _ws_request_is_allowed(ws):\n-        await ws.close(code=4403)\n+    host_origin_reason = _ws_host_origin_reason(ws)\n+    if host_origin_reason is not None:\n+        _log.warning(\"pty refused: %s peer=%s\", host_origin_reason, peer)\n+        await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))\n+        return\n+\n+    client_reason = _ws_client_reason(ws)\n+    if client_reason is not None:\n+        _log.warning(\"pty refused: %s\", client_reason)\n+        await ws.close(code=4408, reason=_ws_close_reason(client_reason))\n         return\n \n     await ws.accept()\n+    _log.info(\"pty accepted peer=%s mode=%s cred=%s\", peer, mode, cred)\n \n     # On native Windows, the POSIX PTY bridge can't be imported.  Tell the\n     # client and close cleanly rather than pretending the feature works."]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -156,6 +156,70 @@ def test_rewarns_after_file_edit(self, tmp_path, capsys):\n             after_edit = capsys.readouterr().err\n             assert \"hermes config:\" in after_edit, \"edited file should re-warn\"\n \n+    def test_corrupt_config_is_backed_up(self, tmp_path, capsys):\n+        \"\"\"A broken config.yaml is snapshotted to a timestamped .bak so the\n+        user's recoverable overrides survive a later wizard/config-set rewrite.\n+\n+        Ported from google-gemini/gemini-cli#21541 (policy-file TOML recovery),\n+        adapted: we back up but deliberately do NOT reset config.yaml.\n+        \"\"\"\n+        from hermes_cli import config as cfg_mod\n+        cfg_mod._CONFIG_PARSE_WARNED.clear()\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            broken = \"\\tmodel: test/custom\\nbroken indent:\\n\"\n+            (tmp_path / \"config.yaml\").write_text(broken)\n+\n+            load_config()\n+            err = capsys.readouterr().err\n+\n+            baks = list(tmp_path.glob(\"config.yaml.corrupt.*.bak\"))\n+            assert len(baks) == 1, f\"expected one backup, got {baks}\"\n+            # Backup preserves the original broken content verbatim\n+            assert baks[0].read_text() == broken\n+            # Original config.yaml is left untouched (not reset to clean state)\n+            assert (tmp_path / \"config.yaml\").read_text() == broken\n+            # User is told where the backup landed\n+            assert str(baks[0]) in err\n+\n+    def test_backup_skips_when_same_size_bak_exists(self, tmp_path, capsys):\n+        \"\"\"Don't churn backups: if a corrupt backup of the same size already\n+        exists (same corruption already preserved), skip making another.\"\"\"\n+        from hermes_cli import config as cfg_mod\n+        cfg_mod._CONFIG_PARSE_WARNED.clear()\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            broken = \"\\tbroken:\\n\"\n+            cfg = tmp_path / \"config.yaml\"\n+            cfg.write_text(broken)\n+\n+            # Pre-existing backup of identical size simulates an earlier snapshot.\n+            (tmp_path / \"config.yaml.corrupt.20260101-000000.bak\").write_text(broken)\n+\n+            load_config()\n+\n+            baks = list(tmp_path.glob(\"config.yaml.corrupt.*.bak\"))\n+            assert len(baks) == 1, f\"should not add a second same-size backup, got {baks}\"\n+\n+    def test_corrupt_symlink_config_not_backed_up(self, tmp_path):\n+        \"\"\"Symlinked config.yaml is not copied (mirrors Gemini #21541 lstat\n+        guard) — avoids clobbering whatever the symlink points at.\"\"\"\n+        import sys as _sys\n+        if _sys.platform == \"win32\":\n+            pytest.skip(\"symlink creation requires privileges on Windows\")\n+        from hermes_cli import config as cfg_mod\n+        cfg_mod._CONFIG_PARSE_WARNED.clear()\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            real = tmp_path / \"real_config.yaml\"\n+            real.write_text(\"\\tbroken:\\n\")\n+            link = tmp_path / \"config.yaml\"\n+            link.symlink_to(real)\n+\n+            load_config()\n+\n+            assert not list(tmp_path.glob(\"config.yaml.corrupt.*.bak\"))\n+\n \n class TestSaveAndLoadRoundtrip:\n     def test_roundtrip(self, tmp_path):"]},{"file":"tui_gateway/ws.py","hunks":["@@ -100,7 +100,10 @@ def write(self, obj: dict) -> bool:\n             return not self._closed\n         except Exception as exc:\n             self._closed = True\n-            _log.warning(\"ws write failed peer=%s error=%s\", self._peer, exc)\n+            _log.warning(\n+                \"ws write failed peer=%s error_type=%s error=%s\",\n+                self._peer, type(exc).__name__, exc,\n+            )\n             return False\n \n     async def write_async(self, obj: dict) -> bool:","@@ -115,7 +118,10 @@ async def _safe_send(self, line: str) -> None:\n             await self._ws.send_text(line)\n         except Exception as exc:\n             self._closed = True\n-            _log.warning(\"ws send failed peer=%s error=%s\", self._peer, exc)\n+            _log.warning(\n+                \"ws send failed peer=%s error_type=%s error=%s\",\n+                self._peer, type(exc).__name__, exc,\n+            )\n \n     def close(self) -> None:\n         self._closed = True"]},{"file":"web/src/pages/ChatPage.tsx","hunks":["@@ -603,19 +603,50 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {\n       if (unmounting) {\n         return;\n       }\n+      // Surface the real cause to the browser console on every close so a\n+      // \"chat won't connect\" report can be diagnosed without server access.\n+      // The server sends a machine-parseable reason on every rejection (see\n+      // pty_ws in web_server.py); echo it verbatim alongside the close code.\n+      const why = ev.reason ? ` reason=${ev.reason}` : \"\";\n+      console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);\n       if (ev.code === 4401) {\n-        setBanner(\"Auth failed. Reload the page to refresh the session token.\");\n+        setBanner(\n+          ev.reason\n+            ? `Auth failed (${ev.reason}). Reload to refresh the session.`\n+            : \"Auth failed. Reload the page to refresh the session token.\",\n+        );\n         return;\n       }\n       if (ev.code === 4403) {\n-        setBanner(\"Chat is only reachable from localhost.\");\n+        // Host/Origin mismatch (DNS-rebinding guard).\n+        setBanner(\n+          ev.reason\n+            ? `Refused: ${ev.reason}.`\n+            : \"Refused: request host/origin doesn't match the dashboard.\",\n+        );\n+        return;\n+      }\n+      if (ev.code === 4404) {\n+        setBanner(\n+          \"Embedded chat is disabled on this server (start it with --tui).\",\n+        );\n+        return;\n+      }\n+      if (ev.code === 4408) {\n+        setBanner(\n+          ev.reason\n+            ? `Refused: ${ev.reason}.`\n+            : \"Refused: your client isn't permitted (server bound to localhost only).\",\n+        );\n         return;\n       }\n       if (ev.code === 1011) {\n         // Server already wrote an ANSI error frame.\n         return;\n       }\n-      term.write(\"\\r\\n\\x1b[90m[session ended]\\x1b[0m\\r\\n\");\n+      term.write(\n+        `\\r\\n\\x1b[90m[session ended (code ${ev.code})]\\x1b[0m\\r\\n`,\n+      );\n     };\n \n     // Keystrokes → PTY.\n"]}]},{"kind":"pr","number":39522,"title":"fix(desktop): validate remote gateway websocket readiness","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#39522","number":39522},{"kind":"pr","value":"#38873","number":38873},{"kind":"pr","value":"#39522","number":39522}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40408,"title":"fix(security): bypass Host/Origin guard for authenticated WS connections (#38412, #40391)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6ed9a2de8","number":0},{"kind":"commit","value":"dd28f2ac9","number":0},{"kind":"pr","value":"#38412","number":38412},{"kind":"pr","value":"#38412","number":38412}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41570,"title":"fix(desktop): increase WebSocket probe grace window from 750ms to 3s (fixes #41566)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"39d09453f9","number":0},{"kind":"pr","value":"#41566","number":41566},{"kind":"pr","value":"#41566","number":41566}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43716,"title":"fix(desktop): attempt renderer reload on killed reason, not just crashed/oom","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#83567","number":83567},{"kind":"pr","value":"#81533","number":81533}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":57088,"title":"fix(tui): route runtime check off read loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56084","number":56084},{"kind":"pr","value":"#56084","number":56084}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":61449,"title":"fix(desktop): extend remote cwd boot timeout","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":62308,"title":"fix(desktop): preserve active connection across stale backend exits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65885","number":65885}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65885,"title":"fix(desktop): preserve active connection across stale backend exits (supersedes #62308)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/backend-connection-state.test.ts","apps/desktop/electron/backend-connection-state.ts","apps/desktop/electron/main.ts","apps/desktop/src/app/settings/gateway-settings.test.ts","apps/desktop/src/app/settings/gateway-settings.tsx"]},{"kind":"pr","number":66553,"title":"fix(desktop): discover basic-auth gateways","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"69ae247cf3dba34a37ab4af8484b96d3559a4fcf","number":0},{"kind":"pr","value":"#66521","number":66521},{"kind":"pr","value":"#66521","number":66521},{"kind":"pr","value":"#81571","number":81571},{"kind":"pr","value":"#66521","number":66521},{"kind":"pr","value":"#66521","number":66521}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":71602,"title":"fix(desktop): connect to password-auth remote gateways","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#71714","number":71714},{"kind":"pr","value":"#71714","number":71714}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":71714,"title":"fix(desktop): connect to gated remote gateways that 401 the readiness probe","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/backend-health.test.ts","apps/desktop/electron/backend-health.ts","apps/desktop/electron/backend-start-failure.test.ts","apps/desktop/electron/backend-start-failure.ts","apps/desktop/electron/main.ts","apps/desktop/electron/native-auth-decisions.test.ts","apps/desktop/electron/native-auth-decisions.ts","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]"]},{"kind":"pr","number":83567,"title":"fix(desktop): renderer-lifecycle diagnostics + crash recovery for every window","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.ts","apps/desktop/electron/wake-indicator-window.ts","apps/desktop/electron/wake-indicator.test.ts","apps/desktop/electron/window-renderer-lifecycle.test.ts","apps/desktop/electron/window-renderer-lifecycle.ts"]},{"kind":"pr","number":123008,"title":"fix(desktop): mirror remote session cookies in memory and retry once on 401","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.ts","apps/desktop/electron/oauth-rest-request.test.ts","apps/desktop/electron/oauth-rest-request.ts","apps/desktop/electron/oauth-session-response.ts","apps/desktop/electron/remote-session-cookies.test.ts","apps/desktop/electron/remote-session-cookies.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}