diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 54458541f92..9f81c3a6516 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 { beforeEach, describe, expect, it, vi, type Mock } 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>() @@ -308,7 +318,8 @@ describe('registerMobileHandlers', () => { address: '100.64.1.20', rotate: true, name: expect.stringMatching(/^Runtime /), - scope: 'runtime' + scope: 'runtime', + reach: 'network' }) // Why: STA-2370 — generating a runtime offer must widen the listener BEFORE advertising its endpoint, // or a client could read the URL and connect before the LAN bind exists. Assert call ORDER, not just @@ -319,6 +330,111 @@ describe('registerMobileHandlers', () => { ) }) + const stubRuntimePairingServer = ( + address: string + ): { createPairingOffer: Mock; ensureNetworkExposure: Mock } => ({ + createPairingOffer: vi.fn().mockReturnValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: `http://${address}/web-index.html?pairing=runtime`, + endpoint: `ws://${address}`, + deviceId: 'runtime-local' + }), + ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) + }) + + // Why: every loopback form the address field accepts must behave identically once the user declared + // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on + // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. + it.each([ + '127.0.0.1', + 'localhost', + '::1', + '127.0.0.1:8443', + 'localhost:8443', + '[::1]:6768', + 'ws://127.0.0.1:6768' + ])('mints a %s runtime link without widening the listener off-host', async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'this-computer' + }) + ).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(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'this-computer' }) + ) + }) + + // Why: the Custom field is documented as "SSH tunnel, reverse proxy, or custom hostname", so a loopback + // front-end there still needs the listener open behind it — gating the widen on the address SHAPE broke + // `ssh -L 8443::6768` (sshd dials the LAN address on this host and gets refused). + it.each(['127.0.0.1:8443', 'localhost', 'ws://127.0.0.1:6768'])( + 'widens the listener for a custom %s address that fronts a tunnel', + async (address) => { + const rpcServer = stubRuntimePairingServer(address) + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address, + rotate: true, + reach: 'network' + }) + ).resolves.toMatchObject({ available: true }) + + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + } + ) + + it('widens for a loopback address when the caller declares no reach', async () => { + const rpcServer = stubRuntimePairingServer('127.0.0.1:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { address: '127.0.0.1', rotate: true }) + ).resolves.toMatchObject({ available: true }) + + // Why: only an explicit "This computer only" declines remote reach; an undeclared caller keeps the + // pre-STA-2370 opt-in behavior rather than silently minting a link the widen never backed. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + }) + + it('widens when a this-computer reach carries an off-host address', async () => { + const rpcServer = stubRuntimePairingServer('192.168.1.5:6768') + + registerMobileHandlers(rpcServer as never) + + await expect( + handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '192.168.1.5', + rotate: true, + reach: 'this-computer' + }) + ).resolves.toMatchObject({ available: true }) + + // Why: the declared reach and the advertised address disagree; honoring the declaration would mint a + // LAN link with no LAN listener behind it, so the address it actually publishes wins. + expect(rpcServer.ensureNetworkExposure).toHaveBeenCalled() + expect(rpcServer.createPairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ reach: 'network' }) + ) + }) + 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 +612,127 @@ 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) + }) + }) + + const startServer = async (userDataPath: string): Promise => { + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + return server + } + + 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 = await startServer(userDataPath) + + 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, + reach: 'this-computer' + }) + ).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, + reach: 'network' + }) + ).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() + } + }) + + // Why: the whole guarantee is worthless if it lasts one process — the local web client authenticating + // marks its grant lastSeenAt > 0, which used to make the NEXT launch bind every interface. + it('still binds loopback on the next launch after the local link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + let deviceId: string + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '127.0.0.1', + rotate: true, + reach: 'this-computer' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + deviceId = offer.deviceId + // Exactly what MobileSocketWiring does for every authenticated socket, local browser included. + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + expect( + relaunched + .getDeviceRegistry() + ?.listDevices() + .some((device) => device.deviceId === deviceId && device.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('127.0.0.1') + } finally { + await relaunched.stop() + } + }) + + it('binds all interfaces on the next launch after a network link has been used', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = await startServer(userDataPath) + try { + registerMobileHandlers(server) + const offer = (await handlers.get('mobile:getRuntimePairingUrl')?.(null, { + address: '100.64.1.20', + rotate: true, + reach: 'network' + })) as { available: true; deviceId: string } + expect(offer.available).toBe(true) + server.getDeviceRegistry()?.updateLastSeen(offer.deviceId) + } finally { + await server.stop() + } + + const relaunched = await startServer(userDataPath) + try { + // Why: the reconnect widen must survive — a client that really did connect from off-host has to + // find the listener at launch without the user re-pairing. + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ff4c5c6d3ec..35584f951a4 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,9 +2,12 @@ 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 type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' +import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' @@ -77,6 +80,18 @@ function getDefaultPairingAddress(): string | null { return ifaces.length > 0 ? ifaces[0]!.address : null } +// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it +// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise +// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address +// that fronts an SSH tunnel or reverse proxy, still opts in. +function servesThisComputerOnly(reach: RuntimePairingReach | undefined, address: string): boolean { + if (reach !== 'this-computer') { + return false + } + const hostname = resolveAdvertisedPairingHostname(address) + return hostname !== null && classifyRemotePairingHostname(hostname) === 'loopback' +} + function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { return { deviceId: device.deviceId, @@ -175,7 +190,7 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getRuntimePairingUrl', - async (_event, args?: { address?: string; rotate?: boolean }) => { + async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => { const ip = args?.address ?? getDefaultPairingAddress() if (!ip) { return { available: false as const } @@ -184,19 +199,24 @@ 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 + // "This computer only" is the opposite opt-in: the loopback listener already serves it, and the widen + // never narrows back, so that pick alone must not expose the runtime off-host. + const thisComputerOnly = servesThisComputerOnly(args?.reach, ip) + if (!thisComputerOnly) { + 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 + } } } @@ -206,7 +226,10 @@ export function registerMobileHandlers( address: ip, rotate: args?.rotate, name: `Runtime ${new Date().toLocaleDateString()}`, - scope: 'runtime' + scope: 'runtime', + // Why: a grant that only ever pointed at loopback must not make the next launch bind every + // interface when its local client reconnects (that would restore the exposure one restart later). + reach: thisComputerOnly ? 'this-computer' : 'network' }) if (!offer.available) { return { available: false as const } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 959b52c9474..7de8af08fde 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -10,6 +10,7 @@ import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' export type { DeviceScope } @@ -22,6 +23,9 @@ export type DeviceEntry = { lastSeenAt: number relayBinding?: RelayDeviceBinding mobilePairingConnectionMode?: MobilePairingConnectionMode + // Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its + // client connects, so the bind decision must be able to tell it apart from a LAN/phone grant. + pairingReach?: RuntimePairingReach } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -52,14 +56,19 @@ export class DeviceRegistry { this.load() } - addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { - return this.createAndPersistDevice(this.devices, name, scope) + addDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { + return this.createAndPersistDevice(this.devices, name, scope, pairingReach) } private createAndPersistDevice( existingDevices: DeviceEntry[], name: string, - scope: DeviceScope + scope: DeviceScope, + pairingReach: RuntimePairingReach ): DeviceEntry { const entry: DeviceEntry = { deviceId: randomUUID(), @@ -67,7 +76,8 @@ export class DeviceRegistry { token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), - lastSeenAt: 0 + lastSeenAt: 0, + pairingReach } const nextDevices = [...existingDevices, entry] // Why: a credential is not valid until its durable registry write succeeds. @@ -82,12 +92,32 @@ export class DeviceRegistry { // copy-button flow that encourages regeneration) leaves an orphaned token // forever. Returns an existing never-scanned entry if present; otherwise // mints a new one and drops any stale pending entries. - getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + getOrCreatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope) if (existing) { - return existing + // Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it, + // or a link already handed out for off-host use would stop being served after the next launch. + return pairingReach === 'network' && existing.pairingReach === 'this-computer' + ? this.setPairingReach(existing, 'network') + : existing } - return this.addDevice(name, scope) + return this.addDevice(name, scope, pairingReach) + } + + private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry { + const updated: DeviceEntry = { ...existing, pairingReach } + const nextDevices = this.devices.map((device) => + device.deviceId === existing.deviceId ? updated : device + ) + // Why: persist before the memory swap so a failed write cannot leave the bind decision reading a + // reach that never reached disk. + this.save(nextDevices) + this.devices = nextDevices + return updated } // Why: explicit rotation path for "Regenerate QR" — invalidates any @@ -96,9 +126,13 @@ export class DeviceRegistry { // this, getOrCreatePendingDevice keeps returning the same token forever // until a phone actually pairs, so users have no way to revoke a leaked // pre-pairing token. - rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry { + rotatePendingDevice( + name: string, + scope: DeviceScope = 'mobile', + pairingReach: RuntimePairingReach = 'network' + ): DeviceEntry { const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) - return this.createAndPersistDevice(retainedDevices, name, scope) + return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) } removeDevice(deviceId: string): boolean { @@ -197,7 +231,10 @@ export class DeviceRegistry { scope: device.scope === 'runtime' ? 'runtime' : 'mobile', relayBinding: validRelayBinding(device.relayBinding, device.deviceId), mobilePairingConnectionMode: - device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' + device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', + // Why: registries written before this field existed only ever held network-reach grants (phones and + // LAN links), so a missing value must keep binding every interface on reconnect. + pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' })) } catch { this.devices = [] diff --git a/src/main/runtime/pairing-endpoint.test.ts b/src/main/runtime/pairing-endpoint.test.ts index d43c95d7873..5fcebe0afd8 100644 --- a/src/main/runtime/pairing-endpoint.test.ts +++ b/src/main/runtime/pairing-endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { + resolveAdvertisedPairingEndpoint, + resolveAdvertisedPairingHostname +} from './pairing-endpoint' import { PAIRING_OFFER_VERSION, PairingOfferSchema } from '../../shared/mobile-relay-pairing-offer' import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' import { parseManualNetworkAddress } from '../../shared/network/manual-address' @@ -100,3 +103,31 @@ describe('resolveAdvertisedPairingEndpoint', () => { expect(PairingOfferSchema.safeParse(offer(aboveLimit)).success).toBe(false) }) }) + +describe('resolveAdvertisedPairingHostname', () => { + // Why: the loopback/off-host decision must read the host a link will advertise, not the raw string the + // user typed — `127.0.0.1:8443` and `ws://127.0.0.1:6768` both publish a loopback host. + it.each([ + ['127.0.0.1', '127.0.0.1'], + ['127.0.0.1:8443', '127.0.0.1'], + ['localhost', 'localhost'], + ['localhost:8443', 'localhost'], + ['::1', '::1'], + ['[::1]:6768', '::1'], + ['ws://127.0.0.1:6768', '127.0.0.1'], + ['http://localhost:6768/web-index.html', 'localhost'], + ['100.64.1.20', '100.64.1.20'], + ['192.168.1.5:6768', '192.168.1.5'], + ['desktop', 'desktop'], + ['wss://proxy.example.test:8443/orca', 'proxy.example.test'] + ])('reads the advertised host of %s', (input, hostname) => { + expect(resolveAdvertisedPairingHostname(input)).toBe(hostname) + }) + + it.each([null, undefined, '', ' ', 'host.example.test:', 'ftp://proxy.example.test'])( + 'returns null for the unusable address %j', + (input) => { + expect(resolveAdvertisedPairingHostname(input)).toBeNull() + } + ) +}) diff --git a/src/main/runtime/pairing-endpoint.ts b/src/main/runtime/pairing-endpoint.ts index c7af166f191..75ef7ae68c6 100644 --- a/src/main/runtime/pairing-endpoint.ts +++ b/src/main/runtime/pairing-endpoint.ts @@ -40,6 +40,23 @@ export function resolveAdvertisedPairingEndpoint( return valid(formatWebSocketUrl(endpoint)) } +// Why: callers that must reason about WHERE a link points (loopback vs off-host) need the hostname the +// offer will advertise, not the raw string the user typed — `127.0.0.1:8443`, `[::1]:6768` and +// `ws://127.0.0.1:6768` all advertise a loopback host but none of them parse as one on their own. +export function resolveAdvertisedPairingHostname( + advertisedAddress: string | null | undefined +): string | null { + const override = advertisedAddress?.trim() + if (!override) { + return null + } + if (override.includes('://')) { + const normalized = normalizePairingUrl(override) + return normalized ? unbracketIpv6(new URL(normalized).hostname) : null + } + return parseHostOverride(override)?.hostname ?? null +} + function resolveFullUrl(value: string): PairingEndpointResolution { const endpoint = normalizePairingUrl(value) return endpoint ? valid(endpoint) : invalid() diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 609079305c5..55d03efbd50 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -5616,6 +5616,149 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('stays on loopback at startup after a "This computer only" grant has connected', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: the local web client authenticating marks its grant lastSeenAt > 0 like any other socket, so a + // blanket "any connected device" widen republished the runtime on every interface one launch later — + // exactly what the user declined by picking "This computer only". + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('Runtime local', 'runtime', 'this-computer') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('binds all interfaces at startup for a connected device paired before pairingReach existed', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: registries written by older desktops only ever held network-reach grants; a missing field must + // keep the reconnect widen or an already-paired phone would be stranded by the upgrade. + const legacyDevice = { + deviceId: 'legacy-device', + name: 'Legacy phone', + token: 'legacy-token', + scope: 'mobile', + pairedAt: Date.now(), + lastSeenAt: Date.now() + } + await writeFile( + join(userDataPath, DEVICE_REGISTRY_FILENAME), + JSON.stringify([legacyDevice]), + 'utf-8' + ) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + + it('upgrades a reused pending grant to network reach so its link survives a relaunch', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + let deviceId: string + try { + const local = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'runtime', + reach: 'this-computer' + }) + expect(local.available).toBe(true) + // Why: without `rotate` the same pending token is re-advertised, now for off-host reach. The mark must + // widen with it — keeping it this-computer would leave the LAN link unserved after the next launch. + const network = server.createPairingOffer({ + address: '100.64.1.20', + scope: 'runtime', + reach: 'network' + }) + expect(network.available).toBe(true) + deviceId = network.available ? network.deviceId : '' + expect(deviceId).toBe(local.available ? local.deviceId : '') + server.getDeviceRegistry()?.updateLastSeen(deviceId) + } finally { + await server.stop() + } + + const relaunched = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await relaunched.start() + try { + expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0') + } finally { + await relaunched.stop() + } + }) + + it('keeps the pinned port when a later widen tears down a live loopback client', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const loopbackPort = wsTransportOf(server)!.resolvedPort + // Why: a "This computer only" link never widens, so unlike before, a local client can already be + // connected when a later LAN offer opts in. The rebind terminates it (ws cannot move a listener), so + // the port must be reused or the already-issued local link could never reconnect. + const client = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + client.once('open', () => resolve()) + client.once('error', reject) + }) + const closed = new Promise((resolve) => client.once('close', () => resolve())) + + await server.ensureNetworkExposure() + await closed + + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort) + + const reconnected = new WebSocket(`ws://127.0.0.1:${loopbackPort}`) + await new Promise((resolve, reject) => { + reconnected.once('open', () => resolve()) + reconnected.once('error', reject) + }) + reconnected.close() + } finally { + await server.stop() + } + }) + it('keeps the same MobileSocketWiring instance across a pairing widen (relay capture stays valid)', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f6274c3a996..7056f4bfe8f 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -30,6 +30,7 @@ import { } from './rpc/mobile-socket-wiring' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { mobileRelayMintFailureFromUnknown, type MobileRelayMintFailure @@ -661,6 +662,9 @@ export class OrcaRuntimeRpcServer { name?: string rotate?: boolean scope?: DeviceScope + // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the + // next launch bind every interface. Defaults to network reach, which is what every other caller means. + reach?: RuntimePairingReach }): | PairingOfferUnavailable | { @@ -697,9 +701,10 @@ export class OrcaRuntimeRpcServer { const scope = args.scope ?? 'runtime' let device: DeviceEntry try { + const reach = args.reach ?? 'network' device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope) + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) } catch (error) { console.error('[runtime] Failed to persist pairing credential:', error) return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) @@ -1228,13 +1233,17 @@ export class OrcaRuntimeRpcServer { // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. + // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so + // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } - const hasConnectedDevice = - this.deviceRegistry?.listDevices().some((device) => device.lastSeenAt > 0) ?? false - return hasConnectedDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK + const hasConnectedNetworkDevice = + this.deviceRegistry + ?.listDevices() + .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false + return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK } // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket @@ -1340,9 +1349,10 @@ export class OrcaRuntimeRpcServer { } // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing - // offer's advertised LAN endpoint is reachable. Idempotent and only ever runs on the first opt-in - // (before any device has connected), so no live socket is disrupted; the resolved port is reused so - // an already-issued endpoint stays valid. + // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first + // pairing action: a "This computer only" link never widens, so live loopback clients can already be + // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), + // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { if ( !this.enableWebSocket || diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0675e398d47..4596777a422 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -49,6 +49,7 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { @@ -3639,7 +3640,11 @@ export type PreloadApi = { { ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' } > openWindowsNetworkSettings: () => Promise - getRuntimePairingUrl: (args?: { address?: string; rotate?: boolean }) => Promise< + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + reach?: RuntimePairingReach + }) => Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { available: true diff --git a/src/preload/index.ts b/src/preload/index.ts index aaeb5403890..87385a186b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,7 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' import type { @@ -4628,6 +4629,9 @@ const api = { getRuntimePairingUrl: (args?: { address?: string rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach }): Promise< | { available: false; reason?: 'network_exposure_failed'; guidance?: string } | { diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index f0bb4ba08df..0b32a439dbd 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -7,15 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ listNetworkInterfaces: vi.fn(), - listRuntimeAccessGrants: vi.fn() + listRuntimeAccessGrants: vi.fn(), + getRuntimePairingUrl: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) vi.mock('./RuntimePairingGeneratorForm', () => ({ - RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( -
{props.selectedAddress}
+ RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => ( +
+
{props.selectedAddress}
+ +
) })) @@ -33,12 +39,20 @@ describe('RuntimePairingUrlGenerator', () => { runtimePairingLinkCache.runtimePairingDeviceId = null mocks.listNetworkInterfaces.mockReset() mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + mocks.getRuntimePairingUrl.mockReset().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: 'http://127.0.0.1:6768/web-index.html?pairing=runtime', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'runtime-1' + }) Object.defineProperty(window, 'api', { configurable: true, value: { mobile: { listNetworkInterfaces: mocks.listNetworkInterfaces, - listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants, + getRuntimePairingUrl: mocks.getRuntimePairingUrl } } }) @@ -67,4 +81,28 @@ describe('RuntimePairingUrlGenerator', () => { expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') ) }) + + // Why: the main process gates the one-way network widen on the declared reach, so dropping it (as the + // component used to) leaves main guessing from the address string — "This computer only" then widened, + // and a Custom loopback tunnel front-end would not. + it.each([ + ['local' as const, '127.0.0.1', 'this-computer'], + ['another' as const, '100.76.32.125', 'network'], + ['custom' as const, '127.0.0.1:8443', 'network'] + ])('sends the %s reach with the address', async (intent, address, reach) => { + runtimePairingLinkCache.intent = intent + runtimePairingLinkCache.selectedAddress = address + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + screen.getByRole('button', { name: 'Generate' }).click() + + await waitFor(() => + expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach }) + ) + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 54c75c7ac92..c6d927de593 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -11,6 +11,7 @@ import { cacheGeneratedRuntimePairingLink, clearGeneratedRuntimePairingLink, runtimePairingLinkCache, + runtimePairingReachForIntent, selectRuntimePairingIntent, type RuntimePairingIntent, type RuntimePairingUrlGeneratorProps @@ -187,7 +188,10 @@ export function RuntimePairingUrlGenerator({ try { const result = await window.api.mobile.getRuntimePairingUrl({ address, - rotate: true + rotate: true, + // Why: main gates the one-way network widen on this, so the declared choice must travel with the + // address — the address alone cannot tell "This computer only" from a loopback tunnel front-end. + reach: runtimePairingReachForIntent(intent) }) if (!result.available) { clearGeneratedUrls() diff --git a/src/renderer/src/components/settings/runtime-pairing-link-state.ts b/src/renderer/src/components/settings/runtime-pairing-link-state.ts index 0b3a1230fb9..ff777fc72fe 100644 --- a/src/renderer/src/components/settings/runtime-pairing-link-state.ts +++ b/src/renderer/src/components/settings/runtime-pairing-link-state.ts @@ -1,7 +1,15 @@ +import type { RuntimePairingReach } from '../../../../shared/runtime-pairing-reach' + export const RUNTIME_PAIRING_LOOPBACK_ADDRESS = '127.0.0.1' export type RuntimePairingIntent = 'another' | 'local' | 'custom' +// Why: only "This computer only" declines off-host reach. Custom is the SSH-tunnel/reverse-proxy field, so +// even a loopback-looking custom address (`127.0.0.1:8443`) needs the listener open behind the tunnel. +export function runtimePairingReachForIntent(intent: RuntimePairingIntent): RuntimePairingReach { + return intent === 'local' ? 'this-computer' : 'network' +} + export type RuntimePairingUrlGeneratorProps = { framed?: boolean showHeader?: boolean diff --git a/src/shared/runtime-pairing-reach.ts b/src/shared/runtime-pairing-reach.ts new file mode 100644 index 00000000000..0f7f0315416 --- /dev/null +++ b/src/shared/runtime-pairing-reach.ts @@ -0,0 +1,5 @@ +// Why: STA-2370 — widening the runtime listener to every interface is one-way for the life of the process +// and persists across launches, so it must follow the reach the user picked, not the shape of the address +// they typed: a Custom `127.0.0.1:8443` is usually an SSH tunnel or reverse proxy that still needs off-host +// reach, while "This computer only" must never publish the runtime beyond loopback. +export type RuntimePairingReach = 'this-computer' | 'network'