Bug
Hermes Desktop remote mode can report the remote backend as ready, then fail during renderer boot with:
Could not connect to Hermes gateway
This happens when the dashboard is exposed on an explicit non-loopback address, for example a Tailscale/LAN bind such as --host 100.64.x.y --insecure --tui.
Root cause
There are two related gaps in the remote path:
- Desktop remote readiness checks
/api/status, which is public. That can pass even when the saved dashboard session token is stale or unusable for the protected endpoints and WebSocket path the renderer actually needs.
- When the packaged Electron renderer opens
/api/ws, Chromium sends a non-web WebSocket Origin such as file:// or null. _ws_host_origin_is_allowed() currently allows those non-web origins only for loopback binds. For an explicit non-loopback bind, the token is accepted first, but the WebSocket is then rejected by the Host/Origin guard.
The result is a confusing split-brain state: desktop.log says Remote Hermes backend is ready, but the UI shows the boot failure overlay.
Reproduction
-
Run a dashboard on a Tailscale/LAN address:
hermes dashboard --host 100.64.0.10 --port 9119 --insecure --no-open --tui
-
Configure Hermes Desktop remote mode to http://100.64.0.10:9119 with the injected dashboard session token.
-
Start Hermes Desktop.
Expected: the desktop renderer connects to ws://100.64.0.10:9119/api/ws?token=... and boots.
Actual: /api/status passes, then gateway.connect() fails because the WebSocket upgrade is rejected when Electron sends Origin: file:// or Origin: null.
Security expectation
The fix should not relax normal browser DNS-rebinding protection:
- Matching
http(s) origins should continue to be required for web origins.
- Mismatched web origins such as
http://localhost:9119 against a Tailscale host should still be rejected.
- OAuth-gated public dashboards should keep rejecting non-web origins.
- The non-web origin allowance should only apply to the legacy token-authenticated remote desktop/dashboard path, after token auth has already succeeded.
Related local evidence
On a Tailscale-bound dashboard, a direct WebSocket test reproduced the exact split:
- no
Origin: open
Origin: http://<tailscale-host>:9119: open
Origin: file://: rejected before fix
Origin: null: rejected before fix
- mismatched
Origin: http://localhost:9119: rejected as expected
Bug
Hermes Desktop remote mode can report the remote backend as ready, then fail during renderer boot with:
This happens when the dashboard is exposed on an explicit non-loopback address, for example a Tailscale/LAN bind such as
--host 100.64.x.y --insecure --tui.Root cause
There are two related gaps in the remote path:
/api/status, which is public. That can pass even when the saved dashboard session token is stale or unusable for the protected endpoints and WebSocket path the renderer actually needs./api/ws, Chromium sends a non-web WebSocketOriginsuch asfile://ornull._ws_host_origin_is_allowed()currently allows those non-web origins only for loopback binds. For an explicit non-loopback bind, the token is accepted first, but the WebSocket is then rejected by the Host/Origin guard.The result is a confusing split-brain state:
desktop.logsaysRemote Hermes backend is ready, but the UI shows the boot failure overlay.Reproduction
Run a dashboard on a Tailscale/LAN address:
Configure Hermes Desktop remote mode to
http://100.64.0.10:9119with the injected dashboard session token.Start Hermes Desktop.
Expected: the desktop renderer connects to
ws://100.64.0.10:9119/api/ws?token=...and boots.Actual:
/api/statuspasses, thengateway.connect()fails because the WebSocket upgrade is rejected when Electron sendsOrigin: file://orOrigin: null.Security expectation
The fix should not relax normal browser DNS-rebinding protection:
http(s)origins should continue to be required for web origins.http://localhost:9119against a Tailscale host should still be rejected.Related local evidence
On a Tailscale-bound dashboard, a direct WebSocket test reproduced the exact split:
Origin: openOrigin: http://<tailscale-host>:9119: openOrigin: file://: rejected before fixOrigin: null: rejected before fixOrigin: http://localhost:9119: rejected as expected