fix(relay): apply the host LAN pairing mode to already-paired devices

settings.mobilePairingConnectionMode was only consulted when minting the next
QR. The live question — may this desktop serve mobile over Relay right now —
had no implementation, so a device paired as `automatic` kept the desktop on
Relay forever after the user picked LAN (#18211).

- src/shared/mobile-relay-policy.ts: isMobileRelayAllowed composes the host
  setting with the per-device pair-time mode, restrictive-only.
- RelayDemandLedger consults the policy for standing bindings and for in-flight
  transient refs (now keyed with a device id) so in-flight work cannot outvote
  the policy.
- DesktopRelayService gates every grant path at withTransientDemand, which is
  what finally covers createPairingRelay; the host mode is pulled through an
  optional callback so existing prototype-built tests keep working.
- RelayAuthCoordinator.reconcile({ skipLinger }) closes the broker promptly on
  a deliberate policy change; pairing churn keeps its ten-minute linger.
- desktop-relay-startup.ts wires the settings read and the settings-changed
  wake signal; the launch file shrinks below the line budget.
- The Relay-mint-failure "Use LAN" recovery buttons no longer persist the host
  policy, since revoking Relay from every paired phone is not what they promise.

Closes #18211
This commit is contained in:
Neil
2026-09-10 04:33:39 -07:00
parent f0295a30e2
commit 9ebf37d960
20 changed files with 642 additions and 161 deletions
@@ -102,6 +102,74 @@ describe('liveness safety net lifecycle', () => {
})
})
describe('host LAN policy applies to already-paired devices (#18211)', () => {
function serviceUnderPolicy(
hostMode: () => 'automatic' | 'local-only',
deviceMode: 'automatic' | 'local-only' = 'automatic'
) {
const registry = {
getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }),
getMobilePairingConnectionMode: () => deviceMode
}
const coordinator = { reconcile: vi.fn(), stop: vi.fn() }
const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService
Object.assign(service, {
coordinator,
demandLedger: { nextPendingExpiry: () => null, acquireTransient: () => () => {} },
hostMobilePairingConnectionMode: hostMode,
stopped: false,
livenessTimer: null,
demandExpiryTimer: null
})
Object.defineProperty(service, 'runtimeRpc', {
value: { getDeviceRegistry: () => registry }
})
return { service, coordinator }
}
afterEach(() => vi.useRealTimers())
it('refuses every relay grant for an automatic device once the host picks LAN', async () => {
vi.useFakeTimers()
const { service } = serviceUnderPolicy(() => 'local-only')
await expect(service.getEndpoints(context({ transport: 'direct' }), {})).resolves.toEqual({
v: 1,
relay: null
})
await expect(
service.provisionRelay(context({ transport: 'direct' }), {
reqId: 'install-1',
newResumeTokenHash: 'A'.repeat(43)
})
).rejects.toThrow('relay_disabled_for_device')
// Why: createPairingRelay had no per-device gate at all; the choke point
// in withTransientDemand is what covers it.
await expect(service.createPairingRelay('device-1')).rejects.toThrow(
'relay_disabled_for_device'
)
service.stop()
})
it('never grants Relay to a local-only device even when the host allows it', async () => {
vi.useFakeTimers()
const { service } = serviceUnderPolicy(() => 'automatic', 'local-only')
await expect(service.createPairingRelay('device-1')).rejects.toThrow(
'relay_disabled_for_device'
)
service.stop()
})
it('pairingPolicyChanged reconciles without the pairing-churn linger', () => {
vi.useFakeTimers()
const { service, coordinator } = serviceUnderPolicy(() => 'local-only')
service.pairingPolicyChanged()
expect(coordinator.reconcile).toHaveBeenLastCalledWith({ skipLinger: true })
service.demandStateChanged()
expect(coordinator.reconcile).toHaveBeenLastCalledWith(undefined)
service.stop()
})
})
describe('local-only mobile pairing', () => {
it('refuses endpoint discovery and provisioning without opening Relay demand', async () => {
const registry = {
+51 -36
View File
@@ -21,6 +21,9 @@ import { deriveRelayHostId } from './relay-http-client'
import { RelayDemandLedger } from './relay-demand-ledger'
import { createRelayRegionPreferenceReader } from './relay-region-preference'
import { pairingAuthorizationForContext } from './relay-pairing-authorization'
import { buildPairingEndpointsResult } from './relay-pairing-endpoints-result'
import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode'
import { isMobileRelayAllowed } from '../../../shared/mobile-relay-policy'
export { pairingAuthorizationForContext } from './relay-pairing-authorization'
@@ -30,6 +33,9 @@ type DesktopRelayServiceOptions = {
appVersion: string
runtimeRpc: OrcaRuntimeRpcServer
onStatus: (status: RelayBrokerStatus, cellUrl?: string) => void
// Live host policy (settings.mobilePairingConnectionMode); read on every
// demand decision so a LAN pick applies to already-paired devices.
hostMobilePairingConnectionMode?: () => MobilePairingConnectionMode
}
// Why: a broker that died without arming a retry (sleep past token expiry,
@@ -42,6 +48,7 @@ export class DesktopRelayService {
private readonly revokeOutbox: RelayRevokeOutbox
private readonly runtimeRpc: OrcaRuntimeRpcServer
private readonly demandLedger: RelayDemandLedger
private readonly hostMobilePairingConnectionMode?: () => MobilePairingConnectionMode
private demandExpiryTimer: ReturnType<typeof setTimeout> | null = null
private livenessTimer: ReturnType<typeof setInterval> | null = null
private stopped = false
@@ -54,10 +61,12 @@ export class DesktopRelayService {
}
this.runtimeRpc = options.runtimeRpc
this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
this.hostMobilePairingConnectionMode = options.hostMobilePairingConnectionMode
this.demandLedger = new RelayDemandLedger({
deviceRegistry: options.runtimeRpc.getDeviceRegistry()!,
revokeOutbox: this.revokeOutbox,
relayHostId: deriveRelayHostId(keypair.publicKey)
relayHostId: deriveRelayHostId(keypair.publicKey),
isRelayAllowedForDevice: (deviceId) => this.isRelayAllowedForDevice(deviceId)
})
const regionPreference = createRelayRegionPreferenceReader(options)
this.coordinator = new RelayAuthCoordinator({
@@ -116,7 +125,7 @@ export class DesktopRelayService {
async createPairingRelay(
relayDeviceId: string
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> {
return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => {
return await this.withTransientDemand('pairing', relayDeviceId, async () => {
const broker = await this.requireActiveBroker()
const relay = await broker.createPairingRelay(relayDeviceId)
return {
@@ -148,38 +157,21 @@ export class DesktopRelayService {
params: PairingGetEndpointsParams
): Promise<PairingGetEndpointsResult> {
this.requireMobileDevice(context.deviceId)
if (
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) ===
'local-only'
) {
if (!this.isRelayAllowedForDevice(context.deviceId)) {
return { v: 1, relay: null }
}
return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => {
return await this.withTransientDemand('endpoints', context.deviceId, async () => {
const broker = await this.activeBrokerForDemand()
if (!broker?.endpoint) {
return { v: 1, relay: null }
}
this.assertRelayHost(context, broker)
const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint }
if (params.installReqId) {
result.installStatus = await broker.credentialInstallStatus(
context.deviceId,
params.installReqId
)
}
if (params.resumeConfirmReqId) {
if (
context.transport.transport !== 'relay' ||
context.transport.credentialKind !== 'resume'
) {
throw new Error('resume_confirmation_unavailable')
}
result.resumeConfirmation = await broker.confirmResume(
context.transport.basisConnId,
params.resumeConfirmReqId
)
}
return result
return await buildPairingEndpointsResult({
broker,
endpoint: broker.endpoint,
context,
params
})
})
}
@@ -188,13 +180,10 @@ export class DesktopRelayService {
params: PairingProvisionRelayParams
): Promise<DeviceCredentialInstalled> {
this.requireMobileDevice(context.deviceId)
if (
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) ===
'local-only'
) {
if (!this.isRelayAllowedForDevice(context.deviceId)) {
throw new Error('relay_disabled_for_device')
}
return await this.withTransientDemand(`provision:${context.deviceId}`, async () => {
return await this.withTransientDemand('provision', context.deviceId, async () => {
const broker = await this.requireActiveBroker()
if (!broker.endpoint) {
throw new Error('relay_control_not_active')
@@ -224,6 +213,13 @@ export class DesktopRelayService {
this.refreshDemand()
}
// Wake signal for a host connection-mode change; the decision is the pull in
// isRelayAllowedForDevice. Skips the pairing-churn linger: a deliberate LAN
// pick that kept Relay open for ten more minutes would look like #18211.
pairingPolicyChanged(): void {
this.refreshDemand({ skipLinger: true })
}
stop(): void {
this.stopped = true
if (this.demandExpiryTimer) {
@@ -275,8 +271,27 @@ export class DesktopRelayService {
}
}
private async withTransientDemand<T>(key: string, operation: () => Promise<T>): Promise<T> {
const release = this.demandLedger.acquireTransient(key)
// Restrictive-only: the host setting withdraws Relay from an `automatic`
// device, never grants it to a `local-only` one.
private isRelayAllowedForDevice(deviceId: string): boolean {
return isMobileRelayAllowed({
hostConnectionMode: this.hostMobilePairingConnectionMode?.() ?? 'automatic',
deviceConnectionMode:
this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(deviceId) ?? null
})
}
// Why the gate lives here: every path that can grant Relay — including
// createPairingRelay, which had no per-device check — funnels through it.
private async withTransientDemand<T>(
kind: 'pairing' | 'endpoints' | 'provision',
deviceId: string,
operation: () => Promise<T>
): Promise<T> {
if (!this.isRelayAllowedForDevice(deviceId)) {
throw new Error('relay_disabled_for_device')
}
const release = this.demandLedger.acquireTransient(`${kind}:${deviceId}`, deviceId)
this.refreshDemand()
try {
return await operation()
@@ -302,7 +317,7 @@ export class DesktopRelayService {
return result.broker
}
private refreshDemand(): void {
private refreshDemand(options?: { skipLinger?: boolean }): void {
if (this.stopped) {
return
}
@@ -313,7 +328,7 @@ export class DesktopRelayService {
clearTimeout(this.demandExpiryTimer)
this.demandExpiryTimer = null
}
this.coordinator.reconcile()
this.coordinator.reconcile(options)
const expiresAt = this.demandLedger.nextPendingExpiry()
if (expiresAt !== null) {
// Why: an unscanned QR must stop holding a standing control when its
@@ -39,3 +39,7 @@ export type RelayAuthCoordinatorOptions = {
export type LiveBrokerWaitResult =
| { broker: CoordinatedRelayBroker }
| { broker: null; offlineReason: RelayOfflineReason | null }
// skipLinger: a deliberate policy change is not pairing churn, so a broker
// that lost demand closes now instead of holding the ten-minute linger.
export type RelayReconcileOptions = { skipLinger?: boolean }
@@ -13,7 +13,8 @@ import type {
LiveBrokerWaitResult,
RelayAuthContext,
RelayAuthCoordinatorOptions,
RelayAuthIdentity
RelayAuthIdentity,
RelayReconcileOptions as ReconcileOptions
} from './relay-auth-coordinator-contract'
export type {
@@ -64,11 +65,15 @@ export class RelayAuthCoordinator {
this.retry = new RelayRetrySchedule(options.random)
}
reconcile(): void {
this.beginReconcile(true)
reconcile(options?: ReconcileOptions): void {
this.beginReconcile(true, undefined, options)
}
private beginReconcile(resetRetry: boolean, expectedIdentityKey?: string): void {
private beginReconcile(
resetRetry: boolean,
expectedIdentityKey?: string,
options?: ReconcileOptions
): void {
if (this.stopped) {
return
}
@@ -78,7 +83,7 @@ export class RelayAuthCoordinator {
}
const epoch = ++this.authEpoch
this.invalidatePendingOwnerships()
const reconcile = this.reconcileEpoch(epoch, expectedIdentityKey)
const reconcile = this.reconcileEpoch(epoch, expectedIdentityKey, options)
this.latestReconcile = reconcile
void reconcile
}
@@ -184,7 +189,11 @@ export class RelayAuthCoordinator {
this.fenceAndCloseNow()
}
private async reconcileEpoch(epoch: number, expectedIdentityKey?: string): Promise<void> {
private async reconcileEpoch(
epoch: number,
expectedIdentityKey?: string,
options?: ReconcileOptions
): Promise<void> {
let retryIdentityKey: string | undefined
try {
const context = await this.options.readContext()
@@ -210,7 +219,10 @@ export class RelayAuthCoordinator {
}
if (!(this.options.hasDemand?.(context) ?? true)) {
this.retry.reset()
if (this.ownership?.valid && this.ownership.identityKey !== nextIdentityKey) {
if (
this.ownership?.valid &&
(options?.skipLinger || this.ownership.identityKey !== nextIdentityKey)
) {
this.cancelLinger()
this.invalidateOwnership()
} else if (this.ownership?.valid) {
@@ -29,8 +29,8 @@ function binding(relayDeviceId: string, inviteExpiresAt?: number): RelayDeviceBi
describe('RelayDemandLedger', () => {
it('reference-counts concurrent main-process work', () => {
const { ledger } = fixture(1_000)
const releaseFirst = ledger.acquireTransient('pairing:device-1')
const releaseSecond = ledger.acquireTransient('pairing:device-1')
const releaseFirst = ledger.acquireTransient('pairing:device-1', 'device-1')
const releaseSecond = ledger.acquireTransient('pairing:device-1', 'device-1')
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
releaseFirst()
releaseFirst()
+29 -9
View File
@@ -5,37 +5,52 @@ type RelayDemandLedgerOptions = {
deviceRegistry: DeviceRegistry
revokeOutbox: RelayRevokeOutbox
relayHostId: string
// Why: the host-level pairing mode is a live policy, not a pair-time record;
// a device it excludes contributes no demand even with a standing binding.
isRelayAllowedForDevice?: (deviceId: string) => boolean
now?: () => number
}
type TransientRef = { deviceId: string; count: number }
export class RelayDemandLedger {
private readonly options: RelayDemandLedgerOptions
private readonly transientRefs = new Map<string, number>()
private readonly transientRefs = new Map<string, TransientRef>()
constructor(options: RelayDemandLedgerOptions) {
this.options = options
}
acquireTransient(key: string): () => void {
this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1)
acquireTransient(key: string, deviceId: string): () => void {
const ref = this.transientRefs.get(key)
if (ref) {
ref.count += 1
} else {
this.transientRefs.set(key, { deviceId, count: 1 })
}
let released = false
return () => {
if (released) {
return
}
released = true
const count = this.transientRefs.get(key) ?? 0
if (count <= 1) {
const current = this.transientRefs.get(key)
if (!current) {
return
}
if (current.count <= 1) {
this.transientRefs.delete(key)
} else {
this.transientRefs.set(key, count - 1)
current.count -= 1
}
}
}
hasDemand(ownerIdentityKey: string): boolean {
if (this.transientRefs.size > 0) {
return true
for (const ref of this.transientRefs.values()) {
if (this.isRelayAllowed(ref.deviceId)) {
return true
}
}
if (this.options.revokeOutbox.pendingFor(ownerIdentityKey, this.options.relayHostId).length) {
return true
@@ -47,7 +62,8 @@ export class RelayDemandLedger {
device.scope !== 'mobile' ||
!binding ||
binding.ownerIdentityKey !== ownerIdentityKey ||
binding.relayHostId !== this.options.relayHostId
binding.relayHostId !== this.options.relayHostId ||
!this.isRelayAllowed(device.deviceId)
) {
return false
}
@@ -69,4 +85,8 @@ export class RelayDemandLedger {
}
return next
}
private isRelayAllowed(deviceId: string): boolean {
return this.options.isRelayAllowedForDevice?.(deviceId) ?? true
}
}
@@ -0,0 +1,35 @@
import type { MobilePairingConnectionContext } from '../runtime-rpc'
import type {
MobileRelayEndpoint,
PairingGetEndpointsParams,
PairingGetEndpointsResult
} from '../../../shared/mobile-relay-credential-contract'
import type { RelaySessionBroker } from './relay-session-broker'
// Assembles the endpoint answer for a device the host is willing to serve
// over Relay; the caller owns the policy gate and the host/broker checks.
export async function buildPairingEndpointsResult(args: {
broker: RelaySessionBroker
endpoint: MobileRelayEndpoint
context: MobilePairingConnectionContext
params: PairingGetEndpointsParams
}): Promise<PairingGetEndpointsResult> {
const { broker, context, params } = args
const result: PairingGetEndpointsResult = { v: 1, relay: args.endpoint }
if (params.installReqId) {
result.installStatus = await broker.credentialInstallStatus(
context.deviceId,
params.installReqId
)
}
if (params.resumeConfirmReqId) {
if (context.transport.transport !== 'relay' || context.transport.credentialKind !== 'resume') {
throw new Error('resume_confirmation_unavailable')
}
result.resumeConfirmation = await broker.confirmResume(
context.transport.basisConnId,
params.resumeConfirmReqId
)
}
return result
}
+110
View File
@@ -0,0 +1,110 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DeviceRegistry } from '../device-registry'
import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode'
import { RelayAuthCoordinator, type RelayAuthContext } from './relay-auth-coordinator'
import { RelayDemandLedger } from './relay-demand-ledger'
import { RelayRevokeOutbox } from './relay-revoke-outbox'
const ownerIdentityKey = 'user-1\0profile-1\0org-1'
const relayHostId = 'relay-host-1'
const context: RelayAuthContext = {
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
accessToken: 'access-1',
relayEntitled: true
}
function liveBroker() {
return {
closeNow: vi.fn(),
isLive: () => true,
endpoint: { cellUrl: 'https://c1.relay.example.test' }
}
}
// #18211: the host-level pairing connection mode must govern whether an
// already-paired `automatic` device may keep the desktop on Relay.
describe('#18211 LAN mode applies to already-paired devices', () => {
afterEach(() => vi.useRealTimers())
it('a standing relay binding stops being demand once the host picks LAN', () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-repro-18211-'))
const deviceRegistry = new DeviceRegistry(userDataPath)
const revokeOutbox = new RelayRevokeOutbox(userDataPath)
const phone = deviceRegistry.addDevice('Phone')
deviceRegistry.setMobilePairingConnectionMode(phone.deviceId, 'automatic')
deviceRegistry.setRelayBinding(phone.deviceId, {
relayHostId,
relayDeviceId: phone.deviceId,
ownerIdentityKey
})
let hostMode: MobilePairingConnectionMode = 'automatic'
const ledger = new RelayDemandLedger({
deviceRegistry,
revokeOutbox,
relayHostId,
isRelayAllowedForDevice: () => hostMode !== 'local-only'
})
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
hostMode = 'local-only'
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
// In-flight work for a device the policy now excludes cannot outvote it.
const release = ledger.acquireTransient(`endpoints:${phone.deviceId}`, phone.deviceId)
expect(ledger.hasDemand(ownerIdentityKey)).toBe(false)
release()
hostMode = 'automatic'
expect(ledger.hasDemand(ownerIdentityKey)).toBe(true)
})
it('a policy change withdraws the open relay session promptly instead of lingering', async () => {
vi.useFakeTimers()
let demanded = true
const broker = liveBroker()
const statuses: string[] = []
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
hasDemand: () => demanded,
openBroker: async () => broker,
onStatus: (status) => statuses.push(status)
})
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
demanded = false
coordinator.reconcile({ skipLinger: true })
await coordinator.waitForLiveBroker()
expect(statuses.at(-1)).toBe('standby')
expect(broker.closeNow).toHaveBeenCalledOnce()
})
it('pairing churn keeps the ten-minute linger', async () => {
vi.useFakeTimers()
let demanded = true
const broker = liveBroker()
const statuses: string[] = []
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
hasDemand: () => demanded,
openBroker: async () => broker,
onStatus: (status) => statuses.push(status)
})
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
demanded = false
coordinator.reconcile()
await coordinator.waitForLiveBroker()
expect(statuses.at(-1)).toBe('standby')
expect(broker.closeNow).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(10 * 60_000 - 1)
expect(broker.closeNow).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(broker.closeNow).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
configured: true,
serviceOptions: null as null | { hostMobilePairingConnectionMode?: () => string },
service: {
start: vi.fn(),
pairingPolicyChanged: vi.fn(),
createPairingRelay: vi.fn(),
onDeviceRevokeQueued: vi.fn(),
demandStateChanged: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn(),
ensureLive: vi.fn()
},
settingsListeners: [] as ((updates: Record<string, unknown>) => void)[],
settings: { mobilePairingConnectionMode: undefined as string | undefined }
}))
vi.mock('electron', () => ({
app: { getVersion: () => '0.0.0-test' },
powerMonitor: { on: vi.fn() }
}))
vi.mock('../orca-profiles/profile-cloud-auth-config', () => ({
getOrcaCloudAuthConfig: () => ({ configured: fakes.configured, config: {} })
}))
vi.mock('../orca-profiles/profile-storage-paths', () => ({
getProfileUserDataPath: () => '/tmp/orca-test'
}))
vi.mock('../runtime/relay/desktop-relay-service', () => ({
DesktopRelayService: class {
constructor(options: typeof fakes.serviceOptions) {
fakes.serviceOptions = options
return fakes.service
}
}
}))
vi.mock('./main-process-state', () => ({
mainProcessState: {
store: {
getSettings: () => fakes.settings,
onSettingsChanged: (listener: (updates: Record<string, unknown>) => void) => {
fakes.settingsListeners.push(listener)
return () => {}
}
},
desktopRelayService: null,
mainWindow: null
}
}))
import { startDesktopRelayService } from './desktop-relay-startup'
import { mainProcessState } from './main-process-state'
describe('startDesktopRelayService (#18211 host policy wiring)', () => {
beforeEach(() => {
vi.clearAllMocks()
fakes.configured = true
fakes.serviceOptions = null
fakes.settingsListeners.length = 0
fakes.settings.mobilePairingConnectionMode = undefined
mainProcessState.desktopRelayService = null
startDesktopRelayService({ setMobileRelayPairingProvider: vi.fn() } as never)
})
it('reads the live host pairing mode from settings, defaulting to automatic', () => {
const read = fakes.serviceOptions?.hostMobilePairingConnectionMode
expect(read?.()).toBe('automatic')
fakes.settings.mobilePairingConnectionMode = 'local-only'
expect(read?.()).toBe('local-only')
})
it('wakes the relay service only when the pairing mode setting changes', () => {
expect(fakes.settingsListeners).toHaveLength(1)
fakes.settingsListeners[0]!({ theme: 'dark' })
expect(fakes.service.pairingPolicyChanged).not.toHaveBeenCalled()
fakes.settingsListeners[0]!({ mobilePairingConnectionMode: 'local-only' })
expect(fakes.service.pairingPolicyChanged).toHaveBeenCalledOnce()
})
})
+57
View File
@@ -0,0 +1,57 @@
import { app, powerMonitor } from 'electron'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
import { mainProcessState as state } from './main-process-state'
// Desktop-mode relay bring-up; a no-op when cloud auth is not configured.
export function startDesktopRelayService(runtimeRpc: OrcaRuntimeRpcServer): void {
const cloudAuth = getOrcaCloudAuthConfig()
if (!cloudAuth.configured) {
return
}
try {
const relayService = new DesktopRelayService({
authConfig: cloudAuth.config,
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc,
hostMobilePairingConnectionMode: () =>
state.store?.getSettings().mobilePairingConnectionMode ?? 'automatic',
onStatus: (status, cellUrl) => {
state.desktopRelayStatus = status
state.desktopRelayCellUrl = cellUrl
state.mainWindow?.webContents.send('mobile:relayStatusChanged', {
status,
...(cellUrl === undefined ? {} : { cellUrl })
} satisfies MobileRelayStatusDetail)
}
})
state.desktopRelayService = relayService
// Wake signal only; the store already filters no-op writes, and the
// decision is pulled through hostMobilePairingConnectionMode.
state.store?.onSettingsChanged((updates) => {
if ('mobilePairingConnectionMode' in updates) {
state.desktopRelayService?.pairingPolicyChanged()
}
})
runtimeRpc.setMobileRelayPairingProvider({
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
onDemandStateChanged: () => relayService.demandStateChanged(),
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
})
relayService.start()
// Why: sleeping past relay-token expiry kills the broker with no retry
// timer; resume is the moment that state becomes recoverable.
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
} catch (error) {
console.warn(
'[relay] Desktop relay startup unavailable:',
error instanceof Error ? error.message : String(error)
)
}
}
@@ -113,7 +113,7 @@ describe('initial proxy application ordering', () => {
const windowIndex = desktop.indexOf('openMainWindow()')
const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady')
const relayIndex = desktop.indexOf('new DesktopRelayService(')
const relayIndex = desktop.indexOf('startDesktopRelayService(')
expect(windowIndex).toBeGreaterThanOrEqual(0)
expect(proxyIndex).toBeGreaterThan(windowIndex)
@@ -1,7 +1,5 @@
import { app, powerMonitor, type BrowserWindow } from 'electron'
import { app, type BrowserWindow } from 'electron'
import { is } from '@electron-toolkit/utils'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import {
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
@@ -13,8 +11,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend'
import { browserManager } from '../browser/browser-manager'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
import { startDesktopRelayService } from './desktop-relay-startup'
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
import {
bindTerminalRuntimeStartupServices,
@@ -245,42 +242,7 @@ async function launchDesktopMode(
// fetcher until the persisted proxy lands, so this only has to keep the launch phase itself
// ordered ahead of the relay — it must not gate the renderer.
await state.initialProxyApplicationReady
const cloudAuth = getOrcaCloudAuthConfig()
if (cloudAuth.configured) {
try {
const relayService = new DesktopRelayService({
authConfig: cloudAuth.config,
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc,
onStatus: (status, cellUrl) => {
state.desktopRelayStatus = status
state.desktopRelayCellUrl = cellUrl
state.mainWindow?.webContents.send('mobile:relayStatusChanged', {
status,
...(cellUrl === undefined ? {} : { cellUrl })
} satisfies MobileRelayStatusDetail)
}
})
state.desktopRelayService = relayService
runtimeRpc.setMobileRelayPairingProvider({
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
onDemandStateChanged: () => relayService.demandStateChanged(),
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
})
relayService.start()
// Why: sleeping past relay-token expiry kills the broker with no retry
// timer; resume is the moment that state becomes recoverable.
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
} catch (error) {
console.warn(
'[relay] Desktop relay startup unavailable:',
error instanceof Error ? error.message : String(error)
)
}
}
startDesktopRelayService(runtimeRpc)
// Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window.
win.once('show', () => {
// Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener.
@@ -62,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({
pairingQrError: boolean
relayMintFailure: MobileRelayMintFailure | null
onRetryRelay: () => void
onUseLan: () => void
selectedAddress: string | undefined
loadNetworkInterfaces: () => void
openAndroidInstallGuide: () => void
@@ -98,6 +99,9 @@ vi.mock('./MobilePageContent', () => ({
<button type="button" onClick={props.onRetryRelay}>
Retry Relay
</button>
<button type="button" onClick={props.onUseLan}>
Use LAN
</button>
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
Change address
</button>
@@ -416,6 +420,37 @@ describe('MobilePage pairing connection mode', () => {
expect(screen.getByTestId('relay-failure')).toHaveTextContent('none')
})
it('does not persist host policy from the Relay-failure Use LAN recovery button', async () => {
getPairingQR.mockResolvedValueOnce({
available: false,
reason: 'relay_mint_failed',
relayFailure: {
code: 'relay_mint_failed',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
const user = userEvent.setup()
await openPairingStep()
await waitFor(() =>
expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay')
)
getPairingQR.mockResolvedValueOnce({
available: true,
qrDataUrl: 'data:image/png;base64,local',
pairingUrl: 'orca://pair#local',
endpoint: 'ws://host',
connectionMode: 'local-only'
})
await user.click(screen.getByRole('button', { name: 'Use LAN' }))
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only'))
// Why: the persisted mode is host policy that withdraws Relay from every
// paired phone; this button only promises a LAN QR (#18211).
expect(mocks.storeState.updateSettings).not.toHaveBeenCalledWith(
expect.objectContaining({ mobilePairingConnectionMode: 'local-only' })
)
})
it('switches to LAN while a Relay retry is still unresolved', async () => {
getPairingQR.mockResolvedValueOnce({
available: false,
@@ -129,7 +129,7 @@ export default function MobilePage(): React.JSX.Element {
}, [connectionMode, generatePairing, pairLoading, signedIn])
const handleConnectionModeChange = useCallback(
(nextMode: MobilePairingConnectionMode): void => {
(nextMode: MobilePairingConnectionMode, options?: { persist?: boolean }): void => {
if (nextMode === connectionMode) {
return
}
@@ -138,7 +138,12 @@ export default function MobilePage(): React.JSX.Element {
// (below), which also covers cross-window preference syncs.
setRelayMintFailure(null)
setConnectionMode(nextMode)
void updateSettings({ mobilePairingConnectionMode: nextMode })
// Why: the persisted setting is host policy — it withdraws Relay from
// every paired phone. A mint-failure recovery button only promises a
// LAN QR, so it must not persist.
if (options?.persist !== false) {
void updateSettings({ mobilePairingConnectionMode: nextMode })
}
},
[connectionMode, updateSettings, setConnectionMode]
)
@@ -345,7 +350,7 @@ export default function MobilePage(): React.JSX.Element {
relayMintFailure={
connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null
}
onUseLan={() => handleConnectionModeChange('local-only')}
onUseLan={() => handleConnectionModeChange('local-only', { persist: false })}
onRetryRelay={() => void generatePairing(true)}
onCopyRelayDiagnostics={() => void copyRelayDiagnostics()}
platform={platform}
@@ -3,7 +3,6 @@
import '@testing-library/jest-dom/vitest'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -164,6 +163,7 @@ vi.mock('../mobile/WindowsFirewallNotice', () => ({
}))
import { MobilePane } from './MobilePane'
import { pairedDevice, renderMobilePane, unmountMobilePaneRoots } from './mobile-pane-test-mount'
describe('MobilePane pairing connection mode', () => {
const getPairingQR = mocks.getPairingQR
@@ -271,6 +271,11 @@ describe('MobilePane pairing connection mode', () => {
await waitFor(() =>
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
)
// Why: the persisted mode is host policy that withdraws Relay from every
// paired phone; a mint-recovery button only promises a LAN QR (#18211).
expect(updateSettings).not.toHaveBeenCalledWith(
expect.objectContaining({ mobilePairingConnectionMode: 'local-only' })
)
})
it('does not show mint failure after an honest Relay mint', async () => {
@@ -750,35 +755,6 @@ describe('MobilePane pairing connection mode', () => {
})
})
const mountedRoots: Root[] = []
function pairedDevice(deviceId: string): PairedDevice {
return {
deviceId,
name: deviceId,
pairedAt: 1,
lastSeenAt: 2
}
}
async function renderMobilePane(): Promise<void> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(<MobilePane />)
})
}
async function unmountMobilePaneRoots(): Promise<void> {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
}
describe('MobilePane', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -4,9 +4,9 @@ import { useAppStore } from '../../store'
import { useMountedRef } from '@/hooks/useMountedRef'
import {
getPairedMobileDevicesSnapshot,
replacePairedMobileDevices,
usePairedMobileDevices
} from '../mobile/paired-mobile-devices'
import { revokePairedMobileDevice } from './mobile-pane-device-revoke'
import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling'
import type { MobileNetworkInterface } from './mobile-network-interface-selection'
import { MobilePairingQrSection } from './MobilePairingQrSection'
@@ -263,7 +263,7 @@ export function MobilePane(): React.JSX.Element {
)
const changeConnectionMode = useCallback(
(nextMode: MobilePairingConnectionMode) => {
(nextMode: MobilePairingConnectionMode, options?: { persist?: boolean }) => {
if (nextMode === connectionMode) {
return
}
@@ -271,7 +271,12 @@ export function MobilePane(): React.JSX.Element {
// instead of snapping back to the default.
handledModeRef.current = nextMode
setConnectionMode(nextMode)
void updateSettings({ mobilePairingConnectionMode: nextMode })
// Why: the persisted setting is host policy — it withdraws Relay from
// every paired phone. A mint-failure recovery button only promises a
// LAN QR, so it must not persist.
if (options?.persist !== false) {
void updateSettings({ mobilePairingConnectionMode: nextMode })
}
// Why: after a Relay mint failure, LAN should mint immediately — including
// when the renderer has not chosen an address yet (main picks the default).
const shouldRecoverWithLan = relayMintFailure != null && nextMode === 'local-only'
@@ -355,34 +360,12 @@ export function MobilePane(): React.JSX.Element {
loadDevices
})
async function revokeDevice(deviceId: string) {
try {
const { revoked } = await window.api.mobile.revokeDevice({ deviceId })
// Why: the backend can resolve revoked=false without removing the device;
// surface that as an error instead of a false "Device revoked".
if (!revoked) {
throw new Error('mobile.revokeDevice returned revoked=false')
}
try {
// Why: the backend may have learned about another phone while Settings
// was open, so refresh from source-of-truth after mutating it.
await refreshDevices({ force: true })
} catch (err) {
console.error('mobile.listDevices failed after revoke', err)
const nextDevices = getPairedMobileDevicesSnapshot().filter((d) => d.deviceId !== deviceId)
replacePairedMobileDevices(nextDevices)
}
if (mountedRef.current) {
toast.success(translate('auto.components.settings.MobilePane.2e3dd0bc29', 'Device revoked'))
}
} catch {
if (mountedRef.current) {
toast.error(
translate('auto.components.settings.MobilePane.870e1b5ca5', 'Failed to revoke device')
)
}
}
}
const revokeDevice = (deviceId: string): Promise<void> =>
revokePairedMobileDevice({
deviceId,
refreshDevices,
isMounted: () => mountedRef.current
})
return (
<div className="space-y-6">
@@ -418,7 +401,7 @@ export function MobilePane(): React.JSX.Element {
{relayMintFailure != null && connectionMode === 'automatic' ? (
<MobileRelayMintFailureNotice
failure={relayMintFailure}
onUseLan={() => changeConnectionMode('local-only')}
onUseLan={() => changeConnectionMode('local-only', { persist: false })}
onRetry={() => void generateQR({ rotate: true })}
onCopyDiagnostics={() => void copyRelayDiagnostics()}
busy={loading}
@@ -0,0 +1,40 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import {
getPairedMobileDevicesSnapshot,
replacePairedMobileDevices
} from '../mobile/paired-mobile-devices'
export async function revokePairedMobileDevice(args: {
deviceId: string
refreshDevices: (opts: { force: true }) => Promise<unknown>
isMounted: () => boolean
}): Promise<void> {
const { deviceId } = args
try {
const { revoked } = await window.api.mobile.revokeDevice({ deviceId })
// Why: the backend can resolve revoked=false without removing the device;
// surface that as an error instead of a false "Device revoked".
if (!revoked) {
throw new Error('mobile.revokeDevice returned revoked=false')
}
try {
// Why: the backend may have learned about another phone while Settings
// was open, so refresh from source-of-truth after mutating it.
await args.refreshDevices({ force: true })
} catch (err) {
console.error('mobile.listDevices failed after revoke', err)
const nextDevices = getPairedMobileDevicesSnapshot().filter((d) => d.deviceId !== deviceId)
replacePairedMobileDevices(nextDevices)
}
if (args.isMounted()) {
toast.success(translate('auto.components.settings.MobilePane.2e3dd0bc29', 'Device revoked'))
}
} catch {
if (args.isMounted()) {
toast.error(
translate('auto.components.settings.MobilePane.870e1b5ca5', 'Failed to revoke device')
)
}
}
}
@@ -0,0 +1,35 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { PairedMobileDevice } from '../mobile/paired-mobile-devices'
import { MobilePane } from './MobilePane'
// Why not RTL render: these tests unmount mid-flight to prove a late resolve
// cannot toast after unmount, which needs a root handle RTL does not expose.
const mountedRoots: Root[] = []
export function pairedDevice(deviceId: string): PairedMobileDevice {
return {
deviceId,
name: deviceId,
pairedAt: 1,
lastSeenAt: 2
}
}
export async function renderMobilePane(): Promise<void> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(<MobilePane />)
})
}
export async function unmountMobilePaneRoots(): Promise<void> {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { isMobileRelayAllowed } from './mobile-relay-policy'
describe('isMobileRelayAllowed', () => {
it('lets the host setting withdraw Relay from an automatic device', () => {
expect(
isMobileRelayAllowed({ hostConnectionMode: 'local-only', deviceConnectionMode: 'automatic' })
).toBe(false)
expect(
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: 'automatic' })
).toBe(true)
})
it('never lets the host setting grant Relay to a local-only device', () => {
expect(
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: 'local-only' })
).toBe(false)
})
it('treats an unplaceable device as having no opinion', () => {
expect(
isMobileRelayAllowed({ hostConnectionMode: 'automatic', deviceConnectionMode: null })
).toBe(true)
expect(
isMobileRelayAllowed({ hostConnectionMode: 'local-only', deviceConnectionMode: null })
).toBe(false)
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { MobilePairingConnectionMode } from './mobile-pairing-connection-mode'
/**
* May this desktop serve a paired mobile device over Relay right now?
*
* Restrictive-only: the host-level setting withdraws Relay from an `automatic`
* device but never grants it to one paired `local-only`. A device the registry
* cannot place (`null`) contributes no opinion. The mint question (what the next
* QR encodes) stays in `mobile-pairing-connection-mode.ts`.
*/
export function isMobileRelayAllowed(args: {
hostConnectionMode: MobilePairingConnectionMode
deviceConnectionMode: MobilePairingConnectionMode | null
}): boolean {
return args.hostConnectionMode !== 'local-only' && args.deviceConnectionMode !== 'local-only'
}