# NousResearch/hermes-agent · Desktop remote gateway connection stability and auth

## Open now

- 2 issues, 2 PRs open on GitHub.
- #46689: macOS Desktop auto-terminates after ~4 minutes; renderer killed with exitCode=15
- #53352: Desktop: renderer crash  boot loop on Windows + build overwrites source
- PR #39522: fix(desktop): validate remote gateway websocket readiness
- PR #66553: fix(desktop): discover basic-auth gateways

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.

## TL;DR

- 43 member(s): 2 open issue(s), 23 closed issue(s), 2 open PR(s), 6 merged PR(s), 10 PR(s) closed without merging.
- A fix was merged to main in PR #37747. Verify it addresses your case.
- **Recommended:** read PR #37747 (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: `partially_resolved` · deciding PR(s): `pr-37747,pr-65885,pr-71714,pr-123008`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.04`: 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.

## Open related issues

- #46689: macOS Desktop auto-terminates after ~4 minutes; renderer killed with exitCode=15
- #53352: Desktop: renderer crash  boot loop on Windows + build overwrites source

## Closed / duplicate issues

- #37399: Hermes Desktop remote mode rejects Electron WebSocket origins on non-loopback dashboard binds
- #37869: \[Bug\]: Desktop app shows remote connection failure while logs say remote backend is ready
- #38061: \[Bug\]: Can't connect to Remote Gateway via Tailscale for Hermes Desktop
- #38115: Remote gateway (macOS): desktop session won't hold — gateway SIGTERM → WebSocket close 1012 loop; "Test remote" false-passes; auto-updater corrupts install
- #38266: \[Bug\]: Hermes Desktop 0.15.1 remote gateway enters reconnect loop after successful connection
- #38332: Hermes Desktop remote gateway reaches backend but UI still shows startup error / falls back to local backend unless env-forced
- #38412: \[Bug\]: Desktop "Remote gateway" can't connect over WebSocket — /api/ws always rejected (4403) for packaged Electron client
- #38873: Hermes Desktop remote gateway mode flaps back to local backend after successful remote readiness
- #38930: Desktop "Remote gateway" reconnects in loop against external \`hermes dashboard\`
- #40083: Desktop: Electron renderer crash loop on remote gateway connection (v0.15.1)
- #40391: \[Bug\]: Hermes Desktop Remote Gateway — connects to REST but fails WebSocket / flaps back to local
- #41566: \[Bug\]: Desktop still shows 'Could not connect to Hermes gateway' after successful remote HTTPS/WSS verification
- #43640: Desktop App: Renderer crashes with exitCode=15 causing boot loop
- #43904: \[Desktop v0.16.0\] Remote backend "ready" but UI stuck on red error dialog, loops in reset/repair cycle
- #48434: Windows Desktop remote gateway fails after successful password-auth sign-in / Save and reconnect
- #51873: Hermes Desktop never shows sign-in UI when connecting to remote backend with basic auth
- #52255: Desktop remote mode fails against authenticated remote gateway: backend ready, but WebSocket/API auth rejected
- #56888: setup.runtime_check missing from _LONG_HANDLERS — blocks WS read loop under GIL pressure, causing false "gateway disconnect / runtime not ready" (#50005 residual)
- #58875: Desktop: React stack overflow when connecting to remote gateway backen
- #61457: Desktop: remote gateway session cookie never persists after basic-auth login — immediate 401 no_cookie loop
- #65115: Desktop: after a username/password gateway sign-in, the app keeps reporting "not signed in" until restart
- #66521: \[Bug\]: Desktop Remote gateway shows legacy session-token field, not username/password Sign In — can't connect to basic-auth backend
- #68517: 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)

## Prior & competing PRs

- **#17887: fix(cli): honor --insecure for dashboard WebSocket connections**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#37747: fix(dashboard): allow desktop websocket origins on remote binds**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/web_server.py`, `scripts/release.py`, `tests/hermes_cli/test_dashboard_auth_ws_auth.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -6603,13 +6603,16 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
     
         parsed = urllib.parse.urlparse(origin)
         if parsed.scheme not in {"http", "https"}:
    -        # Packaged Electron loads the desktop renderer over file://, so its
    -        # WebSocket handshake carries a non-web Origin such as file:// or null.
    -        # DNS-rebinding attacks originate from an http(s) site; they cannot
    -        # forge a file:// origin and still hold the loopback session token.
    -        # Public/gated binds have no legitimate non-web client, so keep
    -        # rejecting these origins there.
    -        return bound_host.lower() in _LOOPBACK_HOST_VALUES
    +        # Packaged Electron loads the desktop renderer over a non-web origin
    +        # such as file:// or null. This helper is called only after _ws_auth_ok
    +        # has accepted the WS credential; in non-gated mode that credential is
    +        # the legacy dashboard session token, including for explicit Tailscale /
    +        # LAN binds opened with --insecure. Real DNS-rebinding attacks arrive
    +        # from http(s) origins and still have to match the bound host below.
    +        #
    +        # OAuth-gated public dashboards authenticate with cookies/tickets and
    +        # have no legitimate file:// client, so keep them strict.
    +        return not getattr(app.state, "auth_required", False)
     
         if not parsed.netloc:
             return False
    ```
  - `scripts/release.py`

    ```diff
    @@ -1425,6 +1425,7 @@
         "[redacted:email]": "x1am1",  # PR #35098 (chown root-owned top-level HERMES_HOME state files)
         "[redacted:email]": "polnikale",  # PR #35717 (discover Playwright headless_shell browser)
         "[redacted:email]": "whyhkzk",  # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
    +    "[redacted:email]": "leonardsellem",  # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
     }
     
     
    ```
  - `tests/hermes_cli/test_dashboard_auth_ws_auth.py`

    ```diff
    @@ -381,8 +381,9 @@ class TestWsHostOriginGuardOrigins:
         Electron loads the packaged renderer over ``file://``, so its WebSocket
         handshake carries ``Origin: file://`` (or the opaque ``null``). The
         DNS-rebinding guard only needs to block cross-site http(s) origins. On a
    -    loopback bind these non-web origins are trusted because the session token
    -    is the real gate. Public/gated binds keep rejecting them.
    +    loopback or explicit non-loopback insecure bind these non-web origins are
    +    trusted because the session token is the real gate. OAuth-gated public
    +    binds keep rejecting them.
         """
     
         def _ws(self, *, origin, host):
    ```

    ```diff
    @@ -413,8 +414,29 @@ def test_loopback_cross_site_http_origin_rejected(self, loopback_app):
             ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080")
             assert web_server._ws_host_origin_is_allowed(ws) is False
     
    +    def test_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app):
    +        """Packaged Hermes Desktop also uses file:// when connecting to a
    +        Tailscale/LAN dashboard bind.
    +
    +        The WebSocket route calls _ws_auth_ok before this guard, so in
    +        non-gated mode the legacy session token remains the auth boundary.
    +        """
    +        ws = self._ws(origin="file://", host="100.64.0.10:9119")
    +        assert web_server._ws_host_origin_is_allowed(ws) is True
    +
    +    def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app):
    +        ws = self._ws(origin="null", host="100.64.0.10:9119")
    +        assert web_server._ws_host_origin_is_allowed(ws) is True
    +
    +    def test_explicit_non_loopback_cross_site_http_origin_rejected(
    +        self, insecure_explicit_host_app
    +    ):
    +        ws = self._ws(origin="http://localhost:9119", host="100.64.0.10:9119")
    +        assert web_server._ws_host_origin_is_allowed(ws) is False
    +
         def test_gated_file_origin_rejected(self, gated_app):
    -        # A public/gated bind has no legitimate file:// client.
    +        # OAuth-gated public dashboards authenticate with cookies/tickets,
    +        # not the legacy desktop session token.
             ws = self._ws(origin="file://", host="fly-app.fly.dev")
             assert web_server._ws_host_origin_is_allowed(ws) is False
     
    ```
- **#38743: fix(dashboard): explain WHY a chat WS connection was refused**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/config.py`

    ```diff
    @@ -17,11 +17,13 @@
     import os
     import platform
     import re
    +import shutil
     import stat
     import subprocess
     import sys
     import tempfile
     import threading
    +import time
     from dataclasses import dataclass
     from pathlib import Path
     from typing import Dict, Any, Optional, List, Tuple
    ```

    ```diff
    @@ -36,6 +38,60 @@
     _CONFIG_PARSE_WARNED: set = set()
     
     
    +def _backup_corrupt_config(config_path: Path) -> Optional[Path]:
    +    """Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.
    +
    +    When the YAML can't be parsed, ``load_config()`` silently falls back to
    +    ``DEFAULT_CONFIG`` and the user's broken file stays on disk untouched.
    +    That file is still the user's only copy of their intended overrides — if
    +    they re-run the setup wizard or ``hermes config set`` (which rewrites
    +    ``config.yaml``), the broken-but-recoverable content is gone for good.
    +
    +    This snapshots the corrupted file to ``config.yaml.corrupt.<ts>.bak`` so
    +    the user can diff/repair it. Unlike Gemini CLI's policy-file recovery
    +    (which resets the live file to a clean state), we deliberately leave
    +    ``config.yaml`` in place: hermes never silently mutates the user's config,
    +    and leaving it means a hand-fixed file is re-read on the next load. The
    +    backup is best-effort — any failure (permissions, symlink, disk full) is
    +    swallowed so config loading is never blocked by backup problems.
    +
    +    Returns the backup path on success, else ``None``. Symlinks are not
    +    followed/copied (mirrors the Gemini #21541 lstat guard) to avoid
    +    clobbering whatever a malicious/misconfigured symlink points at.
    +    """
    +    try:
    +        if config_path.is_symlink():
    +            return None
    +        st = config_path.stat()
    +        if st.st_size == 0:
    +            # Empty file isn't worth preserving and yaml.safe_load returns {}
    +            # for it anyway (so it wouldn't reach here), but guard regardless.
    +            return None
    +        ts = time.strftime("%Y%m%d-%H%M%S")
    +        backup_path = config_path.with_name(f"{config_path.name}.corrupt.{ts}.bak")
    +        # Don't clobber an existing backup from the same second; if there's
    +        # already a corrupt backup for this exact mtime, assume we've snapshotted
    +        # this corruption already and skip (the dedup cache normally prevents a
    +        # second call, but a process restart can clear it).
    +        sibling_baks = list(
    +            config_path.parent.glob(f"{config_path.name}.corrupt.*.bak")
    +        )
    +        for existing in sibling_baks:
    +            try:
    +                if existing.stat().st_size == st.st_size:
    +                    # Same size as the current broken file — likely the same
    +                    # corruption already preserved. Avoid backup churn.
    +                    return None
    +            except OSError:
    +                continue
    +        if backup_path.exists():
    +            return None
    +        shutil.copy2(config_path, backup_path)
    +        return backup_path
    +    except Exception:
    +        return None
    +
    +
     def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
         """Surface a config.yaml parse failure to user, log, and stderr.
     
    ```

    ```diff
    @@ -48,7 +104,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
         Now: warn once per (path, mtime_ns, size) on stderr **and** in
         ``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``
         surfaces it. Re-warns automatically if the file changes (different
    -    mtime/size), so users editing the config see the next failure.
    +    mtime/size), so users editing the config see the next failure. On the
    +    first warning for a given broken file we also snapshot it to a
    +    timestamped ``.bak`` (best-effort) so the user's recoverable content
    +    survives any later rewrite of ``config.yaml`` by the setup wizard or
    +    ``hermes config set``.
         """
         try:
             st = config_path.stat()
    ```

    ```diff
    @@ -59,12 +119,16 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
             return
         _CONFIG_PARSE_WARNED.add(key)
     
    +    backup_path = _backup_corrupt_config(config_path)
    +
         msg = (
             f"Failed to parse {config_path}: {exc}. "
             f"Falling back to default config — every user override "
             f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
             f"Fix the YAML and restart."
         )
    +    if backup_path is not None:
    +        msg += f" A copy of the corrupted file was saved to {backup_path}."
         logger.warning(msg)
         try:
             sys.stderr.write(f"⚠️  hermes config: {msg}\n")
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -6919,6 +6919,28 @@ class PtyUnavailableError(RuntimeError):  # type: ignore[no-redef]
     _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
     
     
    +def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
    +    """Return a rejection reason for the client IP, or None when allowed.
    +
    +    Reasons are short machine-parseable tokens logged on the rejection path
    +    so a "WS keeps closing" report can be diagnosed from agent.log without a
    +    repro. ``None`` means the peer IP passed this gate.
    +
    +    See :func:`_ws_client_is_allowed` for the full policy rationale.
    +    """
    +    if getattr(app.state, "auth_required", False):
    +        return None
    +    bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
    +    if bound_host and bound_host not in _LOOPBACK_HOSTS:
    +        return None
    +    client_host = ws.client.host if ws.client else ""
    +    if not client_host:
    +        return None
    +    if client_host in _LOOPBACK_HOSTS:
    +        return None
    +    return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
    +
    +
     def _ws_client_is_allowed(ws: "WebSocket") -> bool:
         """Check if the WebSocket client IP is acceptable.
     
    ```

    ```diff
    @@ -6959,54 +6981,55 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
         return client_host in _LOOPBACK_HOSTS
     
     
    -def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
    -    """Apply the dashboard Host/Origin guard to WebSocket upgrades.
    +def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
    +    """Return a Host/Origin rejection reason, or None when allowed.
     
    -    FastAPI HTTP middleware does not run for WebSocket routes, so the
    -    DNS-rebinding Host check used for normal dashboard HTTP requests must be
    -    repeated here before accepting the upgrade.  Browsers also send an Origin
    -    header on WebSocket handshakes; when present, require it to target the
    -    same bound dashboard host.
    +    Mirrors :func:`_ws_host_origin_is_allowed` but yields a short
    +    machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``)
    +    on rejection so the close path can log *why* the upgrade was refused.
         """
         bound_host = getattr(app.state, "bound_host", None)
         if not bound_host:
    -        return True
    +        return None
     
         host_header = ws.headers.get("host", "")
         if not _is_accepted_host(host_header, bound_host):
    -        return False
    +        return f"host_mismatch host={host_header or '?'} bound={bound_host}"
     
         origin = ws.headers.get("origin", "")
         if not origin:
    -        return True
    +        return None
     
         parsed = urllib.parse.urlparse(origin)
         if parsed.scheme not in {"http", "https"}:
    -        # Packaged Electron loads the desktop renderer over a non-web origin
    -        # such as file://, null, or a custom app:// scheme. This helper is
    -        # called only AFTER _ws_auth_ok has already accepted the WS credential,
    -        # which is the real auth boundary in every mode:
    -        #   * loopback bind          → legacy dashboard session token
    -        #   * non-loopback --insecure → legacy session token (Tailscale / LAN)
    -        #   * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
    -        #     ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
    -        # A non-web origin can only be produced by a native client (the desktop
    -        # shell); a DNS-rebinding attack always arrives from an http(s) origin
    -        # and is still match-checked against the bound host below. So once the
    -        # credential check upstream has passed, the Origin guard adds nothing
    -        # for a non-web origin — trust it in every mode.
    -        #
    -        # (Earlier revisions restricted this to loopback, then to non-gated
    -        # binds; both excluded the packaged desktop talking to a remote
    -        # OAuth-gated gateway, whose file:// renderer origin then got rejected
    -        # at the WS upgrade even with a valid ticket. The ticket is the gate,
    -        # not the origin.)
    -        return True
    +        # Non-web origin (packaged Electron: file://, null, app://). The
    +        # upstream credential check is the real auth boundary; trust it.
    +        # See _ws_host_origin_is_allowed for the full rationale.
    +        return None
     
         if not parsed.netloc:
    -        return False
    +        return f"origin_mismatch origin={origin} bound={bound_host}"
    +
    +    if not _is_accepted_host(parsed.netloc, bound_host):
    +        return f"origin_mismatch origin={origin} bound={bound_host}"
    +    return None
     
    -    return _is_accepted_host(parsed.netloc, bound_host)
    +
    +def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
    +    """Apply the dashboard Host/Origin guard to WebSocket upgrades.
    +
    +    FastAPI HTTP middleware does not run for WebSocket routes, so the
    +    DNS-rebinding Host check used for normal dashboard HTTP requests must be
    +    repeated here before accepting the upgrade.  Browsers also send an Origin
    +    header on WebSocket handshakes; when present, require it to target the
    +    same bound dashboard host.
    +    """
    +    return _ws_host_origin_reason(ws) is None
    +
    +
    +def _ws_request_reason(ws: "WebSocket") -> Optional[str]:
    +    """First Host/Origin or peer-IP rejection reason, or None when allowed."""
    +    return _ws_host_origin_reason(ws) or _ws_client_reason(ws)
     
     
     def _ws_request_is_allowed(ws: "WebSocket") -> bool:
    ```

    ```diff
    @@ -7014,8 +7037,25 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool:
         return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
     
     
    -def _ws_auth_ok(ws: "WebSocket") -> bool:
    -    """Validate WS-upgrade auth in either loopback or gated mode.
    +def _ws_auth_mode() -> str:
    +    """Short label for the active WS auth mode — logged on every connection."""
    +    if getattr(app.state, "auth_required", False):
    +        return "gated"
    +    bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
    +    if bound_host and bound_host not in _LOOPBACK_HOSTS:
    +        return "insecure"
    +    return "loopback"
    +
    +
    +def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
    +    """Validate WS-upgrade auth; return ``(reason, credential)``.
    +
    +    ``reason`` is None when the credential is accepted, else a short
    +    machine-parseable token explaining the rejection (``no_credential``,
    +    ``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
    +    ``credential`` names which credential type was presented (``ticket``,
    +    ``internal``, ``token``, or ``none``) so the accepted path can log *how*
    +    a peer authed, not just that it did.
     
         Loopback / ``--insecure``: legacy ``?token=[redacted:secret-assignment] query
         parameter, constant-time compared.
    ```

    ```diff
    @@ -7036,9 +7076,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
         (the SPA bundle isn't carrying the token any longer, and a leaked
         ``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
     
    -    Returns True if the WS should be accepted; callers close with the
    -    appropriate WS code (4401) on False. Audit-logs the rejection so
    -    operators can debug "WS keeps closing" issues from the log.
    +    Audit-logs the rejection so operators can debug "WS keeps closing"
    +    issues from the log.
         """
         auth_required = bool(getattr(app.state, "auth_required", False))
         if auth_required:
    ```

    ```diff
    @@ -7058,7 +7097,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
             if internal:
                 try:
                     consume_internal_credential(internal)
    -                return True
    +                return None, "internal"
                 except TicketInvalid as exc:
                     audit_log(
                         AuditEvent.WS_TICKET_REJECTED,
    ```

    ```diff
    @@ -7066,15 +7105,15 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
                         ip=(ws.client.host if ws.client else ""),
                         path=ws.url.path,
                     )
    -                return False
    +                return "internal_invalid", "internal"
     
             ticket = ws.query_params.get("ticket", "")
             if not ticket:
    -            return False
    +            return "no_credential", "none"
     
             try:
                 consume_ticket(ticket)
    -            return True
    +            return None, "ticket"
             except TicketInvalid as exc:
                 audit_log(
                     AuditEvent.WS_TICKET_REJECTED,
    ```

    ```diff
    @@ -7082,10 +7121,19 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
                     ip=(ws.client.host if ws.client else ""),
                     path=ws.url.path,
                 )
    -            return False
    +            return "ticket_invalid", "ticket"
     
         token = [redacted:secret-assignment]"token", "")
    -    return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
    +    if not token:
    +        return "no_credential", "none"
    +    if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
    +        return None, "token"
    +    return "token_mismatch", "token"
    +
    +
    +def _ws_auth_ok(ws: "WebSocket") -> bool:
    +    """True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
    +    return _ws_auth_reason(ws)[0] is None
     
     # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
     # and /api/events (dashboard → browser sidebar).  Keyed by an opaque channel id
    ```

    ```diff
    @@ -7237,22 +7285,58 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
         return channel if _VALID_CHANNEL_RE.match(channel) else None
     
     
    +def _ws_close_reason(text: str) -> str:
    +    """Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.
    +
    +    RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a
    +    longer string is passed. Our reasons embed an attacker-controlled origin,
    +    so truncate defensively rather than crash the close handler.
    +    """
    +    encoded = text.encode("utf-8", "replace")
    +    if len(encoded) <= 123:
    +        return text
    +    return encoded[:120].decode("utf-8", "ignore") + "..."
    +
    +
     @app.websocket("/api/pty")
     async def pty_ws(ws: WebSocket) -> None:
    +    peer = ws.client.host if ws.client else "?"
    +
         if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
    -        await ws.close(code=4403)
    +        _log.info("pty refused: embedded chat disabled peer=%s", peer)
    +        await ws.close(code=4404, reason="embedded chat disabled")
             return
     
    -    # --- auth + loopback check (before accept so we can close cleanly) ---
    -    if not _ws_auth_ok(ws):
    -        await ws.close(code=4401)
    +    # --- auth + host/origin/peer check (before accept so we can close
    +    #     cleanly AND tell the client WHY via the close code + reason).
    +    #     Each gate maps to a distinct close code so the log and the
    +    #     browser banner agree on the cause:
    +    #       4401 bad credential   4403 host/origin mismatch
    +    #       4408 peer not allowed  4404 chat disabled
    +    auth_reason, cred = _ws_auth_reason(ws)
    +    mode = _ws_auth_mode()
    +    if auth_reason is not None:
    +        _log.warning(
    +            "pty auth rejected reason=%s mode=%s cred=%s peer=%s",
    +            auth_reason, mode, cred, peer,
    +        )
    +        await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
             return
     
    -    if not _ws_request_is_allowed(ws):
    -        await ws.close(code=4403)
    +    host_origin_reason = _ws_host_origin_reason(ws)
    +    if host_origin_reason is not None:
    +        _log.warning("pty refused: %s peer=%s", host_origin_reason, peer)
    +        await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
    +        return
    +
    +    client_reason = _ws_client_reason(ws)
    +    if client_reason is not None:
    +        _log.warning("pty refused: %s", client_reason)
    +        await ws.close(code=4408, reason=_ws_close_reason(client_reason))
             return
     
         await ws.accept()
    +    _log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred)
     
         # On native Windows, the POSIX PTY bridge can't be imported.  Tell the
         # client and close cleanly rather than pretending the feature works.
    ```
  - `tests/hermes_cli/test_config.py`

    ```diff
    @@ -156,6 +156,70 @@ def test_rewarns_after_file_edit(self, tmp_path, capsys):
                 after_edit = capsys.readouterr().err
                 assert "hermes config:" in after_edit, "edited file should re-warn"
     
    +    def test_corrupt_config_is_backed_up(self, tmp_path, capsys):
    +        """A broken config.yaml is snapshotted to a timestamped .bak so the
    +        user's recoverable overrides survive a later wizard/config-set rewrite.
    +
    +        Ported from google-gemini/gemini-cli#21541 (policy-file TOML recovery),
    +        adapted: we back up but deliberately do NOT reset config.yaml.
    +        """
    +        from hermes_cli import config as cfg_mod
    +        cfg_mod._CONFIG_PARSE_WARNED.clear()
    +
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
    +            broken = "\tmodel: test/custom\nbroken indent:\n"
    +            (tmp_path / "config.yaml").write_text(broken)
    +
    +            load_config()
    +            err = capsys.readouterr().err
    +
    +            baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
    +            assert len(baks) == 1, f"expected one backup, got {baks}"
    +            # Backup preserves the original broken content verbatim
    +            assert baks[0].read_text() == broken
    +            # Original config.yaml is left untouched (not reset to clean state)
    +            assert (tmp_path / "config.yaml").read_text() == broken
    +            # User is told where the backup landed
    +            assert str(baks[0]) in err
    +
    +    def test_backup_skips_when_same_size_bak_exists(self, tmp_path, capsys):
    +        """Don't churn backups: if a corrupt backup of the same size already
    +        exists (same corruption already preserved), skip making another."""
    +        from hermes_cli import config as cfg_mod
    +        cfg_mod._CONFIG_PARSE_WARNED.clear()
    +
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
    +            broken = "\tbroken:\n"
    +            cfg = tmp_path / "config.yaml"
    +            cfg.write_text(broken)
    +
    +            # Pre-existing backup of identical size simulates an earlier snapshot.
    +            (tmp_path / "config.yaml.corrupt.20260101-000000.bak").write_text(broken)
    +
    +            load_config()
    +
    +            baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
    +            assert len(baks) == 1, f"should not add a second same-size backup, got {baks}"
    +
    +    def test_corrupt_symlink_config_not_backed_up(self, tmp_path):
    +        """Symlinked config.yaml is not copied (mirrors Gemini #21541 lstat
    +        guard) — avoids clobbering whatever the symlink points at."""
    +        import sys as _sys
    +        if _sys.platform == "win32":
    +            pytest.skip("symlink creation requires privileges on Windows")
    +        from hermes_cli import config as cfg_mod
    +        cfg_mod._CONFIG_PARSE_WARNED.clear()
    +
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
    +            real = tmp_path / "real_config.yaml"
    +            real.write_text("\tbroken:\n")
    +            link = tmp_path / "config.yaml"
    +            link.symlink_to(real)
    +
    +            load_config()
    +
    +            assert not list(tmp_path.glob("config.yaml.corrupt.*.bak"))
    +
     
     class TestSaveAndLoadRoundtrip:
         def test_roundtrip(self, tmp_path):
    ```
  - `tui_gateway/ws.py`

    ```diff
    @@ -100,7 +100,10 @@ def write(self, obj: dict) -> bool:
                 return not self._closed
             except Exception as exc:
                 self._closed = True
    -            _log.warning("ws write failed peer=%s error=%s", self._peer, exc)
    +            _log.warning(
    +                "ws write failed peer=%s error_type=%s error=%s",
    +                self._peer, type(exc).__name__, exc,
    +            )
                 return False
     
         async def write_async(self, obj: dict) -> bool:
    ```

    ```diff
    @@ -115,7 +118,10 @@ async def _safe_send(self, line: str) -> None:
                 await self._ws.send_text(line)
             except Exception as exc:
                 self._closed = True
    -            _log.warning("ws send failed peer=%s error=%s", self._peer, exc)
    +            _log.warning(
    +                "ws send failed peer=%s error_type=%s error=%s",
    +                self._peer, type(exc).__name__, exc,
    +            )
     
         def close(self) -> None:
             self._closed = True
    ```
  - `web/src/pages/ChatPage.tsx`

    ```diff
    @@ -603,19 +603,50 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
           if (unmounting) {
             return;
           }
    +      // Surface the real cause to the browser console on every close so a
    +      // "chat won't connect" report can be diagnosed without server access.
    +      // The server sends a machine-parseable reason on every rejection (see
    +      // pty_ws in web_server.py); echo it verbatim alongside the close code.
    +      const why = ev.reason ? ` reason=${ev.reason}` : "";
    +      console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
           if (ev.code === 4401) {
    -        setBanner("Auth failed. Reload the page to refresh the session token.");
    +        setBanner(
    +          ev.reason
    +            ? `Auth failed (${ev.reason}). Reload to refresh the session.`
    +            : "Auth failed. Reload the page to refresh the session token.",
    +        );
             return;
           }
           if (ev.code === 4403) {
    -        setBanner("Chat is only reachable from localhost.");
    +        // Host/Origin mismatch (DNS-rebinding guard).
    +        setBanner(
    +          ev.reason
    +            ? `Refused: ${ev.reason}.`
    +            : "Refused: request host/origin doesn't match the dashboard.",
    +        );
    +        return;
    +      }
    +      if (ev.code === 4404) {
    +        setBanner(
    +          "Embedded chat is disabled on this server (start it with --tui).",
    +        );
    +        return;
    +      }
    +      if (ev.code === 4408) {
    +        setBanner(
    +          ev.reason
    +            ? `Refused: ${ev.reason}.`
    +            : "Refused: your client isn't permitted (server bound to localhost only).",
    +        );
             return;
           }
           if (ev.code === 1011) {
             // Server already wrote an ANSI error frame.
             return;
           }
    -      term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
    +      term.write(
    +        `\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
    +      );
         };
     
         // Keystrokes → PTY.
    ```
- **#40408: fix(security): bypass Host/Origin guard for authenticated WS connections (#38412, #40391)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6ed9a2de8`, `dd28f2ac9`, `#38412`, `#38412`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#57088: fix(tui): route runtime check off read loop**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56084`, `#56084`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#65885: fix(desktop): preserve active connection across stale backend exits (supersedes #62308)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#71714: fix(desktop): connect to gated remote gateways that 401 the readiness probe**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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]`
- **#83567: fix(desktop): renderer-lifecycle diagnostics + crash recovery for every window**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#123008: fix(desktop): mirror remote session cookies in memory and retry once on 401**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#37405: fix(dashboard): allow desktop websocket origins on remote binds**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#38743`, `#37399`, `#37399`
- **#38292: fix(desktop): ignore stale backend exits**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `6feb40e702829639c22aae2ae29d63e9ac5e8508`, `39d09453f95e8aefc0c97e5d9b30ff341cae9ed8`
- **#39522: fix(desktop): validate remote gateway websocket readiness**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#39522`, `#38873`, `#39522`
  - files: `apps/desktop/electron/gateway-ws-probe.cjs`, `apps/desktop/electron/gateway-ws-probe.test.cjs`, `apps/desktop/electron/main.cjs`, `apps/desktop/package.json`
- **#41570: fix(desktop): increase WebSocket probe grace window from 750ms to 3s (fixes #41566)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `39d09453f9`, `#41566`, `#41566`
- **#43716: fix(desktop): attempt renderer reload on killed reason, not just crashed/oom**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#83567`, `#81533`
- **#61449: fix(desktop): extend remote cwd boot timeout**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`
- **#62308: fix(desktop): preserve active connection across stale backend exits**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#65885`
- **#66553: fix(desktop): discover basic-auth gateways**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `69ae247cf3dba34a37ab4af8484b96d3559a4fcf`, `#66521`, `#66521`, `#81571`, `#66521`, `#66521`
  - files: `apps/desktop/electron/connection-config.test.ts`, `apps/desktop/electron/connection-config.ts`, `apps/desktop/src/app/settings/gateway-settings.test.tsx`
- **#71602: fix(desktop): connect to password-auth remote gateways**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#71714`, `#71714`

## Fix status

- Merged to main in PR #37747 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-27`.
- Subsystem: `apps/desktop`
