mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(runtime): gate the pairing widen on the user's declared reach, not the address shape
Review of #12405 found two ways the loopback fix misbehaved. 1. The guarantee died at the next launch. resolveInitialWebSocketBindHost() binds 0.0.0.0 whenever any device has lastSeenAt > 0, and MobileSocketWiring stamps that for EVERY authenticated socket — including the local browser opening a "This computer only" link. So the runtime was still published on every interface, one restart later. Grants now carry the reach they were minted for (DeviceEntry.pairingReach, persisted); a this-computer grant no longer counts as proof that an off-host client may reconnect. Registries written before the field default to network reach, so an already-paired phone still finds a wide listener after upgrading. A pending grant that is re-advertised for the network widens (never narrows) so its link survives. 2. The widen was gated on the shape of the typed address, which the renderer never sent the intent for. A Custom `127.0.0.1:8443` — the documented SSH tunnel / reverse proxy field — skipped the widen and produced a dead link, while `localhost:8443`, `[::1]:6768` and `ws://127.0.0.1:6768` widened, so the same loopback intent was handled three different ways. The renderer now sends the declared reach ('this-computer' | 'network') and main gates on it; the address is only used as a mismatch guard (a this-computer reach carrying an off-host address still widens rather than minting an unreachable link), resolved through resolveAdvertisedPairingHostname so every accepted address form classifies identically. Also corrected the ensureNetworkExposure invariant comment: the widen is no longer confined to the first pairing action, so it can now tear down live loopback sockets — they reconnect on the reused pinned port. Tests: reach-form matrix + tunnel/undeclared/mismatch cases in mobile.test.ts, real-server relaunch bind for both reaches, legacy registry compatibility, the pending-grant reach upgrade, a live-client port-stability guard, hostname resolver coverage, and the renderer reach plumbing. Reverting only the source fails 18 of them.
This commit is contained in:
+172
-24
@@ -1,4 +1,4 @@
|
||||
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(() => ({
|
||||
@@ -318,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
|
||||
@@ -329,32 +330,111 @@ 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 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'
|
||||
})
|
||||
const ensureNetworkExposure = vi.fn().mockResolvedValue(undefined)
|
||||
const rpcServer = { createPairingOffer, ensureNetworkExposure }
|
||||
).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:<desktop-lan-ip>: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 })
|
||||
).resolves.toMatchObject({ available: true, deviceId: 'runtime-local' })
|
||||
handlers.get('mobile:getRuntimePairingUrl')?.(null, {
|
||||
address,
|
||||
rotate: true,
|
||||
reach: 'network'
|
||||
})
|
||||
).resolves.toMatchObject({ available: true })
|
||||
|
||||
// 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()
|
||||
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
|
||||
@@ -551,16 +631,21 @@ describe('runtime pairing bind host', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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 startServer = async (userDataPath: string): Promise<OrcaRuntimeRpcServer> => {
|
||||
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')
|
||||
@@ -568,7 +653,8 @@ describe('runtime pairing bind host', () => {
|
||||
await expect(
|
||||
handlers.get('mobile:getRuntimePairingUrl')?.(null, {
|
||||
address: '127.0.0.1',
|
||||
rotate: true
|
||||
rotate: true,
|
||||
reach: 'this-computer'
|
||||
})
|
||||
).resolves.toMatchObject({ available: true })
|
||||
// Why: the exposure regression — picking "This computer only" used to rebind the listener to
|
||||
@@ -578,7 +664,8 @@ describe('runtime pairing bind host', () => {
|
||||
await expect(
|
||||
handlers.get('mobile:getRuntimePairingUrl')?.(null, {
|
||||
address: '100.64.1.20',
|
||||
rotate: true
|
||||
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.
|
||||
@@ -587,4 +674,65 @@ describe('runtime pairing bind host', () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+23
-5
@@ -3,9 +3,11 @@ 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'
|
||||
@@ -78,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,
|
||||
@@ -176,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 }
|
||||
@@ -185,9 +199,10 @@ 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.
|
||||
// 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') {
|
||||
// "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) {
|
||||
@@ -211,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 }
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
client.once('open', () => resolve())
|
||||
client.once('error', reject)
|
||||
})
|
||||
const closed = new Promise<void>((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<void>((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({
|
||||
|
||||
@@ -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<void> {
|
||||
if (
|
||||
!this.enableWebSocket ||
|
||||
|
||||
@@ -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 {
|
||||
@@ -3648,7 +3649,11 @@ export type PreloadApi = {
|
||||
{ ok: true } | { ok: false; reason: 'cancelled' | 'failed' | 'unsupported' }
|
||||
>
|
||||
openWindowsNetworkSettings: () => Promise<boolean>
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
@@ -4654,6 +4655,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 }
|
||||
| {
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="selected-address">{props.selectedAddress}</div>
|
||||
RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => (
|
||||
<div>
|
||||
<div data-testid="selected-address">{props.selectedAddress}</div>
|
||||
<button type="button" onClick={props.onGenerate}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -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(<RuntimePairingUrlGenerator />)
|
||||
await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce())
|
||||
|
||||
screen.getByRole('button', { name: 'Generate' }).click()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user