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
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
|
|
|
|
const TERMINAL_STREAM_KIND = 0x74
|
|
const TERMINAL_STREAM_VERSION = 1
|
|
const HEADER_BYTES = 16
|
|
export const TERMINAL_STREAM_JSON_MAX_BYTES = 8 * 1024 * 1024
|
|
export const TERMINAL_STREAM_JSON_STRUCTURE_LIMITS = {
|
|
structuralTokens: 256 * 1024,
|
|
nestingDepth: 32
|
|
} as const
|
|
|
|
export enum TerminalStreamOpcode {
|
|
Output = 1,
|
|
SnapshotStart = 2,
|
|
SnapshotChunk = 3,
|
|
SnapshotEnd = 4,
|
|
Resized = 5,
|
|
Error = 6,
|
|
Input = 7,
|
|
Resize = 8,
|
|
Subscribe = 9,
|
|
Unsubscribe = 10,
|
|
SnapshotRequest = 11,
|
|
Metadata = 12,
|
|
// Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only
|
|
// remote-multiplex flow control) renumbers to stay wire-compatible.
|
|
Ack = 13,
|
|
// Why 14: Ack already occupies 13 on current clients; older runtimes ignore
|
|
// this opcode and still receive the compatibility Resize frame behind it.
|
|
ClaimViewport = 14,
|
|
OutputSpan = 15,
|
|
// Negotiated per stream; older hosts reject unknown opcodes, so clients send only after capability confirmation.
|
|
SetOutputPaused = 16,
|
|
// Negotiated per stream because older clients reject unknown opcodes.
|
|
WriteUnavailable = 17,
|
|
// Host->client; `seq` is the cumulative input sequence the host applied (see encodeTerminalInputAck). Sent only after `inputAck` negotiation.
|
|
InputAck = 18
|
|
}
|
|
|
|
export type TerminalInputAckKind = 'applied' | 'delivery-unknown' | 'resend'
|
|
|
|
const INPUT_ACK_FLAGS: Record<TerminalInputAckKind, number> = {
|
|
applied: 0,
|
|
// The write at `seq` may have reached the terminal; the client must not resend it.
|
|
'delivery-unknown': 1,
|
|
// A refusal or gap: the host needs everything after `seq` sent again, in order.
|
|
resend: 2
|
|
}
|
|
|
|
/** InputAck payload; `seq` is the host's cumulative applied input sequence. */
|
|
export function encodeTerminalInputAck(kind: TerminalInputAckKind): Uint8Array {
|
|
return kind === 'applied' ? new Uint8Array() : Uint8Array.of(INPUT_ACK_FLAGS[kind])
|
|
}
|
|
|
|
export function decodeTerminalInputAck(payload: Uint8Array): TerminalInputAckKind {
|
|
return payload[0] === INPUT_ACK_FLAGS.resend
|
|
? 'resend'
|
|
: payload[0] === INPUT_ACK_FLAGS['delivery-unknown']
|
|
? 'delivery-unknown'
|
|
: 'applied'
|
|
}
|
|
|
|
export type TerminalStreamFrame = {
|
|
opcode: TerminalStreamOpcode
|
|
streamId: number
|
|
seq: number
|
|
payload: Uint8Array
|
|
}
|
|
|
|
export function encodeTerminalStreamFrame(frame: TerminalStreamFrame): Uint8Array {
|
|
const out = new Uint8Array(HEADER_BYTES + frame.payload.length)
|
|
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
|
|
view.setUint8(0, TERMINAL_STREAM_KIND)
|
|
view.setUint8(1, TERMINAL_STREAM_VERSION)
|
|
view.setUint8(2, frame.opcode)
|
|
view.setUint8(3, 0)
|
|
view.setUint32(4, frame.streamId, true)
|
|
const seq = Math.max(0, Math.floor(frame.seq))
|
|
view.setUint32(8, Math.floor(seq / 0x100000000), true)
|
|
view.setUint32(12, seq >>> 0, true)
|
|
out.set(frame.payload, HEADER_BYTES)
|
|
return out
|
|
}
|
|
|
|
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
|
if (bytes.length < HEADER_BYTES) {
|
|
return null
|
|
}
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
if (view.getUint8(0) !== TERMINAL_STREAM_KIND || view.getUint8(1) !== TERMINAL_STREAM_VERSION) {
|
|
return null
|
|
}
|
|
const opcode = view.getUint8(2)
|
|
if (!isTerminalStreamOpcode(opcode)) {
|
|
return null
|
|
}
|
|
const high = view.getUint32(8, true)
|
|
const low = view.getUint32(12, true)
|
|
return {
|
|
opcode,
|
|
streamId: view.getUint32(4, true),
|
|
seq: high * 0x100000000 + low,
|
|
payload: bytes.slice(HEADER_BYTES)
|
|
}
|
|
}
|
|
|
|
/** The stream id in a terminal frame's header, or null when the bytes are not a terminal frame. */
|
|
export function readTerminalStreamFrameId(bytes: Uint8Array): number | null {
|
|
if (
|
|
bytes.length < HEADER_BYTES ||
|
|
bytes[0] !== TERMINAL_STREAM_KIND ||
|
|
bytes[1] !== TERMINAL_STREAM_VERSION
|
|
) {
|
|
return null
|
|
}
|
|
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(4, true)
|
|
}
|
|
|
|
/** Overwrites the stream id of a frame `readTerminalStreamFrameId` accepted. */
|
|
export function writeTerminalStreamFrameId(bytes: Uint8Array, streamId: number): void {
|
|
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(4, streamId, true)
|
|
}
|
|
|
|
export function encodeTerminalStreamJson(value: unknown): Uint8Array {
|
|
return new TextEncoder().encode(JSON.stringify(value))
|
|
}
|
|
|
|
export function decodeTerminalStreamJson<T>(payload: Uint8Array): T | null {
|
|
if (payload.byteLength > TERMINAL_STREAM_JSON_MAX_BYTES) {
|
|
return null
|
|
}
|
|
try {
|
|
const content = new TextDecoder().decode(payload)
|
|
assertJsonTextStructureWithinLimits(content, TERMINAL_STREAM_JSON_STRUCTURE_LIMITS)
|
|
return JSON.parse(content) as T
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function encodeTerminalStreamText(value: string): Uint8Array {
|
|
return new TextEncoder().encode(value)
|
|
}
|
|
|
|
export function decodeTerminalStreamText(payload: Uint8Array): string {
|
|
return new TextDecoder().decode(payload)
|
|
}
|
|
|
|
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
|
return (
|
|
value === TerminalStreamOpcode.Output ||
|
|
value === TerminalStreamOpcode.SnapshotStart ||
|
|
value === TerminalStreamOpcode.SnapshotChunk ||
|
|
value === TerminalStreamOpcode.SnapshotEnd ||
|
|
value === TerminalStreamOpcode.Resized ||
|
|
value === TerminalStreamOpcode.Error ||
|
|
value === TerminalStreamOpcode.Input ||
|
|
value === TerminalStreamOpcode.Resize ||
|
|
value === TerminalStreamOpcode.Subscribe ||
|
|
value === TerminalStreamOpcode.Unsubscribe ||
|
|
value === TerminalStreamOpcode.SnapshotRequest ||
|
|
value === TerminalStreamOpcode.Metadata ||
|
|
value === TerminalStreamOpcode.Ack ||
|
|
value === TerminalStreamOpcode.ClaimViewport ||
|
|
value === TerminalStreamOpcode.OutputSpan ||
|
|
value === TerminalStreamOpcode.SetOutputPaused ||
|
|
value === TerminalStreamOpcode.WriteUnavailable ||
|
|
value === TerminalStreamOpcode.InputAck
|
|
)
|
|
}
|