fix(runtime): keep the listener on loopback for a "This computer only" pairing link

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.
This commit is contained in:
OrcaWin
2026-08-03 22:35:54 -04:00
parent a69ca6d5b7
commit 75b53f7039
2 changed files with 111 additions and 14 deletions
+93 -1
View File
@@ -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<typeof NodeOs>()),
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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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()
}
})
})
+18 -13
View File
@@ -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
}
}
}