mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user