mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
* 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
83 lines
2.7 KiB
TypeScript
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))
|
|
}
|
|
})
|
|
})
|