From 75b53f70396ec19f0d6dc5d1af2c1909667b0212 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:35:54 -0400 Subject: [PATCH] fix(runtime): keep the listener on loopback for a "This computer only" pairing link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime pairing URL handler called ensureNetworkExposure() for every offer, including one whose advertised address is loopback. Settings -> "Share this Orca server" offers a "This computer only" radio that pairs against 127.0.0.1 precisely so nothing is reachable off-host, yet choosing it rebound the WebSocket listener from 127.0.0.1 to 0.0.0.0 — and the widen never narrows back, so the runtime stayed exposed to the whole LAN for the rest of the process after the user picked the option that exists to avoid exactly that. Gate the widen on the advertised address: only a non-loopback endpoint (LAN, Tailscale, custom host) needs a listener reachable off this machine, so those paths keep widening exactly as STA-2370 intended. A loopback link is already served by the loopback listener, so it now mints without touching the bind. Classification reuses the shared pairing-address classifier, which also covers localhost, ::1 and 127.0.0.0/8 typed into the custom-address field. Tests: a real OrcaRuntimeRpcServer driven through the IPC handler asserts the bind host stays 127.0.0.1 after a local link and flips to 0.0.0.0 after a LAN one, plus handler-level cases for 127.0.0.1 / localhost / ::1. --- src/main/ipc/mobile.test.ts | 94 ++++++++++++++++++++++++++++++++++++- src/main/ipc/mobile.ts | 31 +++++++----- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 54458541f92..ab787f6b2d6 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeOs from 'node:os' const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -17,12 +18,21 @@ vi.mock('qrcode', () => ({ } })) -vi.mock('os', () => ({ +// Why: only the interface enumeration is faked; the real `os` stays available for the integration +// test below, which needs tmpdir() for a real runtime's user data directory. +vi.mock('os', async (importOriginal) => ({ + ...(await importOriginal()), networkInterfaces: networkInterfacesMock })) +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { registerMobileHandlers } from './mobile' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { WebSocketTransport } from '../runtime/rpc/ws-transport' describe('registerMobileHandlers', () => { const handlers = new Map unknown>() @@ -319,6 +329,32 @@ describe('registerMobileHandlers', () => { ) }) + it.each(['127.0.0.1', 'localhost', '::1'])( + 'mints a %s runtime link without widening the listener off-host', + async (address) => { + const createPairingOffer = vi.fn().mockReturnValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: `http://${address}:6768/web-index.html?pairing=runtime`, + endpoint: `ws://${address}:6768`, + deviceId: 'runtime-local' + }) + const ensureNetworkExposure = vi.fn().mockResolvedValue(undefined) + const rpcServer = { createPairingOffer, ensureNetworkExposure } + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { address, rotate: true }) + ).resolves.toMatchObject({ available: true, deviceId: 'runtime-local' }) + + // Why: "This computer only" exists so nothing is exposed off-host — a loopback link is already + // served by the loopback listener, and the widen is one-way for the life of the process. + expect(ensureNetworkExposure).not.toHaveBeenCalled() + expect(createPairingOffer).toHaveBeenCalled() + } + ) + it('reports the runtime pairing url unavailable and mints no offer when the widen fails', async () => { const createPairingOffer = vi.fn() // Why: STA-2370 — a failed widen leaves the listener on loopback, so the handler must NOT advertise a @@ -496,3 +532,59 @@ describe('registerMobileHandlers', () => { expect(runPowerShell).not.toHaveBeenCalled() }) }) + +describe('runtime pairing bind host', () => { + const handlers = new Map unknown>() + + const wsTransportOf = (server: OrcaRuntimeRpcServer): WebSocketTransport | undefined => + (server['activeTransports'] as unknown[]).find( + (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport + ) + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + networkInterfacesMock.mockReset() + networkInterfacesMock.mockReturnValue({}) + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + it('keeps a real listener on loopback for a local link and widens it only for an off-host one', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + registerMobileHandlers(server) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true + }) + ).resolves.toMatchObject({ available: true }) + // Why: the exposure regression — picking "This computer only" used to rebind the listener to + // 0.0.0.0 and leave it there, publishing the runtime to the whole LAN for the rest of the process. + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true + }) + ).resolves.toMatchObject({ available: true }) + // Why: the LAN/Tailscale choice still opts in — a client off this host cannot reach a loopback bind. + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ff4c5c6d3ec..ca343137901 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,6 +2,7 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' @@ -184,19 +185,23 @@ export function registerMobileHandlers( // Why: STA-2370 — generating a runtime pairing offer is the user's explicit opt-in to remote // reach, so widen the loopback listener before advertising its LAN endpoint. If the widen fails the // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. - try { - await rpcServer.ensureNetworkExposure() - } catch (error) { - console.error( - '[mobile] Network exposure failed while creating a runtime pairing offer:', - error - ) - // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so - // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. - return { - available: false as const, - reason: 'network_exposure_failed' as const, - guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + // A loopback advertised address ("This computer only") is the opposite opt-in: the loopback listener + // already serves it, and the widen never narrows back, so it must not expose the runtime off-host. + if (classifyRemotePairingHostname(ip) !== 'loopback') { + try { + await rpcServer.ensureNetworkExposure() + } catch (error) { + console.error( + '[mobile] Network exposure failed while creating a runtime pairing offer:', + error + ) + // Why: STA-2370 — carry the specific reason/guidance to the renderer (mirrors the mobile-QR path) so + // a widen failure is distinguishable from a missing address, not collapsed into a bare unavailable. + return { + available: false as const, + reason: 'network_exposure_failed' as const, + guidance: NETWORK_EXPOSURE_FAILED_GUIDANCE + } } }