Files
orca/src/shared/remote-runtime-request-websocket.test.ts
T
Jinwoo Hong ff5c284cc1 feat(mobile): relay a paired phone's server-workspace requests through the desktop (hidden) (#26435)
* 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

* feat(mobile): relay a paired phone's server-workspace requests through the desktop

A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop
now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per
(phone, server), signed in as the phone's delegated device; replies pass through untouched except
stream ids. Hidden until the phone client ships: the relay capability is not advertised yet.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): state the desktop relay contract in its entry module

main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a
server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a
later request syncs it again.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the relay's configured-server adapter

The production hosts adapter was only typechecked; this pins that it resolves configured servers
by exact id only (never a name or a removed server), fences by pairing revision and runtime
identity, and stops reporting retirements once unsubscribed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten the desktop relay after elegance review

Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request
whose link ended while opening, passes failure codes through, relays only this desktop's own
phones, and drops the unused host-state reader until S2 needs one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): bound relay frames and stop sticky relay verdicts

Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on
each new link so an in-place upgrade relays, answers a phone the server just declined without
syncing again, and ends a link whose mirrored capability update cannot be queued.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): run targeted desktop-owned calls on the desktop

The phone tags every call on a workspace screen with its execution host, so a
paired-desktop method carrying `runtime:<env>` now runs locally instead of
failing 'forbidden'. The Mac stays the only reader of the routing table.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(ci): run the remote-runtime WebSocket opener test on Node

Under the bun vitest project, `ws` resolves to Bun's built-in client, which
ignores maxPayload, so the frame-cap test hung. The opener only ships on
Node/Electron, so run its test in the Node runtime project like its siblings.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile-relay): derive the phone allowlist from the routing table

Adding a phone method now takes one edit (its route). A server-targeted call on a
legacy non-E2EE socket is refused instead of running locally, and only
execution-host methods enter the relay.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-10-11 03:12:24 -04:00

83 lines
2.7 KiB
TypeScript

import type { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { generateKeyPair, publicKeyToBase64 } from './e2ee-crypto'
import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket'
import { REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES } from './remote-runtime-memory-limits'
import { WebSocketServer } from 'ws'
describe('openRemoteRuntimeWebSocket', () => {
it('detaches Orca callback listeners when cleaned up', () => {
const keyPair = generateKeyPair()
const opened = openRemoteRuntimeWebSocket(
{
v: 2,
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'device-token',
publicKeyB64: publicKeyToBase64(keyPair.publicKey)
},
{
onClose: vi.fn(),
onError: vi.fn(),
onTextFrame: vi.fn()
}
)
if (!opened.ok) {
throw opened.error
}
const socketEvents = opened.socket.ws as unknown as EventEmitter
expect(socketEvents.listenerCount('open')).toBe(1)
expect(socketEvents.listenerCount('close')).toBe(1)
expect(socketEvents.listenerCount('message')).toBe(1)
expect(socketEvents.listenerCount('error')).toBe(1)
opened.socket.cleanup()
opened.socket.cleanup()
expect(socketEvents.listenerCount('open')).toBe(0)
expect(socketEvents.listenerCount('close')).toBe(0)
expect(socketEvents.listenerCount('message')).toBe(0)
expect(socketEvents.listenerCount('error')).toBe(1)
expect(() => socketEvents.emit('error', new Error('late socket error'))).not.toThrow()
opened.socket.ws.terminate()
})
it('closes on a frame larger than any runtime sends', async () => {
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
server.on('connection', (ws) =>
ws.send(Buffer.alloc(REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES + 1))
)
await new Promise((resolve) => server.once('listening', resolve))
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
const onError = vi.fn()
const onBinaryFrame = vi.fn()
const closed = new Promise<void>((resolve) => {
const opened = openRemoteRuntimeWebSocket(
{
v: 2,
endpoint: `ws://127.0.0.1:${port}`,
deviceToken: 'device-token',
publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey)
},
{
onClose: () => resolve(),
onError,
onTextFrame: vi.fn(),
onBinaryFrame
}
)
if (!opened.ok) {
throw opened.error
}
})
try {
await closed
expect(onError).toHaveBeenCalledOnce()
expect(onBinaryFrame).not.toHaveBeenCalled()
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
})