diff --git a/src/main/runtime/relay/desktop-relay-service.test.ts b/src/main/runtime/relay/desktop-relay-service.test.ts index 508b3fb64d7..2eba52ae0c8 100644 --- a/src/main/runtime/relay/desktop-relay-service.test.ts +++ b/src/main/runtime/relay/desktop-relay-service.test.ts @@ -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 = { diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 0fa924a8ac0..be9b22bb0c4 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -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 | null = null private livenessTimer: ReturnType | 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 { 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 { 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(key: string, operation: () => Promise): Promise { - 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( + kind: 'pairing' | 'endpoints' | 'provision', + deviceId: string, + operation: () => Promise + ): Promise { + 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 diff --git a/src/main/runtime/relay/relay-auth-coordinator-contract.ts b/src/main/runtime/relay/relay-auth-coordinator-contract.ts index 961bb64708e..41a2da8261d 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-contract.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-contract.ts @@ -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 } diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index 8f2305981a8..e4f2ed73582 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -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 { + private async reconcileEpoch( + epoch: number, + expectedIdentityKey?: string, + options?: ReconcileOptions + ): Promise { 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) { diff --git a/src/main/runtime/relay/relay-demand-ledger.test.ts b/src/main/runtime/relay/relay-demand-ledger.test.ts index b8227e7d42d..89b3f2bc0a4 100644 --- a/src/main/runtime/relay/relay-demand-ledger.test.ts +++ b/src/main/runtime/relay/relay-demand-ledger.test.ts @@ -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() diff --git a/src/main/runtime/relay/relay-demand-ledger.ts b/src/main/runtime/relay/relay-demand-ledger.ts index 8bcc1a6a2df..817fbc37e37 100644 --- a/src/main/runtime/relay/relay-demand-ledger.ts +++ b/src/main/runtime/relay/relay-demand-ledger.ts @@ -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() + private readonly transientRefs = new Map() 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 + } } diff --git a/src/main/runtime/relay/relay-pairing-endpoints-result.ts b/src/main/runtime/relay/relay-pairing-endpoints-result.ts new file mode 100644 index 00000000000..802bd314cb0 --- /dev/null +++ b/src/main/runtime/relay/relay-pairing-endpoints-result.ts @@ -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 { + 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 +} diff --git a/src/main/runtime/relay/repro-18211.test.ts b/src/main/runtime/relay/repro-18211.test.ts new file mode 100644 index 00000000000..f6e3182564f --- /dev/null +++ b/src/main/runtime/relay/repro-18211.test.ts @@ -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() + }) +}) diff --git a/src/main/startup/desktop-relay-startup.test.ts b/src/main/startup/desktop-relay-startup.test.ts new file mode 100644 index 00000000000..49459a5e0d7 --- /dev/null +++ b/src/main/startup/desktop-relay-startup.test.ts @@ -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) => 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) => 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() + }) +}) diff --git a/src/main/startup/desktop-relay-startup.ts b/src/main/startup/desktop-relay-startup.ts new file mode 100644 index 00000000000..1846c004193 --- /dev/null +++ b/src/main/startup/desktop-relay-startup.ts @@ -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) + ) + } +} diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts index 749eb56cb0f..243bf074713 100644 --- a/src/main/startup/main-process-ready-phase-ordering.test.ts +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -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) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 4ec2bd7bfac..bc6c197bb92 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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. diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 2149cadc284..f982f95840c 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -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', () => ({ + @@ -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, diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 9cb01e208ff..4087e483efe 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -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} diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 28f83ccf697..0212f3c7e4c 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -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 { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - mountedRoots.push(root) - await act(async () => { - root.render() - }) -} - -async function unmountMobilePaneRoots(): Promise { - await act(async () => { - for (const root of mountedRoots.splice(0)) { - root.unmount() - } - }) -} - describe('MobilePane', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 1c82adf25cb..814f3beeaa1 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -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 => + revokePairedMobileDevice({ + deviceId, + refreshDevices, + isMounted: () => mountedRef.current + }) return (
@@ -418,7 +401,7 @@ export function MobilePane(): React.JSX.Element { {relayMintFailure != null && connectionMode === 'automatic' ? ( changeConnectionMode('local-only')} + onUseLan={() => changeConnectionMode('local-only', { persist: false })} onRetry={() => void generateQR({ rotate: true })} onCopyDiagnostics={() => void copyRelayDiagnostics()} busy={loading} diff --git a/src/renderer/src/components/settings/mobile-pane-device-revoke.ts b/src/renderer/src/components/settings/mobile-pane-device-revoke.ts new file mode 100644 index 00000000000..0ccfbc999af --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-device-revoke.ts @@ -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 + isMounted: () => boolean +}): Promise { + 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') + ) + } + } +} diff --git a/src/renderer/src/components/settings/mobile-pane-test-mount.tsx b/src/renderer/src/components/settings/mobile-pane-test-mount.tsx new file mode 100644 index 00000000000..3bfefebd90f --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-test-mount.tsx @@ -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 { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render() + }) +} + +export async function unmountMobilePaneRoots(): Promise { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) +} diff --git a/src/shared/mobile-relay-policy.test.ts b/src/shared/mobile-relay-policy.test.ts new file mode 100644 index 00000000000..f1233ec8db0 --- /dev/null +++ b/src/shared/mobile-relay-policy.test.ts @@ -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) + }) +}) diff --git a/src/shared/mobile-relay-policy.ts b/src/shared/mobile-relay-policy.ts new file mode 100644 index 00000000000..918db0a056e --- /dev/null +++ b/src/shared/mobile-relay-policy.ts @@ -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' +}