From 500cf537b7d0fc31345588b4498e99e86a0f46f8 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Thu, 4 Jun 2026 21:02:39 +0700 Subject: [PATCH] fix(desktop): validate live WebSocket in remote gateway connection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Test remote" button only checked HTTP GET /api/status, but the chat surface depends on the renderer opening a live WebSocket to /api/ws — a separate transport with separate server-side guards (Host/Origin checks, ws-ticket/token auth, peer-IP checks). A gateway could pass the HTTP check yet reject the WebSocket, so the test reported "reachable" while boot still failed with the opaque "Could not connect to Hermes gateway". testDesktopConnectionConfig now mirrors the renderer's connect: after the status check it opens the WS URL (token/local) or a freshly minted ws-ticket (OAuth) and confirms the upgrade is accepted and not immediately torn down by a post-handshake auth rejection. Failures surface an actionable message instead of a false-positive. The WS leg is skipped when the runtime lacks a global WebSocket so it never fails spuriously. --- apps/desktop/electron/main.cjs | 47 +++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index ded844ebe..5792b1dc2 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -27,6 +27,7 @@ const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') +const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -3747,18 +3748,42 @@ async function testDesktopConnectionConfig(input = {}) { // for local we fall back to the resolved/started backend. let baseUrl let token = null + let authMode = 'token' if (config.mode === 'remote') { baseUrl = normalizeRemoteBaseUrl(config.remote.url) - if ((config.remote.authMode || 'token') !== 'oauth') { + authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token' + if (authMode !== 'oauth') { token = decryptDesktopSecret(config.remote.token) } } else { const remote = (await resolveRemoteBackend()) || (await startHermes()) baseUrl = remote.baseUrl token = remote.token + authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' } const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 }) + // The HTTP status check above proves the backend is reachable, but the chat + // surface only works once the renderer's live WebSocket to ``/api/ws`` + // connects — a separate transport with separate server-side guards (Host/ + // Origin, ws-ticket/token auth). Validating only the HTTP side produced a + // false-positive "reachable" while the real boot still failed with "Could not + // connect to Hermes gateway". Mirror the renderer's connect here so the test + // reflects the full path the app actually uses. + const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token) + // Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an + // older Electron/Node never fails the test spuriously); Electron's main + // process ships a global WebSocket on every supported version. + if (wsUrl && typeof globalThis.WebSocket === 'function') { + const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket }) + if (!probe.ok) { + throw new Error( + `Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` + + 'The HTTP check can pass while the WebSocket is blocked by a proxy, firewall, or gateway auth/origin guard.' + ) + } + } + return { ok: true, baseUrl, @@ -3766,6 +3791,26 @@ async function testDesktopConnectionConfig(input = {}) { } } +// Build the WS URL the renderer would connect with, so the connection test can +// exercise the same transport. OAuth gateways need a freshly minted single-use +// ticket; token/local gateways carry a long-lived token in the query string. A +// null return means we can't form a credentialed URL (e.g. OAuth without a live +// session) and the WS leg of the test is skipped rather than failing spuriously. +async function resolveTestWsUrl(baseUrl, authMode, token) { + if (authMode === 'oauth') { + try { + const ticket = await mintGatewayWsTicket(baseUrl) + return buildGatewayWsUrlWithTicket(baseUrl, ticket) + } catch { + return null + } + } + if (!token) { + return null + } + return buildGatewayWsUrl(baseUrl, token) +} + function resetBootProgressForReconnect() { updateBootProgress( {