import { pairedRuntimeConnectionFromOffer } from './paired-runtime-connection' import { createServer, type Server, type Socket } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { generateKeyPair, publicKeyToBase64 } from './e2ee-crypto' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { isRecoverableRemoteRuntimeConnectionError } from './remote-runtime-client-error-classification' import { REMOTE_RUNTIME_CONNECT_TIMEOUT_MS, WS_HANDSHAKE_TIMEOUT_MESSAGE, isRemoteRuntimeConnectTimeout, remoteRuntimeConnectFailureMessage, remoteRuntimeConnectOptions } from './remote-runtime-connect-bound' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' import { withRemoteRuntimeTailscaleHint } from './remote-runtime-tailscale-hint' const servers = new Set() const sockets = new Set() const handshakeTimeoutError = (): Error => new Error(WS_HANDSHAKE_TIMEOUT_MESSAGE) afterEach(async () => { for (const socket of sockets) { socket.destroy() } sockets.clear() await Promise.all( [...servers].map( (server) => new Promise((resolve) => { server.close(() => resolve()) }) ) ) servers.clear() }) /** * Accepts TCP but never answers the HTTP upgrade, which is the same silent * stall a black-holed host produces and is bounded by the same `ws` timer. */ async function listenSilentUpgradeServer(): Promise { const server = createServer((socket) => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) }) servers.add(server) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') { throw new Error('expected a TCP address') } return `ws://127.0.0.1:${address.port}` } describe('remote runtime connect bound', () => { it('bounds the production connect with a finite handshake timeout', () => { const options = remoteRuntimeConnectOptions({ maxPayload: 1024 }) expect(Number.isFinite(options.handshakeTimeout)).toBe(true) expect(options.handshakeTimeout).toBe(REMOTE_RUNTIME_CONNECT_TIMEOUT_MS) expect(options.maxPayload).toBe(1024) }) it('reports an unanswered host as unreachable rather than as an empty result', async () => { const endpoint = await listenSilentUpgradeServer() const keyPair = generateKeyPair() const onError = vi.fn() const onTextFrame = vi.fn() const opened = openRemoteRuntimeWebSocket( pairedRuntimeConnectionFromOffer({ v: 2, endpoint, deviceToken: 'device-token', publicKeyB64: publicKeyToBase64(keyPair.publicKey) }), { onClose: vi.fn(), onError, onTextFrame }, 150 ) if (!opened.ok) { throw opened.error } await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1), { timeout: 5_000 }) // The bounded path was taken: a connect failure, not a silent empty answer. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: vi mock call args are untyped; this subscription's onError is only ever invoked with a RemoteRuntimeClientError. const error = onError.mock.calls[0][1] as RemoteRuntimeClientError expect(error.code).toBe('remote_runtime_unavailable') expect(error.message).toContain(endpoint) expect(error.message).toContain('unverifiable') expect(onTextFrame).not.toHaveBeenCalled() // Loss of contact is never evidence the host's work stopped. expect(error.message).not.toMatch(/\b(exited|gone|stopped|empty|no terminals)\b/i) // The subscribe IPC boundary drops `code`, so the renderer classifies this // message alone. Fatal there means `recovery.cancel()` and a dead-ended pane // instead of a retry, so the real produced message must still read recoverable. expect(isRecoverableRemoteRuntimeConnectionError({ message: error.message })).toBe(true) // ...and must still earn the Tailscale remedy, which is gated on the same phrase. expect(withRemoteRuntimeTailscaleHint(error.message, endpoint)).not.toBe(error.message) opened.socket.cleanup() opened.socket.ws.terminate() }) it('only calls an elapsed handshake a connect timeout', () => { expect(isRemoteRuntimeConnectTimeout(handshakeTimeoutError())).toBe(true) expect(isRemoteRuntimeConnectTimeout(new Error('connect ECONNREFUSED'))).toBe(false) expect(remoteRuntimeConnectFailureMessage(new Error('connect ECONNREFUSED'), 'ws://h')).toBe( 'Could not connect to the remote Orca runtime.' ) }) // Why: the message is the only carrier on the code-less paths (subscribe IPC, // web, mobile). Both gates below match a phrase, so a rewording silently turns // a retrying pane into a dead-ended one and drops the only actionable remedy. it('keeps the unreachable-host message inside both message gates', () => { const message = remoteRuntimeConnectFailureMessage( handshakeTimeoutError(), 'ws://desk.example.com:6768' ) expect(isRecoverableRemoteRuntimeConnectionError({ message })).toBe(true) // The shape Electron produces for a rejected ipcMain.handle, which keeps no code. expect( isRecoverableRemoteRuntimeConnectionError({ message: `Error invoking remote method 'runtimeEnvironments:subscribe': Error: ${message}` }) ).toBe(true) expect(withRemoteRuntimeTailscaleHint(message, 'ws://192.168.1.10:6768')).toContain( 'connect both devices to Tailscale' ) expect( withRemoteRuntimeTailscaleHint( remoteRuntimeConnectFailureMessage(handshakeTimeoutError(), 'wss://desk.tail1234.ts.net'), 'wss://desk.tail1234.ts.net' ) ).toContain('tailnet') }) // Why: the hint's idempotency check used to key on the word "tailscale" anywhere in the // message. Now that the message carries the endpoint, such a host would suppress the very // remedy it needs. it('still earns the hint when the endpoint itself contains the vendor name', () => { const endpoint = 'wss://tailscale-box.example.com:6768' const message = remoteRuntimeConnectFailureMessage(handshakeTimeoutError(), endpoint) expect(message).toContain('tailscale-box') expect(withRemoteRuntimeTailscaleHint(message, endpoint)).toContain( 'connect both devices to Tailscale' ) }) // Why: the endpoint arrives from a pasted pairing code, which is only length-capped. it('shows the endpoint origin only, never pasted credentials', () => { const message = remoteRuntimeConnectFailureMessage( handshakeTimeoutError(), 'wss://user:s3cret@desk.example.com:6768/path?token=abc' ) expect(message).toContain('wss://desk.example.com:6768') expect(message).not.toContain('s3cret') expect(message).not.toContain('token=abc') }) // Why: `isRemoteTerminalGoneMessage` in the pty transport substring-matches these tokens and // runs BEFORE the recoverable gate, and WHATWG URL accepts `_` in a host. An endpoint could // otherwise turn loss of contact into a terminal-gone verdict. it('never lets the endpoint smuggle a terminal-gone token into the message', () => { for (const host of ['terminal_gone.example', 'terminal_exited.example', 'no_connected_pty']) { const message = remoteRuntimeConnectFailureMessage( handshakeTimeoutError(), `ws://${host}:6768` ) expect(message).not.toMatch(/terminal_exited|terminal_gone|no_connected_pty/) // Still reads as a recoverable connect failure, so the pane keeps retrying. expect(isRecoverableRemoteRuntimeConnectionError({ message })).toBe(true) } // A well-formed host is still shown, so the redaction is not blanket. expect( remoteRuntimeConnectFailureMessage(handshakeTimeoutError(), 'ws://[fd7a:115c:a1e0::1]:6768') ).toContain('[fd7a:115c:a1e0::1]:6768') }) // Why: `ws` and `net` gate on a truthy timeout, so 0 would leave the connect unbounded. it('refuses a non-positive or non-finite bound and keeps the production default', () => { for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { expect(remoteRuntimeConnectOptions(undefined, value).handshakeTimeout).toBe( REMOTE_RUNTIME_CONNECT_TIMEOUT_MS ) } expect(remoteRuntimeConnectOptions(undefined, 150).handshakeTimeout).toBe(150) }) })