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
- 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.
Next step: read #37747, then check whether your report is one of the 2 still open.
Read #37747Pick up a Lab taskThese 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
Open PRs2
Fixes on record
-
#37747 fix(dashboard): allow desktop websocket origins on remote binds
files:
hermes_cli/web_server.py,scripts/release.py,tests/hermes_cli/test_dashboard_auth_ws_auth.pyShow 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
files:
hermes_cli/config.py,hermes_cli/web_server.py,tests/hermes_cli/test_config.py,tui_gateway/ws.py,web/src/pages/ChatPage.tsxShow 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)
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
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
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
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.00means 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