Desktop remote gateway connection stability and auth

NousResearch/hermes-agent · cluster 37399 · apps/desktop

Data as of

4 open items. Fix merged in #37747. Check it covers your case.

The short answer

2open issues
2open PRs
43items grouped
Known?
Yes. 43 related items grouped here, 23 issues already closed.
Fix event
A fix was merged to main in PR #37747. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #37747, #65885, #71714, #123008. That is separate from the fix event.
Still open
2 issues and 2 PRs.

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.

Open issues2

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

Open PRs2

  • PR#39522fix(desktop): validate remote gateway websocket readiness
  • PR#66553fix(desktop): discover basic-auth gateways

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #37747 fix(dashboard): allow desktop websocket origins on remote binds

    merged to main

    files: hermes_cli/web_server.py, scripts/release.py, tests/hermes_cli/test_dashboard_auth_ws_auth.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -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
      @@ -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
      @@ -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):
      @@ -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

    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

    Show the diff
    • hermes_cli/config.py
      @@ -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
      @@ -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.
       
      @@ -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()
      @@ -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
      @@ -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.
       
      @@ -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:
      @@ -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.
      @@ -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:
      @@ -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,
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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
      @@ -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.
  • #65885 fix(desktop): preserve active connection across stale backend exits (supersedes #62308)

    merged to main

    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

    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

    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

    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

10 PRs closed without merging

From the close comments: done elsewhere 4 · superseded by another PR 4 · PR volume cleanup 1 · other 1

  • #17887 fix(cli): honor --insecure for dashboard WebSocket connections · other
  • #37405 fix(dashboard): allow desktop websocket origins on remote binds · done elsewhere → #38743, #37399
  • #38292 fix(desktop): ignore stale backend exits · done elsewhere → 6feb40e, 39d0945
  • #40408 fix(security): bypass Host/Origin guard for authenticated WS connections (#38412, #40391) · done elsewhere → 6ed9a2d, dd28f2a, #38412
  • #41570 fix(desktop): increase WebSocket probe grace window from 750ms to 3s (fixes #41566) · superseded by another PR → 39d0945, #41566
Show 5 more
  • #43716 fix(desktop): attempt renderer reload on killed reason, not just crashed/oom · superseded by another PR → #83567, #81533
  • #57088 fix(tui): route runtime check off read loop · superseded by another PR → #56084
  • #61449 fix(desktop): extend remote cwd boot timeout · PR volume cleanup
  • #62308 fix(desktop): preserve active connection across stale backend exits · done elsewhere → #65885
  • #71602 fix(desktop): connect to password-auth remote gateways · superseded by another PR → #71714
Closed issues · 23
  • Closed#37399Hermes Desktop remote mode rejects Electron WebSocket origins on non-loopback dashboard binds
  • Closed#37869[Bug]: Desktop app shows remote connection failure while logs say remote backend is ready
  • Closed#38061[Bug]: Can't connect to Remote Gateway via Tailscale for Hermes Desktop
  • Closed#38115Remote gateway (macOS): desktop session won't hold — gateway SIGTERM → WebSocket close 1012 loop; "Test remote" false-passes; auto-updater corrupts install
  • Closed#38266[Bug]: Hermes Desktop 0.15.1 remote gateway enters reconnect loop after successful connection
  • Closed#38332Hermes Desktop remote gateway reaches backend but UI still shows startup error / falls back to local backend unless env-forced
  • Closed#38412[Bug]: Desktop "Remote gateway" can't connect over WebSocket — /api/ws always rejected (4403) for packaged Electron client
  • Closed#38873Hermes Desktop remote gateway mode flaps back to local backend after successful remote readiness
  • Closed#38930Desktop "Remote gateway" reconnects in loop against external `hermes dashboard`
  • Closed#40083Desktop: Electron renderer crash loop on remote gateway connection (v0.15.1)
  • Closed#40391[Bug]: Hermes Desktop Remote Gateway — connects to REST but fails WebSocket / flaps back to local
  • Closed#41566[Bug]: Desktop still shows 'Could not connect to Hermes gateway' after successful remote HTTPS/WSS verification
  • Closed#43640Desktop App: Renderer crashes with exitCode=15 causing boot loop
  • Closed#43904[Desktop v0.16.0] Remote backend "ready" but UI stuck on red error dialog, loops in reset/repair cycle
  • Closed#48434Windows Desktop remote gateway fails after successful password-auth sign-in / Save and reconnect
  • Closed#51873Hermes Desktop never shows sign-in UI when connecting to remote backend with basic auth
  • Closed#52255Desktop remote mode fails against authenticated remote gateway: backend ready, but WebSocket/API auth rejected
  • Closed#56888setup.runtime_check missing from _LONG_HANDLERS — blocks WS read loop under GIL pressure, causing false "gateway disconnect / runtime not ready" (#50005 residual)
  • Closed#58875Desktop: React stack overflow when connecting to remote gateway backen
  • Closed#61457Desktop: remote gateway session cookie never persists after basic-auth login — immediate 401 no_cookie loop
  • Closed#65115Desktop: after a username/password gateway sign-in, the app keeps reporting "not signed in" until restart
  • Closed#66521[Bug]: Desktop Remote gateway shows legacy session-token field, not username/password Sign In — can't connect to basic-auth backend
  • Closed#68517Desktop 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)
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#37747, #38743, #65885, #71714, #83567, #123008
Closed issues
23
Attempts
10 PRs closed without merging
Fix link checked
27 Sep 2026

For your agent

The same facts, typed.