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
This commit is contained in:
Jinwoo Hong
2026-10-11 02:54:09 -04:00
committed by GitHub
parent 7abee44e78
commit de00a4f03c
15 changed files with 673 additions and 117 deletions
@@ -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> | 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('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([])
})
})
@@ -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') }
}
@@ -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<string> = 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<RelayDeviceBinding>
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
}
+34 -38
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,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<string> = 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<RelayDeviceBinding>
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) {
+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'
@@ -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<ArrayBufferLike>) => boolean | void
// Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created.
@@ -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,
+16
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
@@ -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)
}
})
]
+9 -4
View File
@@ -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
@@ -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<boolean> {
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
}
}
@@ -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<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' || !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
}
}
@@ -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) =>
@@ -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<typeof DelegatedPhoneSchema>
export type DelegatedMobileDeviceSyncParams = z.infer<typeof DelegatedMobileDeviceSyncParamsSchema>
export type DelegatedMobileDeviceSyncResult = {
devices: { phoneKey: string; deviceId: string; token: string }[]
}
+1
View File
@@ -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,
+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
@@ -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,