Merge branch 'feat/runtime-delegated-mobile-devices' into feat/desktop-mobile-relay

Takes S1a's contract file verbatim (plural capability name) and moves the unpair re-sync into its
RuntimeRpcMobileDevices; the relay integration test now syncs against the real server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-08 00:22:34 -04:00
17 changed files with 613 additions and 121 deletions
@@ -0,0 +1,334 @@
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> | 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<OrcaRuntimeRpcServer['createPairingOffer']>[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<DelegatedDevice[]> {
const response = await sendRemoteRuntimeRequest<DelegatedMobileDeviceSyncResult>(
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('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([])
})
})
@@ -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 }
}
+6
View File
@@ -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') }
}
+32 -4
View File
@@ -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,9 +12,15 @@ 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 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 {
@@ -95,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,
@@ -155,10 +162,28 @@ 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)
}
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) {
@@ -2,7 +2,7 @@ import { z } from 'zod'
import {
DELEGATED_MOBILE_DEVICE_SYNC_METHOD,
DELEGATED_MOBILE_DEVICE_SYNC_MAX_PHONES,
DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY,
DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY,
type DelegatedMobileDeviceSyncParams
} from '../../../shared/delegated-mobile-device-contract'
import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error'
@@ -36,7 +36,7 @@ export async function syncDelegatedPhoneGrants(
throw new RemoteRuntimeClientError('remote_runtime_unavailable', status.error.message)
}
const capabilities = StatusCapabilitiesSchema.safeParse(status.result).data?.capabilities ?? []
if (!capabilities.includes(DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY)) {
if (!capabilities.includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) {
return { kind: 'update-needed', fence: host.fence }
}
const response = await hosts.call(host, DELEGATED_MOBILE_DEVICE_SYNC_METHOD, {
@@ -15,11 +15,11 @@ import {
} from '../../../shared/e2ee-crypto'
import { parsePairingCode, type PairingOffer } from '../../../shared/pairing'
import {
DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY,
DELEGATED_MOBILE_DEVICE_SYNC_METHOD,
DelegatedMobileDeviceSyncParamsSchema
} from '../../../shared/delegated-mobile-device-contract'
import { decodeTerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { DeviceEntry } from '../device-registry'
import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime-client'
import { OrcaRuntimeService } from '../orca-runtime'
import { readRuntimeMetadata } from '../runtime-metadata'
import { OrcaRuntimeRpcServer } from '../runtime-rpc'
@@ -142,43 +142,27 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
const desktop = await startRuntime(desktopSpawns)
const desktopOnHost = pair(host.server, 'runtime', 'MacBook')
const registry = host.server.getDeviceRegistry()!
const children = new Map<string, DeviceEntry>()
const children = () => registry.listDelegatedMobileDevices(desktopOnHost.pairedDeviceId!)
const syncedNames: string[][] = []
const state = { capable: true, retire: (_id: string) => {} }
const ok = (result: unknown) => ({
id: 'x',
ok: true as const,
result,
_meta: { runtimeId: 'h' }
})
const hosts: MobileDesktopRelayHosts = {
resolve: async (environmentId) =>
environmentId === 'env-1'
? { environmentId, fence: 'pairing-1', pairing: desktopOnHost }
: null,
call: async (_host, method, params) => {
if (method === 'status.get') {
return ok({
capabilities: state.capable ? [DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY] : []
})
if (method === DELEGATED_MOBILE_DEVICE_SYNC_METHOD) {
const { phones } = DelegatedMobileDeviceSyncParamsSchema.parse(params)
syncedNames.push(phones.map((phone) => phone.name))
}
// Stand-in for the server's sync (lane S1a): mint or return ordinary phone devices on the host.
const { phones } = DelegatedMobileDeviceSyncParamsSchema.parse(params)
syncedNames.push(phones.map((phone) => phone.name))
for (const phone of phones) {
const existing = children.get(phone.phoneKey)
if (!existing || !registry.getDevice(existing.deviceId)) {
const device = registry.addDevice(phone.name, 'mobile')
registry.updateLastSeen(device.deviceId)
children.set(phone.phoneKey, device)
}
}
return ok({
devices: phones.map((phone) => {
const device = children.get(phone.phoneKey)!
return { phoneKey: phone.phoneKey, deviceId: device.deviceId, token: device.token }
})
})
const response = await sendRemoteRuntimeRequest(desktopOnHost, method, params, 5_000)
// An older server: the same status without the delegated-devices capability.
return method === 'status.get' && !state.capable && response.ok
? {
...response,
result: { ...z.object({}).passthrough().parse(response.result), capabilities: [] }
}
: response
},
onEnvironmentRetired: (listener) => {
state.retire = listener
@@ -247,6 +231,7 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
const token = pairing.deviceToken
return {
token,
deviceId: pairing.pairedDeviceId!,
frames,
binaries,
close: () => ws.close(),
@@ -309,7 +294,7 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
).toBe(true)
)
expect(syncedNames).toEqual([[`iPhone via ${desktop.runtime.readMachineName()}`]])
const delegated = [...children.values()][0]!
const delegated = children()[0]!
// The host seats the phone's delegated device, never the desktop's token for it.
expect(host.runtime.seatClientIds('pty-shared')).toEqual([delegated.token])
@@ -339,7 +324,7 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
const streamA = await subscribe(phoneA, handle)
const streamB = await subscribe(phoneB, handle)
expect(streamA).not.toBe(streamB)
const tokens = [...children.values()].map((device) => device.token)
const tokens = children().map((device) => device.token)
expect(new Set(tokens).size).toBe(2)
expect(host.runtime.seatClientIds('pty-shared').sort()).toEqual([...tokens].sort())
@@ -376,11 +361,25 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
).toHaveLength(1)
})
it('drops a phone from the server as soon as the desktop unpairs it', async () => {
const { host, desktop, children, handle } = await startTopology()
const kept = await connectPhone(desktop.server, 'Kept')
const unpaired = await connectPhone(desktop.server, 'Unpaired')
await subscribe(kept, handle)
expect(children().map((device) => device.phoneKey)).toEqual([kept.deviceId, unpaired.deviceId])
await desktop.server.revokeMobileDevice(unpaired.deviceId)
await vi.waitFor(() =>
expect(children().map((device) => device.phoneKey)).toEqual([kept.deviceId])
)
expect(host.runtime.seatClientIds('pty-shared')).toEqual([children()[0]!.token])
})
it('stops relaying a phone the server revoked until the next sync', async () => {
const { host, desktop, children, state, handle } = await startTopology()
const phone = await connectPhone(desktop.server, 'iPhone')
await subscribe(phone, handle)
const revoked = [...children.values()][0]!
const revoked = children()[0]!
await host.server.revokeMobileDevice(revoked.deviceId)
// The open stream ends as unavailable, never as an unpaired phone.
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error'
import type { RemoteRuntimePassthroughCallbacks } from '../../../shared/remote-runtime-passthrough-socket'
import { MobileDesktopRelay, type RelayedPhone } from './mobile-desktop-relay'
@@ -46,7 +46,7 @@ function relayWithPhone() {
}),
call: async (_host, method) =>
method === 'status.get'
? ok({ capabilities: [DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY] })
? ok({ capabilities: [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] })
: ok({ devices: [{ phoneKey: 'phone-1', deviceId: 'child', token: 'host-token' }] }),
onEnvironmentRetired: () => () => {}
}
+8
View File
@@ -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<ArrayBufferLike>) => boolean | void
// Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created.
@@ -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<ArrayBufferLike>) => boolean | void
registerBinaryStreamHandler?: (
streamId: number,
+14
View File
@@ -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)
}
})
]
@@ -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,
@@ -0,0 +1,121 @@
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<boolean> {
const revoked = this.revokeMobileDeviceNow(deviceId)
if (revoked) {
this.mobileDesktopRelay?.phonesChanged()
}
return revoked
}
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
}
}
@@ -1,11 +1,7 @@
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'
@@ -25,8 +21,6 @@ import {
} from './runtime-rpc-pairing-types'
export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
private onPushUnregisterQueued?: () => void
getDeviceRegistry(): DeviceRegistry | null {
return this.deviceRegistry
}
@@ -116,39 +110,6 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
}))
}
async revokeMobileDevice(deviceId: string): Promise<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)
this.mobileDesktopRelay?.phonesChanged()
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
@@ -224,23 +185,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
@@ -251,20 +195,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
}
}
@@ -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 { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist'
import { parseExecutionHostId } from '../../../shared/execution-host'
@@ -27,7 +27,7 @@ function injectDeviceScope(response: string, scope: DeviceScope): string {
}
}
export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission {
export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
// Why: a phone names a server's workspace with `executionHost`; only configured servers are reached,
// and a target the desktop cannot reach is an error, never a silent local run.
private relayMobileRequest(
@@ -209,6 +209,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) =>
@@ -1,7 +1,11 @@
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
@@ -22,12 +26,9 @@ export const DelegatedMobileDeviceSyncParamsSchema = z
})
.strict()
export type DelegatedPhone = z.infer<typeof DelegatedPhoneSchema>
export type DelegatedMobileDeviceSyncParams = z.infer<typeof DelegatedMobileDeviceSyncParamsSchema>
export type DelegatedMobileDeviceSyncResult = {
devices: { phoneKey: string; deviceId: string; token: string }[]
}
// Why: the paired desktop relays a phone to this host only when the host can mint its delegated device.
export const DELEGATED_MOBILE_DEVICE_SYNC_RUNTIME_CAPABILITY =
'pairing.delegated-mobile-device.v1' as const
+1
View File
@@ -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
+2
View File
@@ -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,