Skip to content

Remote Gateway connection loop — "Could Not Connect to Hermes Gateway" #520

Description

@jojo2a

Remote gateway mode unusable: renderer WebSocket (/api/ws) is rejected by the dashboard, causing an infinite "Hermes couldn't start" reset loop

Summary

When Hermes Desktop is configured to use a remote gateway (Settings → Gateway → Remote, pointing at a self-hosted hermes dashboard reachable over the network), the app enters an infinite reset loop:

[boot] Connecting to remote Hermes backend at http://<host>:9119
[boot] Remote Hermes backend is ready
[bootstrap] reset requested by renderer; clearing latched failure
... (repeats forever)

The UI shows "Hermes couldn't start — Could not connect to Hermes gateway" with Retry / Repair install / Use local gateway.

The HTTP side works (the boot probe to /api/status succeeds, and authenticated REST calls succeed). The failure is the renderer's WebSocket to /api/ws, which the gateway closes immediately. The desktop interprets that as "backend dead" and resets, forever.

After tracing both sides (desktop app.asar main + the hermes_cli dashboard server), there are two independent server-side conditions that each break remote mode. The desktop satisfies neither when talking to a normal self-hosted gateway, and the second one is unsatisfiable for any non-loopback bind.

Environment

  • Hermes Desktop: v0.15.1 (Windows x64)
  • Gateway: v0.15.1, official Docker image nousresearch/hermes-agent:latest, started by its s6 service as:
    hermes dashboard --host 0.0.0.0 --port 9119 --no-open --insecure
    
    (HERMES_DASHBOARD=1, HERMES_DASHBOARD_HOST=0.0.0.0, HERMES_DASHBOARD_INSECURE=1, HERMES_DASHBOARD_SESSION_TOKEN=<set>)
  • Desktop reaches the gateway over the LAN / Tailscale (e.g. http://100.x.x.x:9119).
  • connection.json is mode: "remote" with a valid remote.url + encrypted remote.token.

Repro

  1. Run a gateway reachable off-localhost: hermes dashboard --host 0.0.0.0 --port 9119 --insecure (token via HERMES_DASHBOARD_SESSION_TOKEN).
  2. In Hermes Desktop, set Gateway → Remote, URL = http://<lan-or-tailscale-ip>:9119, token = that session token.
  3. Restart the app.

Expected: desktop connects and works, like local mode.
Actual: infinite "Remote Hermes backend is ready" → "reset requested by renderer" loop; UI stuck on "Could not connect to Hermes gateway".

Root cause

Condition 1 (blocking): the JSON-RPC WebSocket /api/ws is gated behind --tui

The renderer connects to ws://<host>/api/ws?token=<session_token> (built by buildGatewayWsUrl in the desktop's main.cjs). In hermes_cli/web_server.py, every dashboard WebSocket route starts with:

@app.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None:
    if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
        await ws.close(code=4403)
        return
    ...

_DASHBOARD_EMBEDDED_CHAT_ENABLED is only set True when the dashboard is launched with --tui / HERMES_DASHBOARD_TUI=1 (start_server(embedded_chat=...)). The official Docker image's s6 launch script (s6-rc.d/dashboard/run) runs hermes dashboard --host --port --no-open [--insecure] and never passes --tui nor reads HERMES_DASHBOARD_TUI. So /api/ws closes with 4403 for every client, regardless of token validity.

Confirmed empirically (token redacted): the correct HERMES_DASHBOARD_SESSION_TOKEN authenticates HTTP fine, while the WS is rejected the same way with a correct or a deliberately wrong token:

X-Hermes-Session-Token header:
  /api/status          -> 200
  /api/sessions        -> 200
  /api/config          -> 200
WS /api/ws?token=<correct>  -> 403
WS /api/ws?token=<wrong>    -> 403   (same: rejected before the token is even checked)

This is the primary blocker. The HTTP boot probe (/api/status, a public path) succeeds, so the desktop logs "Remote Hermes backend is ready" right before the WS fails. The desktop never surfaces the WS close code (4403); it just resets.

Condition 2 (blocking by design): WS origin guard rejects the desktop's file:// origin on any non-loopback bind

Even with --tui enabled, _ws_host_origin_is_allowed in web_server.py does:

origin = ws.headers.get("origin", "")
if not origin:
    return True
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
    # Electron renderer loads over file://, so Origin is "null"/file://
    return bound_host.lower() in _LOOPBACK_HOST_VALUES
...

The packaged desktop loads its renderer over file:// (main.cjs: loadURL(pathToFileURL(resolveRendererIndex())...)), so the renderer's WebSocket handshake carries Origin: null. The guard then allows that only if the server bound to loopback. A remote gateway must bind a non-loopback interface (0.0.0.0 / LAN / Tailscale) to be reachable, so the origin is rejected.

The two requirements are mutually exclusive: reachable-off-localhost requires a non-loopback bind, but a non-loopback bind rejects the renderer's file:// origin. The comment in _ws_host_origin_is_allowed states this is intentional ("Public/gated binds have no legitimate non-web client"), which means remote mode is effectively designed only for a backend the desktop reaches as loopback (e.g. via an SSH tunnel) and that runs with the embedded chat enabled.

Why local mode works but remote doesn't

In local mode the desktop spawns its own backend with the embedded chat enabled and binds loopback, so both conditions are satisfied (tui_gateway.ws: ws accepted). Remote mode reuses the same renderer WS path against a gateway that has the chat disabled and is bound non-loopback, so both conditions fail.

Impact

Remote gateway mode is documented/exposed in the UI but cannot connect to a normal self-hosted gateway. Users burn hours assuming a token/network problem because the error ("Could not connect to Hermes gateway") and the logs ("Remote Hermes backend is ready" then reset) point away from the real cause (a 4403 WebSocket close they never see).

Suggested fixes (any subset)

  1. Open the gateway WS from the desktop main process instead of the renderer. A Node ws client sends no Origin header, which _ws_host_origin_is_allowed already allows (if not origin: return True), and frames can be relayed to the renderer over IPC. This removes the file:// origin problem entirely.
  2. Don't gate the JSON-RPC sidecar WS behind --tui for remote/headless gateways, or make the Docker image set HERMES_DASHBOARD_TUI=1 (and document HERMES_DASHBOARD_TUI=1 as required for Desktop remote mode).
  3. Surface the WS close code in the desktop. Closing 4403/4401 should produce an actionable error ("gateway rejected the chat WebSocket: embedded chat disabled / origin not allowed / bad token") instead of a silent infinite reset loop.
  4. Implement the documented ?ticket= flow (POST /api/auth/ws-ticket?ticket= on the WS) in the desktop, so gated (non---insecure) remote gateways are also supported. Today the desktop only ever sends ?token=, which is loopback-only.
  5. Docs: clarify that remote mode currently requires the gateway to be reached as loopback (tunnel) and started with the embedded chat enabled.

Workarounds (for other users hitting this)

  • Reach the gateway through an SSH tunnel so the desktop connects to http://127.0.0.1:<port> (loopback Host + loopback client + file:// origin tolerated), and run the gateway with HERMES_DASHBOARD_TUI=1.
  • Or keep the 0.0.0.0 bind + HERMES_DASHBOARD_TUI=1 and run a small local proxy that rewrites the WS Origin to http://localhost.
  • Or use a browser-based UI against the gateway's OpenAI-compatible API server instead of the desktop.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions