diff --git a/apps/desktop/electron/gateway-ws-probe.cjs b/apps/desktop/electron/gateway-ws-probe.cjs new file mode 100644 index 000000000..6ed1280be --- /dev/null +++ b/apps/desktop/electron/gateway-ws-probe.cjs @@ -0,0 +1,188 @@ +/** + * Live WebSocket validation for the remote-gateway "Test remote" button. + * + * Background: the desktop boot does two independent things to a remote gateway: + * + * 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header) + * to confirm the backend is up. This is what "Test remote" historically + * checked, and what the boot logs print as "Remote Hermes backend is + * ready". + * 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a + * query param) via ``gateway.connect()``. The chat surface only works once + * THIS succeeds. + * + * Those two paths use different processes, transports, and credentials, and the + * server applies extra guards to the WS upgrade that the HTTP status route never + * sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway + * can pass the HTTP status check yet reject the WebSocket — which surfaces to + * the user as a green "Test remote" followed by an opaque "Could not connect to + * Hermes gateway" on the boot overlay. + * + * This module performs the second half of the check: it actually opens the WS + * URL and confirms the upgrade is accepted (and isn't immediately torn down by + * a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the + * unit tests can drive the handshake without a real socket; in production the + * caller passes the Node/Electron global ``WebSocket``. + */ + +const DEFAULT_CONNECT_TIMEOUT_MS = 10_000 +// After the upgrade is accepted, a gateway that rejects the credential +// post-handshake closes the socket almost immediately. Wait a short grace +// window: a frame (gateway.ready) or a still-open socket means success; an +// early close means the upgrade was accepted but the session was refused. +const DEFAULT_READY_GRACE_MS = 750 + +/** + * Attempt a live WebSocket connection and classify the outcome. + * + * @param {string} wsUrl - Fully-formed ws(s):// URL including the credential. + * @param {object} [options] + * @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor. + * @param {number} [options.connectTimeoutMs] + * @param {number} [options.readyGraceMs] + * @returns {Promise<{ ok: boolean, reason?: string }>} + */ +function probeGatewayWebSocket(wsUrl, options = {}) { + const WebSocketImpl = options.WebSocketImpl + const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS + const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS + + if (typeof WebSocketImpl !== 'function') { + return Promise.resolve({ + ok: false, + reason: 'WebSocket is not available in this runtime.' + }) + } + + return new Promise(resolve => { + let settled = false + let opened = false + let connectTimer = null + let graceTimer = null + let socket + + const clearTimers = () => { + if (connectTimer !== null) { + clearTimeout(connectTimer) + connectTimer = null + } + if (graceTimer !== null) { + clearTimeout(graceTimer) + graceTimer = null + } + } + + const finish = result => { + if (settled) return + settled = true + clearTimers() + try { + socket?.close?.() + } catch { + // ignore — best effort teardown + } + resolve(result) + } + + try { + socket = new WebSocketImpl(wsUrl) + } catch (error) { + finish({ + ok: false, + reason: error instanceof Error ? error.message : String(error) + }) + return + } + + const onOpen = () => { + if (settled) return + opened = true + // Upgrade accepted. Give the server a brief window to reject the + // credential post-handshake (early close) before declaring success. + graceTimer = setTimeout(() => { + finish({ ok: true }) + }, readyGraceMs) + } + + const onMessage = () => { + // Any frame means the gateway accepted us and is talking — unambiguous + // success, no need to wait out the grace window. + finish({ ok: true }) + } + + const onError = event => { + finish({ + ok: false, + reason: extractErrorReason(event) || 'WebSocket connection failed.' + }) + } + + const onClose = event => { + if (settled) return + if (opened) { + // Opened, then closed inside the grace window: the upgrade was accepted + // but the session was refused (e.g. ws-ticket/token rejected, or a + // server-side Host/Origin guard tripped after accept). + finish({ + ok: false, + reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).') + }) + return + } + finish({ + ok: false, + reason: closeReason(event, 'The gateway closed the WebSocket before it opened.') + }) + } + + addListener(socket, 'open', onOpen) + addListener(socket, 'message', onMessage) + addListener(socket, 'error', onError) + addListener(socket, 'close', onClose) + + if (connectTimeoutMs > 0) { + connectTimer = setTimeout(() => { + finish({ + ok: false, + reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.` + }) + }, connectTimeoutMs) + } + }) +} + +function addListener(socket, type, handler) { + if (typeof socket.addEventListener === 'function') { + socket.addEventListener(type, handler) + return + } + // Node's global WebSocket implements addEventListener; this fallback keeps the + // helper usable with the `ws` package's EventEmitter shape too. + if (typeof socket.on === 'function') { + socket.on(type, handler) + } +} + +function extractErrorReason(event) { + if (!event) return '' + if (event instanceof Error) return event.message + const err = event.error || event.message + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + return '' +} + +function closeReason(event, fallback) { + const code = event && typeof event.code === 'number' ? event.code : null + const reason = event && typeof event.reason === 'string' ? event.reason.trim() : '' + if (code && reason) return `${fallback} (code ${code}: ${reason})` + if (code) return `${fallback} (code ${code})` + if (reason) return `${fallback} (${reason})` + return fallback +} + +module.exports = { + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_READY_GRACE_MS, + probeGatewayWebSocket +} diff --git a/apps/desktop/electron/gateway-ws-probe.test.cjs b/apps/desktop/electron/gateway-ws-probe.test.cjs new file mode 100644 index 000000000..810494fdc --- /dev/null +++ b/apps/desktop/electron/gateway-ws-probe.test.cjs @@ -0,0 +1,122 @@ +/** + * Tests for electron/gateway-ws-probe.cjs. + * + * Run with: node --test electron/gateway-ws-probe.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * The probe drives a real WebSocket handshake for the "Test remote" button. + * Here we inject a fake socket so we can deterministically replay each handshake + * outcome (open, frame, error, early close, never-opens) without a network. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') + +// Minimal WebSocket double: records listeners synchronously (the probe attaches +// them in its executor) and exposes emit() so the test can replay events. +function makeFakeWs() { + const instances = [] + class FakeWs { + constructor(url) { + this.url = url + this.listeners = {} + this.closed = false + instances.push(this) + } + addEventListener(type, fn) { + ;(this.listeners[type] ||= []).push(fn) + } + close() { + this.closed = true + } + emit(type, event) { + for (const fn of this.listeners[type] || []) fn(event) + } + } + return { FakeWs, instances } +} + +const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 } + +test('probe resolves ok when the socket opens and stays open', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + const result = await promise + assert.deepEqual(result, { ok: true }) + assert.equal(instances[0].closed, true) +}) + +test('probe resolves ok immediately when a frame arrives', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 1_000, + readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer + }) + instances[0].emit('open') + instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) + const result = await promise + assert.deepEqual(result, { ok: true }) +}) + +test('probe fails when the socket errors before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('error', { message: 'ECONNREFUSED' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /ECONNREFUSED/) +}) + +test('probe fails when the gateway closes before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('close', { code: 1006 }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /before it opened/) + assert.match(result.reason, /1006/) +}) + +test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + instances[0].emit('close', { code: 4403, reason: 'forbidden' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /credential rejected/) + assert.match(result.reason, /4403/) + assert.match(result.reason, /forbidden/) +}) + +test('probe times out when the socket never opens', async () => { + const { FakeWs } = makeFakeWs() + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 20, + readyGraceMs: 10 + }) + assert.equal(result.ok, false) + assert.match(result.reason, /Timed out/) +}) + +test('probe fails gracefully when the constructor throws', async () => { + class ThrowingWs { + constructor() { + throw new Error('bad url') + } + } + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST }) + assert.equal(result.ok, false) + assert.match(result.reason, /bad url/) +}) + +test('probe reports unavailable when no WebSocket implementation is provided', async () => { + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined }) + assert.equal(result.ok, false) + assert.match(result.reason, /not available/) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 10703c127..372022949 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",