From 9e9652bd21cc06754b1709687740434bbf1279d4 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:02:24 -0400 Subject: [PATCH 1/4] feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...evice-allowlist-census.integration.test.ts | 150 +++++++++++ ...elegated-mobile-device.integration.test.ts | 242 ++++++++++++++++++ src/main/runtime/delegated-mobile-devices.ts | 44 ++++ src/main/runtime/device-registry.ts | 30 ++- src/main/runtime/rpc/core.ts | 8 + .../runtime/rpc/dispatcher-stream-options.ts | 3 +- src/main/runtime/rpc/methods/pairing.ts | 14 + .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 + .../runtime-rpc/runtime-rpc-lifecycle.ts | 2 +- .../runtime-rpc-network-exposure.ts | 2 +- .../runtime-rpc/runtime-rpc-pairing.ts | 41 ++- .../runtime-rpc-websocket-dispatch.ts | 16 +- .../delegated-mobile-device-contract.ts | 34 +++ src/shared/protocol-version.ts | 1 + .../rpc-params-catalog.generated.ts | 2 + 15 files changed, 585 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts create mode 100644 src/main/runtime/delegated-mobile-device.integration.test.ts create mode 100644 src/main/runtime/delegated-mobile-devices.ts create mode 100644 src/shared/delegated-mobile-device-contract.ts diff --git a/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts b/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts new file mode 100644 index 00000000000..fc3697fabe0 --- /dev/null +++ b/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts @@ -0,0 +1,150 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + type DelegatedMobileDeviceSyncResult +} from '../../shared/delegated-mobile-device-contract' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' +import { + authenticate, + createReader, + makeStore, + send, + type PairedSession, + type ResponseReader +} from './paired-client-navigation-test-harness' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +const FIRST_FRAME_TIMEOUT_MS = 1_500 +const ErrorCodeSchema = z.object({ error: z.object({ code: z.string() }) }) + +describe('delegated phone allowlist census', () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + it('every allowlisted method answers a delegated phone exactly as it answers a direct phone', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the navigation fixture store carries every surface the dispatcher reads here. + const runtime = new OrcaRuntimeService(makeStore() as never) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: mkdtempSync(join(tmpdir(), 'orca-delegated-census-')), + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + + const directOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'direct', + scope: 'mobile' + }) + if (!directOffer.available) { + throw new Error('pairing unavailable') + } + const direct = parsePairingCode(directOffer.pairingUrl)! + const desktopOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'Mac', + scope: 'runtime' + }) + if (!desktopOffer.available) { + throw new Error('pairing unavailable') + } + const desktop = parsePairingCode(desktopOffer.pairingUrl)! + const synced = await sendRemoteRuntimeRequest( + desktop, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + { phones: [{ phoneKey: 'p', name: 'p via Mac' }] }, + 5_000 + ) + if (!synced.ok) { + throw new Error('sync failed') + } + const child = synced.result.devices[0]! + const delegated = { + ...desktop, + deviceToken: child.token, + pairedDeviceId: child.deviceId, + scope: 'mobile' as const + } + + const sessions: [PairedSession, ResponseReader][] = [] + for (const pairing of [direct, delegated]) { + const session = await authenticate(encodePairingOffer(pairing)) + const reader = createReader(session) + sessions.push([session, reader]) + cleanups.push(() => { + reader.dispose() + session.ws.close() + }) + } + const identities: [string, string][] = [ + [direct.deviceToken, ''], + [direct.pairedDeviceId!, ''], + [child.token, ''], + [child.deviceId, ''] + ] + const normalize = (response: Record | 'no-first-frame'): string => { + if (response === 'no-first-frame') { + return response + } + const { _meta: _ignored, id: _id, ...rest } = response + let text = JSON.stringify(rest) + for (const [value, placeholder] of identities) { + text = text.split(value).join(placeholder) + } + // Why: ready-stream ids embed the per-socket connection id and a server-wide counter. + return text.replace(/-[0-9a-f]{16}-\d+"/g, '--"') + } + const firstFrame = (reader: ResponseReader, id: string) => + Promise.race([ + reader.next(id), + new Promise<'no-first-frame'>((resolve) => + setTimeout(() => resolve('no-first-frame'), FIRST_FRAME_TIMEOUT_MS) + ) + ]) + + const mismatches: { method: string; direct: string; delegated: string }[] = [] + const outcomes = new Map() + const methods = [...MOBILE_RPC_METHOD_ALLOWLIST].sort() + for (const method of methods) { + const results = await Promise.all( + sessions.map(([session, reader], index) => { + const id = `${method}#${index}` + send(session, { id, method, params: {} }) + return firstFrame(reader, id) + }) + ) + const [directText, delegatedText] = results.map(normalize) + const code = + results[0] === 'no-first-frame' + ? 'no-first-frame' + : results[0]!.ok + ? 'ok' + : String(ErrorCodeSchema.safeParse(results[0]).data?.error.code) + outcomes.set(code, (outcomes.get(code) ?? 0) + 1) + if (directText !== delegatedText) { + mismatches.push({ method, direct: directText!, delegated: delegatedText! }) + } + } + // Presence precondition: the probe reached handlers, not only a blanket refusal. + expect(outcomes.get('ok')).toBeGreaterThan(0) + expect(mismatches).toEqual([]) + }, 120_000) +}) diff --git a/src/main/runtime/delegated-mobile-device.integration.test.ts b/src/main/runtime/delegated-mobile-device.integration.test.ts new file mode 100644 index 00000000000..afaeaa761e9 --- /dev/null +++ b/src/main/runtime/delegated-mobile-device.integration.test.ts @@ -0,0 +1,242 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { readRuntimeMetadata } from './runtime-metadata' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { DeviceRegistry } from './device-registry' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from '../../shared/pairing' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + type DelegatedMobileDeviceSyncResult +} from '../../shared/delegated-mobile-device-contract' +import { sendRequest } from './runtime-rpc-test-harness' +import { + authenticateMobileWsSession, + createEncryptedWsResponseReader, + sendEncryptedWsRequest, + waitForWsClose +} from './runtime-rpc-mobile-ws-test-harness' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([]), + listWorktreesStrict: vi.fn().mockResolvedValue([]) +})) + +type DelegatedDevice = DelegatedMobileDeviceSyncResult['devices'][number] + +describe('pairing.delegatedMobileDevice.sync', () => { + const cleanups: (() => Promise | void)[] = [] + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup() + } + }) + + async function startServer() { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling WebSocket tests. + const runtime = new OrcaRuntimeService(makeStore() as never) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + cleanups.push(() => server.stop()) + return { server, userDataPath, registry: server.getDeviceRegistry()! } + } + + function pair( + server: OrcaRuntimeRpcServer, + args: Parameters[0] + ): PairingOffer { + const offer = server.createPairingOffer({ address: '127.0.0.1', ...args }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + return parsePairingCode(offer.pairingUrl)! + } + + function pairDesktop(server: OrcaRuntimeRpcServer, name = 'MacBook'): PairingOffer { + return pair(server, { name, scope: 'runtime' }) + } + + async function sync( + desktop: PairingOffer, + phones: { phoneKey: string; name: string }[] + ): Promise { + const response = await sendRemoteRuntimeRequest( + desktop, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + { phones }, + 5_000 + ) + if (!response.ok) { + throw new Error(`sync failed: ${response.error.code} ${response.error.message}`) + } + return response.result.devices + } + + function asPhone(desktop: PairingOffer, device: DelegatedDevice): PairingOffer { + return { + ...desktop, + deviceToken: device.token, + pairedDeviceId: device.deviceId, + scope: 'mobile' + } + } + + it('advertises the capability a desktop gates on', async () => { + const { server } = await startServer() + const status = await sendRemoteRuntimeRequest<{ capabilities: string[] }>( + pairDesktop(server), + 'status.get', + {}, + 5_000 + ) + expect(status).toMatchObject({ ok: true }) + expect(status.ok && status.result.capabilities).toContain( + DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY + ) + }) + + it('creates an ordinary mobile device a real E2EE client uses, idempotently', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const devices = await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }]) + expect(devices).toEqual([ + { phoneKey: 'phone-1', deviceId: expect.any(String), token: expect.any(String) } + ]) + expect(await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }])).toEqual( + devices + ) + const child = devices[0]! + expect(child.deviceId).not.toBe(desktop.pairedDeviceId) + expect(registry.getDevice(child.deviceId)).toMatchObject({ + name: 'iPhone via MacBook', + scope: 'mobile', + parentDeviceId: desktop.pairedDeviceId, + phoneKey: 'phone-1' + }) + + const phone = asPhone(desktop, child) + await expect(sendRemoteRuntimeRequest(phone, 'status.get', {}, 5_000)).resolves.toMatchObject({ + ok: true, + result: { deviceScope: 'mobile' } + }) + // The mobile allowlist applies, including to sync itself. + await expect( + sendRemoteRuntimeRequest(phone, 'worktree.list', {}, 5_000) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + await expect( + sendRemoteRuntimeRequest(phone, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, { phones: [] }, 5_000) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + }) + + it('refuses a directly paired phone and a local caller with no paired device', async () => { + const { server, registry, userDataPath } = await startServer() + const phone = pair(server, { name: 'phone', scope: 'mobile' }) + await expect( + sendRemoteRuntimeRequest( + phone, + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + { phones: [{ phoneKey: 'p', name: 'p' }] }, + 5_000 + ) + ).resolves.toMatchObject({ ok: false, error: { code: 'forbidden' } }) + const metadata = readRuntimeMetadata(userDataPath)! + await expect( + sendRequest(metadata.transports[0]!.endpoint, { + id: 'local', + authToken: metadata.authToken, + method: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + params: { phones: [{ phoneKey: 'p', name: 'p' }] } + }) + ).resolves.toMatchObject({ ok: false }) + expect(registry.listDevices().filter((device) => device.parentDeviceId)).toEqual([]) + }) + + it('gives children the parent grant reach, so they never widen the bind', async () => { + const { server, registry } = await startServer() + const desktop = pair(server, { name: 'local Mac', scope: 'runtime', reach: 'this-computer' }) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect(registry.getDevice(child!.deviceId)?.pairingReach).toBe('this-computer') + }) + + it('never lets the QR pending lookups hand out or delete a child', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'phone-1', name: 'iPhone via MacBook' }]) + + expect(registry.getPendingDevice('mobile')).toBeNull() + const offer = server.createPairingOffer({ address: '127.0.0.1', scope: 'mobile' }) + expect(offer.available && offer.deviceId).not.toBe(child!.deviceId) + const rotated = server.createPairingOffer({ + address: '127.0.0.1', + scope: 'mobile', + rotate: true + }) + expect(rotated.available && rotated.deviceId).not.toBe(child!.deviceId) + expect(registry.getDevice(child!.deviceId)).not.toBeNull() + await expect( + sendRemoteRuntimeRequest(asPhone(desktop, child!), 'status.get', {}, 5_000) + ).resolves.toMatchObject({ ok: true }) + }) + + it('revokes a dropped child and closes its socket', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [a, b] = await sync(desktop, [ + { phoneKey: 'phone-a', name: 'A' }, + { phoneKey: 'phone-b', name: 'B' } + ]) + const sessionB = await authenticateMobileWsSession(encodePairingOffer(asPhone(desktop, b!))) + const closedB = waitForWsClose(sessionB.ws) + + expect(await sync(desktop, [{ phoneKey: 'phone-a', name: 'A' }])).toEqual([a]) + expect(registry.getDevice(b!.deviceId)).toBeNull() + await closedB + }) + + it('cascades a parent revoke to its children only', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'phone-a', name: 'A' }]) + // Paired after the first desktop consumed its pending offer; the same phoneKey is scoped per parent. + const otherDesktop = pairDesktop(server, 'Other Mac') + const [otherChild] = await sync(otherDesktop, [{ phoneKey: 'phone-a', name: 'A' }]) + expect(otherChild!.deviceId).not.toBe(child!.deviceId) + + const session = await authenticateMobileWsSession(encodePairingOffer(asPhone(desktop, child!))) + const reader = createEncryptedWsResponseReader(session) + cleanups.push(() => reader.dispose()) + sendEncryptedWsRequest(session, { id: 's', method: 'status.get' }) + await expect(reader.next('s')).resolves.toMatchObject({ ok: true }) + const closed = waitForWsClose(session.ws) + + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(true) + expect(registry.getDevice(child!.deviceId)).toBeNull() + expect(registry.getDevice(otherChild!.deviceId)).not.toBeNull() + await closed + }) + + it('loads a registry written before delegated devices existed unchanged', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-old-')) + const old = [ + { deviceId: 'd1', name: 'Phone', token: 't1', scope: 'mobile', pairedAt: 1, lastSeenAt: 2 }, + { deviceId: 'd2', name: 'Mac', token: 't2', scope: 'runtime', pairedAt: 3, lastSeenAt: 4 } + ] + writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), JSON.stringify(old)) + const registry = new DeviceRegistry(userDataPath) + expect(registry.listDevices()).toMatchObject(old) + expect(registry.listDevices().every((device) => device.parentDeviceId === undefined)).toBe(true) + expect(registry.listDelegatedMobileDevices('d2')).toEqual([]) + }) +}) diff --git a/src/main/runtime/delegated-mobile-devices.ts b/src/main/runtime/delegated-mobile-devices.ts new file mode 100644 index 00000000000..b7ec051c4cf --- /dev/null +++ b/src/main/runtime/delegated-mobile-devices.ts @@ -0,0 +1,44 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' +import type { DeviceEntry } from './device-registry' + +export type DelegatedMobileDeviceEntry = DeviceEntry & { parentDeviceId: string; phoneKey: string } + +/** The registry after making `parent`'s children cover `phones`, and those children in `phones` order. */ +export function planDelegatedMobileDevices( + devices: DeviceEntry[], + parent: DeviceEntry, + phones: readonly DelegatedPhone[], + now = Date.now() +): { nextDevices: DeviceEntry[]; entries: DelegatedMobileDeviceEntry[] } { + let nextDevices = devices + const entries = phones.map((phone) => { + const existing = nextDevices.find( + (device): device is DelegatedMobileDeviceEntry => + device.parentDeviceId === parent.deviceId && device.phoneKey === phone.phoneKey + ) + if (existing) { + if (existing.name === phone.name) { + return existing + } + const renamed = { ...existing, name: phone.name } + nextDevices = nextDevices.map((device) => (device === existing ? renamed : device)) + return renamed + } + const entry: DelegatedMobileDeviceEntry = { + deviceId: randomUUID(), + name: phone.name, + token: randomBytes(24).toString('hex'), + scope: 'mobile', + pairedAt: now, + // Why: never pending — the QR lookups select lastSeenAt === 0 and must not hand out or rotate a child. + lastSeenAt: now, + pairingReach: parent.pairingReach ?? 'network', + parentDeviceId: parent.deviceId, + phoneKey: phone.phoneKey + } + nextDevices = [...nextDevices, entry] + return entry + }) + return { nextDevices, entries } +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 612b5fcada1..d80d526739b 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -16,6 +16,11 @@ 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' +import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' +import { + planDelegatedMobileDevices, + type DelegatedMobileDeviceEntry +} from './delegated-mobile-devices' import { parseMobilePushRegistration, type MobilePushRegistration @@ -38,6 +43,9 @@ export type DeviceEntry = { // Why: survives a desktop restart so the host can keep pushing without the phone // re-registering. Absent on every registry written before background push existed. pushRegistration?: MobilePushRegistration + // Why: a phone relayed by a paired desktop is that desktop's child; it dies with the parent's grant. + parentDeviceId?: string + phoneKey?: string } function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { @@ -159,6 +167,23 @@ export class DeviceRegistry { return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) } + listDelegatedMobileDevices(parentDeviceId: string): DeviceEntry[] { + return this.devices.filter((device) => device.parentDeviceId === parentDeviceId) + } + + /** Create-or-return one mobile child per phoneKey under `parent`, in `phones` order. */ + upsertDelegatedMobileDevices( + parent: DeviceEntry, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceEntry[] { + const { nextDevices, entries } = planDelegatedMobileDevices(this.devices, parent, phones) + if (nextDevices !== this.devices) { + this.save(nextDevices) + this.devices = nextDevices + } + return entries + } + removeDevice(deviceId: string): boolean { const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId) if (nextDevices.length === this.devices.length) { @@ -333,7 +358,10 @@ export class DeviceRegistry { pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network', // Why: a malformed row must degrade to "no background push", never fail the load // and strand every paired device. - pushRegistration: parseMobilePushRegistration(device.pushRegistration) + pushRegistration: parseMobilePushRegistration(device.pushRegistration), + parentDeviceId: + typeof device.parentDeviceId === 'string' ? device.parentDeviceId : undefined, + phoneKey: typeof device.phoneKey === 'string' ? device.phoneKey : undefined })) this.registryUnreadable = false } catch (error) { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 006bb7a982d..b330bd55ee0 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -9,6 +9,10 @@ import type { PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' @@ -111,6 +115,10 @@ export type RpcContext = { // Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage. authenticatedCallerFingerprint?: string pairing?: PairingRpcContext + // Why: set only for an authenticated runtime-scope device and bound to it, so it manages only its own phones. + delegatedMobileDevices?: { + sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult + } // Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths. sendBinary?: (bytes: Uint8Array) => boolean | void // Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cca01d83588..ae92fd9fa63 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -1,6 +1,6 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' -import type { PairingRpcContext } from './core' +import type { PairingRpcContext, RpcContext } from './core' import type { RpcCallerIdentity } from './rpc-caller-identity' export type RpcDispatchStreamingOptions = { @@ -15,6 +15,7 @@ export type RpcDispatchStreamingOptions = { clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void pairing?: PairingRpcContext + delegatedMobileDevices?: RpcContext['delegatedMobileDevices'] sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( streamId: number, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 5a32ddab62f..7ecfd659b1d 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -1,4 +1,8 @@ import { defineMethod } from '../core' +import { + DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + DelegatedMobileDeviceSyncParamsSchema +} from '../../../../shared/delegated-mobile-device-contract' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema @@ -24,5 +28,15 @@ export const PAIRING_METHODS = [ } return await ctx.pairing.provisionRelay(params) } + }), + defineMethod({ + name: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + params: DelegatedMobileDeviceSyncParamsSchema, + handler: (params, ctx) => { + if (!ctx.delegatedMobileDevices) { + throw new Error('runtime_device_required') + } + return ctx.delegatedMobileDevices.sync(params.phones) + } }) ] diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 7dc5dc0586a..56216da2a44 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -150,6 +150,7 @@ export class RpcStreamingDispatcher { recordMutationReceipt: mutation?.recordReceipt, orchestrationMutation: mutation?.identity, pairing: options?.pairing, + delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, registerBinaryMessageHandler: options?.registerBinaryMessageHandler, @@ -199,6 +200,7 @@ export class RpcStreamingDispatcher { clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, pairing: options?.pairing, + delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, registerBinaryMessageHandler: options?.registerBinaryMessageHandler, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index efa05957f40..91ccc414a3e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -20,7 +20,7 @@ import { sweepOrphanedRuntimeSockets } from './runtime-rpc-socket-metadata' -export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { +export abstract class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { async start(): Promise { if (this.activeTransports.length > 0) { return diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts index ee7e4c08d1a..caa7e072578 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts @@ -3,7 +3,7 @@ import { writeWsFallbackPort } from '../rpc/ws-fallback-port-store' import { RuntimeRpcLifecycle } from './runtime-rpc-lifecycle' import { WS_BIND_HOST_ALL_INTERFACES, WS_BIND_HOST_LOOPBACK } from './runtime-rpc-pairing-types' -export class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { +export abstract class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing // 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 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 7d8bba9f958..df86e0bfd27 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -9,6 +9,10 @@ import type { import type { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure' import { @@ -86,6 +90,10 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { } async revokeMobileDevice(deviceId: string): Promise { + return this.revokeMobileDeviceNow(deviceId) + } + + private revokeMobileDeviceNow(deviceId: string): boolean { const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'mobile') { return false @@ -109,7 +117,13 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { revokeRuntimeAccess(deviceId: string): boolean { const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { + if (device?.scope !== 'runtime') { + return false + } + for (const child of this.deviceRegistry?.listDelegatedMobileDevices(deviceId) ?? []) { + this.revokeMobileDeviceNow(child.deviceId) + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { return false } this.runtime.forgetClientNavigationState(deviceId) @@ -117,6 +131,31 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { return true } + /** Make the runtime device `parentDeviceId`'s phones exactly `phones`; dropped ones are fully revoked. */ + protected syncDelegatedMobileDevices( + parentDeviceId: string, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceSyncResult { + const registry = this.deviceRegistry + const parent = registry?.getDevice(parentDeviceId) + if (!registry || parent?.scope !== 'runtime') { + throw new Error('delegated_device_parent_unavailable') + } + const wanted = new Set(phones.map((phone) => phone.phoneKey)) + for (const child of registry.listDelegatedMobileDevices(parentDeviceId)) { + if (!child.phoneKey || !wanted.has(child.phoneKey)) { + this.revokeMobileDeviceNow(child.deviceId) + } + } + return { + devices: registry.upsertDelegatedMobileDevices(parent, phones).map((device) => ({ + phoneKey: device.phoneKey, + deviceId: device.deviceId, + token: device.token + })) + } + } + getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index dd714b77528..09b1363d14b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -3,6 +3,10 @@ import type { PairingGetEndpointsParams, PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' import { fingerprintAuthenticatedPairingCredential } from '../rpc/orchestration-mutation-executor' import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring' import type { RpcRequest, RpcResponse } from '../rpc/core' @@ -26,7 +30,13 @@ function injectDeviceScope(response: string, scope: DeviceScope): string { } } -export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { +export abstract class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { + // Why: the pairing layer owns device revocation; dispatch only binds the authenticated parent. + protected abstract syncDelegatedMobileDevices( + parentDeviceId: string, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceSyncResult + // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. protected async handleWebSocketMessage( rawMessage: string, @@ -148,6 +158,10 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { } : undefined, pairing: pairingContext, + delegatedMobileDevices: + device.scope === 'runtime' + ? { sync: (phones) => this.syncDelegatedMobileDevices(device.deviceId, phones) } + : undefined, signal: abortRegistration?.signal, sendBinary, registerBinaryStreamHandler: (streamId, handler) => diff --git a/src/shared/delegated-mobile-device-contract.ts b/src/shared/delegated-mobile-device-contract.ts new file mode 100644 index 00000000000..9a0032eee27 --- /dev/null +++ b/src/shared/delegated-mobile-device-contract.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import type { RUNTIME_CAPABILITIES } from './protocol-version' + +// Why: a paired desktop relays each of its phones to this host as an ordinary phone device it owns. +export const DELEGATED_MOBILE_DEVICE_SYNC_METHOD = 'pairing.delegatedMobileDevice.sync' +// Why: a desktop relays phones only to a host advertising this; `satisfies` ties it to RUNTIME_CAPABILITIES. +export const DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY = + 'pairing.delegated-mobile-devices.v1' satisfies (typeof RUNTIME_CAPABILITIES)[number] +export const DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES = 32 + +const DelegatedPhoneSchema = z + .object({ + phoneKey: z.string().min(1).max(128), + name: z.string().min(1).max(128) + }) + .strict() + +export const DelegatedMobileDeviceSyncParamsSchema = z + .object({ + phones: z + .array(DelegatedPhoneSchema) + .max(DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES) + .refine((phones) => new Set(phones.map((phone) => phone.phoneKey)).size === phones.length, { + message: 'duplicate phoneKey' + }) + }) + .strict() + +export type DelegatedPhone = z.infer +export type DelegatedMobileDeviceSyncParams = z.infer + +export type DelegatedMobileDeviceSyncResult = { + devices: { phoneKey: string; deviceId: string; token: string }[] +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d27c8751cd5..72dbb0b02f8 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -434,6 +434,7 @@ export const RUNTIME_CAPABILITIES = [ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + 'pairing.delegated-mobile-devices.v1', ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 27c4ee41ded..4398f8abbf5 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -18,6 +18,7 @@ import { BrowserNetworkTunnelAttachParams } from '../browser-client-host-protocol' import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol' +import { DelegatedMobileDeviceSyncParamsSchema } from '../delegated-mobile-device-contract' import { PairingGetEndpointsParamsSchema, PairingProvisionRelayParamsSchema @@ -1090,6 +1091,7 @@ export const RPC_PARAMS_BY_METHOD = { 'orchestration.workerStart': WorkerStartParams, 'orchestration.workerStop': WorkerDispatchParamsOfOrchestrationWorkerStopParams, 'orchestration.workerTerminalUserInput': OrchestrationWorkerTerminalUserInputParams, + 'pairing.delegatedMobileDevice.sync': DelegatedMobileDeviceSyncParamsSchema, 'pairing.getEndpoints': PairingGetEndpointsParamsSchema, 'pairing.provisionRelay': PairingProvisionRelayParamsSchema, 'plugins.consent': pluginConsentRequestSchema, From 509389157f12d3352dd56ea6e7cdea5b44390ba3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:05:03 -0400 Subject: [PATCH 2/4] refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../runtime-rpc/runtime-rpc-lifecycle.ts | 2 +- .../runtime-rpc/runtime-rpc-mobile-devices.ts | 95 ++++++++++++++++++ .../runtime-rpc-network-exposure.ts | 2 +- .../runtime-rpc/runtime-rpc-pairing.ts | 96 +------------------ .../runtime-rpc-websocket-dispatch.ts | 14 +-- 5 files changed, 100 insertions(+), 109 deletions(-) create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 91ccc414a3e..efa05957f40 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -20,7 +20,7 @@ import { sweepOrphanedRuntimeSockets } from './runtime-rpc-socket-metadata' -export abstract class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { +export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { async start(): Promise { if (this.activeTransports.length > 0) { return diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts new file mode 100644 index 00000000000..3a7281e6838 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -0,0 +1,95 @@ +import type { RelayDeviceBinding, RelayRevokeOutboxItem } from '../relay/relay-revoke-outbox' +import type { + DelegatedPhone, + DelegatedMobileDeviceSyncResult +} from '../../../shared/delegated-mobile-device-contract' +import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' + +// Why: below dispatch so a caller-bound RPC context can revoke and mint mobile devices. +export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { + private onPushUnregisterQueued?: () => void + + async revokeMobileDevice(deviceId: string): Promise { + return this.revokeMobileDeviceNow(deviceId) + } + + protected revokeMobileDeviceNow(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile') { + return false + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } + } + // Why: unpairing must delete the phone's push token at the gateway too, and the + // registration id is only readable while the device row still exists. + this.queuePushUnregister(deviceId, device.pushRegistration?.registrationId) + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + /** Make the runtime device `parentDeviceId`'s phones exactly `phones`; dropped ones are fully revoked. */ + protected syncDelegatedMobileDevices( + parentDeviceId: string, + phones: readonly DelegatedPhone[] + ): DelegatedMobileDeviceSyncResult { + const registry = this.deviceRegistry + const parent = registry?.getDevice(parentDeviceId) + if (!registry || parent?.scope !== 'runtime') { + throw new Error('delegated_device_parent_unavailable') + } + const wanted = new Set(phones.map((phone) => phone.phoneKey)) + for (const child of registry.listDelegatedMobileDevices(parentDeviceId)) { + if (!child.phoneKey || !wanted.has(child.phoneKey)) { + this.revokeMobileDeviceNow(child.deviceId) + } + } + return { + devices: registry.upsertDelegatedMobileDevices(parent, phones).map((device) => ({ + phoneKey: device.phoneKey, + deviceId: device.deviceId, + token: device.token + })) + } + } + + /** Best-effort: a failed enqueue must never block the revoke the user asked for. */ + protected queuePushUnregister(deviceId: string, registrationId: string | undefined): void { + if (!registrationId) { + return + } + try { + this.pushUnregisterOutbox.enqueue({ registrationId, deviceId }) + this.onPushUnregisterQueued?.() + } catch (error) { + console.error('[runtime] Failed to persist a push token cleanup:', error) + } + } + + setOnPushUnregisterQueued(callback: (() => void) | null): void { + this.onPushUnregisterQueued = callback ?? undefined + } + + protected queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts index caa7e072578..ee7e4c08d1a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts @@ -3,7 +3,7 @@ import { writeWsFallbackPort } from '../rpc/ws-fallback-port-store' import { RuntimeRpcLifecycle } from './runtime-rpc-lifecycle' import { WS_BIND_HOST_ALL_INTERFACES, WS_BIND_HOST_LOOPBACK } from './runtime-rpc-pairing-types' -export abstract class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { +export class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing // 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 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index df86e0bfd27..04b560d9512 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -1,18 +1,10 @@ import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' -import type { - RelayDeviceBinding, - RelayRevokeOutbox, - RelayRevokeOutboxItem -} from '../relay/relay-revoke-outbox' +import type { RelayDeviceBinding, RelayRevokeOutbox } from '../relay/relay-revoke-outbox' import type { PushUnregisterOutbox } from '../push/push-unregister-outbox' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' -import type { - DelegatedPhone, - DelegatedMobileDeviceSyncResult -} from '../../../shared/delegated-mobile-device-contract' import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure' import { @@ -25,8 +17,6 @@ import { } from './runtime-rpc-pairing-types' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { - private onPushUnregisterQueued?: () => void - getDeviceRegistry(): DeviceRegistry | null { return this.deviceRegistry } @@ -89,32 +79,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProvider = provider } - async revokeMobileDevice(deviceId: string): Promise { - return this.revokeMobileDeviceNow(deviceId) - } - - private revokeMobileDeviceNow(deviceId: string): boolean { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'mobile') { - return false - } - if (device.relayBinding) { - if (!this.queueRelayDeviceRevoke(device.relayBinding)) { - return false - } - } - // Why: unpairing must delete the phone's push token at the gateway too, and the - // registration id is only readable while the device row still exists. - this.queuePushUnregister(deviceId, device.pushRegistration?.registrationId) - if (!this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.mobileRelayPairingProvider?.onDemandStateChanged?.() - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - revokeRuntimeAccess(deviceId: string): boolean { const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'runtime') { @@ -131,31 +95,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { return true } - /** Make the runtime device `parentDeviceId`'s phones exactly `phones`; dropped ones are fully revoked. */ - protected syncDelegatedMobileDevices( - parentDeviceId: string, - phones: readonly DelegatedPhone[] - ): DelegatedMobileDeviceSyncResult { - const registry = this.deviceRegistry - const parent = registry?.getDevice(parentDeviceId) - if (!registry || parent?.scope !== 'runtime') { - throw new Error('delegated_device_parent_unavailable') - } - const wanted = new Set(phones.map((phone) => phone.phoneKey)) - for (const child of registry.listDelegatedMobileDevices(parentDeviceId)) { - if (!child.phoneKey || !wanted.has(child.phoneKey)) { - this.revokeMobileDeviceNow(child.deviceId) - } - } - return { - devices: registry.upsertDelegatedMobileDevices(parent, phones).map((device) => ({ - phoneKey: device.phoneKey, - deviceId: device.deviceId, - token: device.token - })) - } - } - getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null @@ -231,23 +170,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { } } - /** Best-effort: a failed enqueue must never block the revoke the user asked for. */ - protected queuePushUnregister(deviceId: string, registrationId: string | undefined): void { - if (!registrationId) { - return - } - try { - this.pushUnregisterOutbox.enqueue({ registrationId, deviceId }) - this.onPushUnregisterQueued?.() - } catch (error) { - console.error('[runtime] Failed to persist a push token cleanup:', error) - } - } - - setOnPushUnregisterQueued(callback: (() => void) | null): void { - this.onPushUnregisterQueued = callback ?? undefined - } - protected queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { if (this.queueRelayDeviceRevoke(binding)) { return @@ -258,20 +180,4 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) } } - - protected queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { - let item: RelayRevokeOutboxItem - try { - item = this.relayRevokeOutbox.enqueue(binding) - } catch (error) { - console.error('[runtime] Failed to persist Relay device cleanup:', error) - return false - } - try { - this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) - } catch (error) { - console.warn('[runtime] Failed to notify Relay cleanup worker:', error) - } - return true - } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 09b1363d14b..a3e1322ccd8 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -3,16 +3,12 @@ import type { PairingGetEndpointsParams, PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' -import type { - DelegatedPhone, - DelegatedMobileDeviceSyncResult -} from '../../../shared/delegated-mobile-device-contract' import { fingerprintAuthenticatedPairingCredential } from '../rpc/orchestration-mutation-executor' import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring' import type { RpcRequest, RpcResponse } from '../rpc/core' import type { WebSocketTransport } from '../rpc/ws-transport' import type { DeviceScope } from '../device-registry' -import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' +import { RuntimeRpcMobileDevices } from './runtime-rpc-mobile-devices' import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist' @@ -30,13 +26,7 @@ function injectDeviceScope(response: string, scope: DeviceScope): string { } } -export abstract class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { - // Why: the pairing layer owns device revocation; dispatch only binds the authenticated parent. - protected abstract syncDelegatedMobileDevices( - parentDeviceId: string, - phones: readonly DelegatedPhone[] - ): DelegatedMobileDeviceSyncResult - +export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. protected async handleWebSocketMessage( rawMessage: string, From 3d8bb0681581cf2b74328049387c33447f14df62 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:08:47 -0400 Subject: [PATCH 3/4] fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...evice-allowlist-census.integration.test.ts | 150 ------------------ ...elegated-mobile-device.integration.test.ts | 73 ++++++++- src/main/runtime/delegated-mobile-devices.ts | 5 +- src/main/runtime/device-credential.ts | 6 + src/main/runtime/device-registry.ts | 5 +- .../runtime-rpc/runtime-rpc-mobile-devices.ts | 21 ++- .../runtime-rpc/runtime-rpc-pairing.ts | 16 -- 7 files changed, 100 insertions(+), 176 deletions(-) delete mode 100644 src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts create mode 100644 src/main/runtime/device-credential.ts diff --git a/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts b/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts deleted file mode 100644 index fc3697fabe0..00000000000 --- a/src/main/runtime/delegated-mobile-device-allowlist-census.integration.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { z } from 'zod' -import { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { - DELEGATED_MOBILE_DEVICE_SYNC_METHOD, - type DelegatedMobileDeviceSyncResult -} from '../../shared/delegated-mobile-device-contract' -import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' -import { - authenticate, - createReader, - makeStore, - send, - type PairedSession, - type ResponseReader -} from './paired-client-navigation-test-harness' - -vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue([]), - listWorktreesStrict: vi.fn().mockResolvedValue([]) -})) - -const FIRST_FRAME_TIMEOUT_MS = 1_500 -const ErrorCodeSchema = z.object({ error: z.object({ code: z.string() }) }) - -describe('delegated phone allowlist census', () => { - const cleanups: (() => Promise | void)[] = [] - afterEach(async () => { - for (const cleanup of cleanups.splice(0).toReversed()) { - await cleanup() - } - }) - - it('every allowlisted method answers a delegated phone exactly as it answers a direct phone', async () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the navigation fixture store carries every surface the dispatcher reads here. - const runtime = new OrcaRuntimeService(makeStore() as never) - const server = new OrcaRuntimeRpcServer({ - runtime, - userDataPath: mkdtempSync(join(tmpdir(), 'orca-delegated-census-')), - enableWebSocket: true, - wsPort: 0 - }) - await server.start() - cleanups.push(() => server.stop()) - - const directOffer = server.createPairingOffer({ - address: '127.0.0.1', - name: 'direct', - scope: 'mobile' - }) - if (!directOffer.available) { - throw new Error('pairing unavailable') - } - const direct = parsePairingCode(directOffer.pairingUrl)! - const desktopOffer = server.createPairingOffer({ - address: '127.0.0.1', - name: 'Mac', - scope: 'runtime' - }) - if (!desktopOffer.available) { - throw new Error('pairing unavailable') - } - const desktop = parsePairingCode(desktopOffer.pairingUrl)! - const synced = await sendRemoteRuntimeRequest( - desktop, - DELEGATED_MOBILE_DEVICE_SYNC_METHOD, - { phones: [{ phoneKey: 'p', name: 'p via Mac' }] }, - 5_000 - ) - if (!synced.ok) { - throw new Error('sync failed') - } - const child = synced.result.devices[0]! - const delegated = { - ...desktop, - deviceToken: child.token, - pairedDeviceId: child.deviceId, - scope: 'mobile' as const - } - - const sessions: [PairedSession, ResponseReader][] = [] - for (const pairing of [direct, delegated]) { - const session = await authenticate(encodePairingOffer(pairing)) - const reader = createReader(session) - sessions.push([session, reader]) - cleanups.push(() => { - reader.dispose() - session.ws.close() - }) - } - const identities: [string, string][] = [ - [direct.deviceToken, ''], - [direct.pairedDeviceId!, ''], - [child.token, ''], - [child.deviceId, ''] - ] - const normalize = (response: Record | 'no-first-frame'): string => { - if (response === 'no-first-frame') { - return response - } - const { _meta: _ignored, id: _id, ...rest } = response - let text = JSON.stringify(rest) - for (const [value, placeholder] of identities) { - text = text.split(value).join(placeholder) - } - // Why: ready-stream ids embed the per-socket connection id and a server-wide counter. - return text.replace(/-[0-9a-f]{16}-\d+"/g, '--"') - } - const firstFrame = (reader: ResponseReader, id: string) => - Promise.race([ - reader.next(id), - new Promise<'no-first-frame'>((resolve) => - setTimeout(() => resolve('no-first-frame'), FIRST_FRAME_TIMEOUT_MS) - ) - ]) - - const mismatches: { method: string; direct: string; delegated: string }[] = [] - const outcomes = new Map() - const methods = [...MOBILE_RPC_METHOD_ALLOWLIST].sort() - for (const method of methods) { - const results = await Promise.all( - sessions.map(([session, reader], index) => { - const id = `${method}#${index}` - send(session, { id, method, params: {} }) - return firstFrame(reader, id) - }) - ) - const [directText, delegatedText] = results.map(normalize) - const code = - results[0] === 'no-first-frame' - ? 'no-first-frame' - : results[0]!.ok - ? 'ok' - : String(ErrorCodeSchema.safeParse(results[0]).data?.error.code) - outcomes.set(code, (outcomes.get(code) ?? 0) + 1) - if (directText !== delegatedText) { - mismatches.push({ method, direct: directText!, delegated: delegatedText! }) - } - } - // Presence precondition: the probe reached handlers, not only a blanket refusal. - expect(outcomes.get('ok')).toBeGreaterThan(0) - expect(mismatches).toEqual([]) - }, 120_000) -}) diff --git a/src/main/runtime/delegated-mobile-device.integration.test.ts b/src/main/runtime/delegated-mobile-device.integration.test.ts index afaeaa761e9..f1ae68d551d 100644 --- a/src/main/runtime/delegated-mobile-device.integration.test.ts +++ b/src/main/runtime/delegated-mobile-device.integration.test.ts @@ -38,8 +38,7 @@ describe('pairing.delegatedMobileDevice.sync', () => { } }) - async function startServer() { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-')) + async function startServer(userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-'))) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling WebSocket tests. const runtime = new OrcaRuntimeService(makeStore() as never) const server = new OrcaRuntimeRpcServer({ @@ -84,6 +83,10 @@ describe('pairing.delegatedMobileDevice.sync', () => { return response.result.devices } + function sendSync(desktop: PairingOffer, phones: unknown) { + return sendRemoteRuntimeRequest(desktop, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, { phones }, 5_000) + } + function asPhone(desktop: PairingOffer, device: DelegatedDevice): PairingOffer { return { ...desktop, @@ -159,7 +162,10 @@ describe('pairing.delegatedMobileDevice.sync', () => { method: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, params: { phones: [{ phoneKey: 'p', name: 'p' }] } }) - ).resolves.toMatchObject({ ok: false }) + ).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: 'runtime_device_required' } + }) expect(registry.listDevices().filter((device) => device.parentDeviceId)).toEqual([]) }) @@ -227,6 +233,67 @@ describe('pairing.delegatedMobileDevice.sync', () => { await closed }) + it('renames a child in place and rejects duplicate or too many phones', async () => { + const { server, registry, userDataPath } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'iPhone via MacBook' }]) + expect(await sync(desktop, [{ phoneKey: 'p', name: 'iPad via MacBook' }])).toEqual([child]) + expect(new DeviceRegistry(userDataPath).getDevice(child!.deviceId)?.name).toBe( + 'iPad via MacBook' + ) + + const tooMany = Array.from({ length: 33 }, (_, index) => ({ phoneKey: `p${index}`, name: 'n' })) + for (const phones of [ + [ + { phoneKey: 'p', name: 'a' }, + { phoneKey: 'p', name: 'b' } + ], + tooMany + ]) { + await expect(sendSync(desktop, phones)).resolves.toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + } + expect(registry.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toHaveLength(1) + }) + + it('keeps the parent when a child cleanup cannot be saved', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect( + server.setMobileRelayBinding(child!.deviceId, { + relayHostId: 'host', + relayDeviceId: child!.deviceId, + ownerIdentityKey: 'owner' + }) + ).toBe(true) + const enqueue = vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + cleanups.push(() => enqueue.mockRestore()) + + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(false) + expect(registry.getDevice(desktop.pairedDeviceId!)).not.toBeNull() + expect(registry.getDevice(child!.deviceId)).not.toBeNull() + }) + + it('keeps the parent link across a restart, so the cascade still applies', async () => { + const first = await startServer() + const desktop = pairDesktop(first.server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + await first.server.stop() + + const reloaded = new DeviceRegistry(first.userDataPath) + expect(reloaded.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toMatchObject([ + { deviceId: child!.deviceId, phoneKey: 'p', parentDeviceId: desktop.pairedDeviceId } + ]) + const { server, registry } = await startServer(first.userDataPath) + expect(server.revokeRuntimeAccess(desktop.pairedDeviceId!)).toBe(true) + expect(registry.getDevice(child!.deviceId)).toBeNull() + }) + it('loads a registry written before delegated devices existed unchanged', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-delegated-old-')) const old = [ diff --git a/src/main/runtime/delegated-mobile-devices.ts b/src/main/runtime/delegated-mobile-devices.ts index b7ec051c4cf..1919ef2b7e2 100644 --- a/src/main/runtime/delegated-mobile-devices.ts +++ b/src/main/runtime/delegated-mobile-devices.ts @@ -1,4 +1,4 @@ -import { randomBytes, randomUUID } from 'node:crypto' +import { mintDeviceCredential } from './device-credential' import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' import type { DeviceEntry } from './device-registry' @@ -26,9 +26,8 @@ export function planDelegatedMobileDevices( return renamed } const entry: DelegatedMobileDeviceEntry = { - deviceId: randomUUID(), + ...mintDeviceCredential(), name: phone.name, - token: randomBytes(24).toString('hex'), scope: 'mobile', pairedAt: now, // Why: never pending — the QR lookups select lastSeenAt === 0 and must not hand out or rotate a child. diff --git a/src/main/runtime/device-credential.ts b/src/main/runtime/device-credential.ts new file mode 100644 index 00000000000..15791b15256 --- /dev/null +++ b/src/main/runtime/device-credential.ts @@ -0,0 +1,6 @@ +import { randomBytes, randomUUID } from 'node:crypto' + +/** A fresh device id and its bearer token; every registry entry is minted here. */ +export function mintDeviceCredential(): { deviceId: string; token: string } { + return { deviceId: randomUUID(), token: randomBytes(24).toString('hex') } +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index d80d526739b..14ccb7fc66a 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -2,7 +2,6 @@ // (mobile) connections. Each paired device gets its own revocable token so // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. -import { randomBytes, randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { @@ -13,6 +12,7 @@ import { import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { mintDeviceCredential } from './device-credential' 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' @@ -103,9 +103,8 @@ export class DeviceRegistry { pairingReach: RuntimePairingReach ): DeviceEntry { const entry: DeviceEntry = { - deviceId: randomUUID(), + ...mintDeviceCredential(), name, - token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), lastSeenAt: 0, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts index 3a7281e6838..f915ac13cd7 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -13,7 +13,7 @@ export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { return this.revokeMobileDeviceNow(deviceId) } - protected revokeMobileDeviceNow(deviceId: string): boolean { + private revokeMobileDeviceNow(deviceId: string): boolean { const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'mobile') { return false @@ -35,6 +35,25 @@ export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { return true } + revokeRuntimeAccess(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'runtime') { + return false + } + for (const child of this.deviceRegistry?.listDelegatedMobileDevices(deviceId) ?? []) { + // Why: keep the parent until every child's cleanup is saved, so a retry can still find them. + if (!this.revokeMobileDeviceNow(child.deviceId)) { + return false + } + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + /** Make the runtime device `parentDeviceId`'s phones exactly `phones`; dropped ones are fully revoked. */ protected syncDelegatedMobileDevices( parentDeviceId: string, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 04b560d9512..b2f3b990369 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -79,22 +79,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProvider = provider } - revokeRuntimeAccess(deviceId: string): boolean { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'runtime') { - return false - } - for (const child of this.deviceRegistry?.listDelegatedMobileDevices(deviceId) ?? []) { - this.revokeMobileDeviceNow(child.deviceId) - } - if (!this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - getWebSocketEndpoint(): string | null { const ws = this.transports.find((t) => t.kind === 'websocket') return ws?.endpoint ?? null From a680a40676b0a39bdbb09c6593637a9e26b94f10 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 00:12:28 -0400 Subject: [PATCH 4/4] fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...elegated-mobile-device.integration.test.ts | 25 +++++++++++++++++++ src/main/runtime/device-registry.ts | 1 + .../runtime-rpc/runtime-rpc-mobile-devices.ts | 7 ++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/delegated-mobile-device.integration.test.ts b/src/main/runtime/delegated-mobile-device.integration.test.ts index f1ae68d551d..274e600a87d 100644 --- a/src/main/runtime/delegated-mobile-device.integration.test.ts +++ b/src/main/runtime/delegated-mobile-device.integration.test.ts @@ -279,6 +279,31 @@ describe('pairing.delegatedMobileDevice.sync', () => { expect(registry.getDevice(child!.deviceId)).not.toBeNull() }) + it('fails a sync whose dropped child cannot be revoked, keeping that child', async () => { + const { server, registry } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + expect( + server.setMobileRelayBinding(child!.deviceId, { + relayHostId: 'host', + relayDeviceId: child!.deviceId, + ownerIdentityKey: 'owner' + }) + ).toBe(true) + const enqueue = vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => { + throw new Error('disk full') + }) + cleanups.push(() => enqueue.mockRestore()) + + await expect(sendSync(desktop, [{ phoneKey: 'q', name: 'q' }])).resolves.toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: 'delegated_device_revoke_failed' } + }) + expect(registry.listDelegatedMobileDevices(desktop.pairedDeviceId!)).toMatchObject([ + { deviceId: child!.deviceId } + ]) + }) + it('keeps the parent link across a restart, so the cascade still applies', async () => { const first = await startServer() const desktop = pairDesktop(first.server) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 14ccb7fc66a..63699b1d043 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -162,6 +162,7 @@ export class DeviceRegistry { scope: DeviceScope = 'mobile', pairingReach: RuntimePairingReach = 'network' ): DeviceEntry { + // Why: a pending runtime device has no delegated children — its first auth sets lastSeenAt before any sync. const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope) return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach) } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts index f915ac13cd7..bf5eb52b4cc 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -66,8 +66,11 @@ export class RuntimeRpcMobileDevices extends RuntimeRpcRequestAdmission { } const wanted = new Set(phones.map((phone) => phone.phoneKey)) for (const child of registry.listDelegatedMobileDevices(parentDeviceId)) { - if (!child.phoneKey || !wanted.has(child.phoneKey)) { - this.revokeMobileDeviceNow(child.deviceId) + if ( + (!child.phoneKey || !wanted.has(child.phoneKey)) && + !this.revokeMobileDeviceNow(child.deviceId) + ) { + throw new Error('delegated_device_revoke_failed') } } return {