From de00a4f03c3e1c9bc0ceda03ecd09b8b87e51634 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:54:09 -0400 Subject: [PATCH] feat(runtime): let a paired desktop register its phones as phone devices (#26427) * 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 * 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 * 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 * 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 * fix(runtime): refuse pairing admin to phones delegated through a paired desktop A delegated child phone could call the mobile-allowlisted pairing-admin methods (push registration, relay provisioning) that its parent desktop is never granted. The caller scope now marks delegated phones and denies them that permission. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...elegated-mobile-device.integration.test.ts | 354 ++++++++++++++++++ src/main/runtime/delegated-mobile-devices.ts | 43 +++ src/main/runtime/device-credential.ts | 6 + .../runtime/device-registry-row-validation.ts | 39 ++ src/main/runtime/device-registry.ts | 72 ++-- src/main/runtime/rpc/core.ts | 8 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + src/main/runtime/rpc/methods/pairing.ts | 16 + src/main/runtime/rpc/rpc-caller-scope.ts | 13 +- .../runtime-rpc/runtime-rpc-mobile-devices.ts | 117 ++++++ .../runtime-rpc/runtime-rpc-pairing.ts | 73 +--- .../runtime-rpc-websocket-dispatch.ts | 10 +- .../delegated-mobile-device-contract.ts | 34 ++ src/shared/protocol-version.ts | 1 + .../rpc-params-catalog.generated.ts | 2 + 15 files changed, 673 insertions(+), 117 deletions(-) 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/main/runtime/device-credential.ts create mode 100644 src/main/runtime/device-registry-row-validation.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts create mode 100644 src/shared/delegated-mobile-device-contract.ts 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..1435e5c1466 --- /dev/null +++ b/src/main/runtime/delegated-mobile-device.integration.test.ts @@ -0,0 +1,354 @@ +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(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 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, + 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, + error: { code: 'runtime_error', message: 'runtime_device_required' } + }) + expect(registry.listDevices().filter((device) => device.parentDeviceId)).toEqual([]) + }) + + it('refuses a child the pairing admin its parent was never granted', async () => { + const { server } = await startServer() + const desktop = pairDesktop(server) + const [child] = await sync(desktop, [{ phoneKey: 'p', name: 'p' }]) + const directPhone = pair(server, { name: 'phone', scope: 'mobile' }) + const registerPush = (caller: PairingOffer) => + sendRemoteRuntimeRequest( + caller, + 'notifications.registerPush', + { platform: 'android', token: 'fcm-token', filter: {} }, + 5_000 + ) + + await expect(registerPush(asPhone(desktop, child!))).resolves.toMatchObject({ + ok: false, + error: { code: 'forbidden' } + }) + await expect(registerPush(directPhone)).resolves.toMatchObject({ ok: true }) + }) + + 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('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('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) + 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 = [ + { 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..1919ef2b7e2 --- /dev/null +++ b/src/main/runtime/delegated-mobile-devices.ts @@ -0,0 +1,43 @@ +import { mintDeviceCredential } from './device-credential' +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 = { + ...mintDeviceCredential(), + name: phone.name, + 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-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-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts new file mode 100644 index 00000000000..54c22ef97ad --- /dev/null +++ b/src/main/runtime/device-registry-row-validation.ts @@ -0,0 +1,39 @@ +import type { DeviceScope } from '../../shared/runtime-types' +import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' + +const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) + +function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { + return typeof value === 'string' && GRANTABLE.has(value) +} + +export function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { + if (scope !== 'runtime' || !Array.isArray(value)) { + return undefined + } + const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() + return grants.length > 0 ? grants : undefined +} + +export function validRelayBinding( + value: unknown, + deviceId: string +): RelayDeviceBinding | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const binding = value as Partial + return binding.relayDeviceId === deviceId && + typeof binding.relayHostId === 'string' && + typeof binding.ownerIdentityKey === 'string' + ? { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) + } + : undefined +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 5e310ff19ce..78fffa300bf 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,10 +12,17 @@ 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' -import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' +import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract' +import { + planDelegatedMobileDevices, + type DelegatedMobileDeviceEntry +} from './delegated-mobile-devices' +import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission' +import { validGrants, validRelayBinding } from './device-registry-row-validation' import { parseMobilePushRegistration, type MobilePushRegistration @@ -39,48 +45,18 @@ 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 // Why: administrative permissions are granted only when pairing; absent on older rows means none. grants?: RuntimeDeviceGrant[] } -const GRANTABLE: ReadonlySet = new Set(RUNTIME_DEVICE_GRANTS) - -function isRuntimeDeviceGrant(value: unknown): value is RuntimeDeviceGrant { - return typeof value === 'string' && GRANTABLE.has(value) -} - -function validGrants(value: unknown, scope: DeviceScope): RuntimeDeviceGrant[] | undefined { - if (scope !== 'runtime' || !Array.isArray(value)) { - return undefined - } - const grants = [...new Set(value.filter(isRuntimeDeviceGrant))].sort() - return grants.length > 0 ? grants : undefined -} - function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean { const current = entry.grants ?? [] return current.length === grants.length && current.every((grant) => grants.includes(grant)) } -function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const binding = value as Partial - return binding.relayDeviceId === deviceId && - typeof binding.relayHostId === 'string' && - typeof binding.ownerIdentityKey === 'string' - ? { - relayHostId: binding.relayHostId, - relayDeviceId: binding.relayDeviceId, - ownerIdentityKey: binding.ownerIdentityKey, - ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) - ? { inviteExpiresAt: binding.inviteExpiresAt } - : {}) - } - : undefined -} - // Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of // paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection. const LAST_SEEN_FLUSH_DELAY_MS = 250 @@ -120,9 +96,8 @@ export class DeviceRegistry { ): DeviceEntry { const validatedGrants = validGrants(grants, scope) const entry: DeviceEntry = { - deviceId: randomUUID(), + ...mintDeviceCredential(), name, - token: randomBytes(24).toString('hex'), scope, pairedAt: Date.now(), lastSeenAt: 0, @@ -187,10 +162,28 @@ export class DeviceRegistry { pairingReach: RuntimePairingReach = 'network', grants: readonly RuntimeDeviceGrant[] = [] ): 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, grants) } + 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) { @@ -366,7 +359,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 85ab2c94f5e..ac9756c63bd 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' @@ -114,6 +118,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 6587825ac82..9c6b2447be2 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -16,6 +16,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'clientCapabilities' | 'updateClientCapabilities' | 'pairing' + | 'delegatedMobileDevices' | 'sendBinary' | 'registerBinaryStreamHandler' | 'registerBinaryMessageHandler' @@ -48,6 +49,7 @@ export function rpcContextFromTransport( updateClientCapabilities: options?.updateClientCapabilities, authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, pairing: options?.pairing, + delegatedMobileDevices: options?.delegatedMobileDevices, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, registerBinaryMessageHandler: options?.registerBinaryMessageHandler, diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index 775bcea7580..e6d366681b8 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 @@ -26,5 +30,17 @@ export const PAIRING_METHODS = [ } return await ctx.pairing.provisionRelay(params) } + }), + defineMethod({ + name: DELEGATED_MOBILE_DEVICE_SYNC_METHOD, + // Why: paired desktops call this and are never granted pairing-admin; children reach no further (rpc-caller-scope.ts). + permission: 'workspace', + 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-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index ecb1a0fb430..9b1bd33c0e3 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -19,7 +19,9 @@ export type { RuntimeDeviceGrant } export type RpcCallerScope = | { kind: 'owner' } - | { kind: 'mobile' } + /** `delegated`: a phone a paired desktop holds a child device for; it reaches no further than that + * desktop, which is never granted `pairing-admin`. */ + | { kind: 'mobile'; delegated?: boolean } | { kind: 'runtime-paired'; grants: readonly RuntimeDeviceGrant[] } /** The `orca` CLI of a remote SSH host, relayed through this host's bridge. `remoteCliControl` * is the per-target opt-in that lets that host's CLI act outside its own terminals. */ @@ -51,9 +53,12 @@ export function denyRpcMethodForCaller( permission: RpcMethodPermission | undefined ): string | null { if (scope.kind === 'mobile') { - return MOBILE_RPC_METHOD_ALLOWLIST.has(methodName) - ? null - : `Method '${methodName}' is not available to mobile clients` + if (!MOBILE_RPC_METHOD_ALLOWLIST.has(methodName)) { + return `Method '${methodName}' is not available to mobile clients` + } + return scope.delegated && permission === 'pairing-admin' + ? `Method '${methodName}' is not available to a phone connected through a paired desktop` + : null } if (scope.kind === 'owner' || permission === undefined) { return null 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..bf5eb52b4cc --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-devices.ts @@ -0,0 +1,117 @@ +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) + } + + 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') { + 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, + 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) + ) { + throw new Error('delegated_device_revoke_failed') + } + } + 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-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 1cd22660652..7c60b590eff 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -2,11 +2,7 @@ import type { RuntimeDeviceGrant } from '../rpc/rpc-caller-scope' 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' @@ -22,8 +18,6 @@ import { } from './runtime-rpc-pairing-types' export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { - private onPushUnregisterQueued?: () => void - getDeviceRegistry(): DeviceRegistry | null { return this.deviceRegistry } @@ -90,38 +84,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayPairingProviderInstaller = install } - async revokeMobileDevice(deviceId: string): Promise { - 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' || !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 @@ -203,23 +165,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 @@ -230,20 +175,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 67869763db8..e761255222c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -8,7 +8,7 @@ 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 type { RpcCallerScope } from '../rpc/rpc-caller-scope' import { limitRuntimeRpcReplySize } from './runtime-rpc-reply-size-limit' @@ -27,7 +27,7 @@ function injectDeviceScope(response: string, scope: DeviceScope): string { } } -export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { +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, @@ -77,7 +77,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { // Why: the dispatcher enforces this scope's permissions; it never trusts a request field. const callerScope: RpcCallerScope = device.scope === 'mobile' - ? { kind: 'mobile' } + ? { kind: 'mobile', delegated: device.parentDeviceId !== undefined } : { kind: 'runtime-paired', grants: device.grants ?? [] } // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. @@ -146,6 +146,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 075e6a26d29..c63eca1e11c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -436,6 +436,7 @@ export const RUNTIME_CAPABILITIES = [ ...SKILL_RUNTIME_CAPABILITIES, ...AUTOMATION_RUNTIME_CAPABILITIES, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + 'pairing.delegated-mobile-devices.v1', PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, WORKSPACE_PORTS_HOST_SCOPED_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index c3aa9708862..5955b80dafe 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 @@ -1109,6 +1110,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,