mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* fix(relay): refuse silent fallback when pairing invite fails When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options. * fix issues
5443 lines
181 KiB
TypeScript
5443 lines
181 KiB
TypeScript
/* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */
|
|
import { existsSync, mkdirSync, mkdtempSync } from 'node:fs'
|
|
import { rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { createConnection, type Socket } from 'node:net'
|
|
import { EventEmitter } from 'node:events'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import WebSocket from 'ws'
|
|
import Database from '../sqlite/sync-database'
|
|
import { OrcaRuntimeService } from './orca-runtime'
|
|
import { OrchestrationDb } from './orchestration/db'
|
|
import * as runtimeMetadataModule from './runtime-metadata'
|
|
import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
|
|
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
|
|
import { parsePairingCode } from '../../shared/pairing'
|
|
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
|
import {
|
|
TerminalStreamOpcode,
|
|
decodeTerminalStreamFrame,
|
|
decodeTerminalStreamText,
|
|
encodeTerminalStreamFrame,
|
|
encodeTerminalStreamJson,
|
|
encodeTerminalStreamText
|
|
} from '../../shared/terminal-stream-protocol'
|
|
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
|
import { DeviceRegistry } from './device-registry'
|
|
import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files'
|
|
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
|
|
|
vi.mock('../git/worktree', () => ({
|
|
listWorktrees: vi.fn().mockResolvedValue([
|
|
{
|
|
path: '/tmp/worktree-a',
|
|
head: 'abc',
|
|
branch: 'feature/foo',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
]),
|
|
listWorktreesStrict: vi.fn().mockResolvedValue([])
|
|
}))
|
|
|
|
async function sendRequest(
|
|
endpoint: string,
|
|
request: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
return await new Promise((resolve, reject) => {
|
|
const socket = createConnection(endpoint)
|
|
let buffer = ''
|
|
socket.setEncoding('utf8')
|
|
socket.once('error', reject)
|
|
socket.on('data', (chunk: string) => {
|
|
buffer += chunk
|
|
const newlineIndex = buffer.indexOf('\n')
|
|
if (newlineIndex === -1) {
|
|
return
|
|
}
|
|
const message = buffer.slice(0, newlineIndex)
|
|
socket.end()
|
|
resolve(JSON.parse(message) as Record<string, unknown>)
|
|
})
|
|
socket.on('connect', () => {
|
|
socket.write(`${JSON.stringify(withCurrentOrchestrationContract(request))}\n`)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Why: long-poll keepalive tests need every frame, not just the first, because
|
|
// we need to count `_keepalive` frames before the terminal success/failure.
|
|
// Also exposes the socket so tests can close it mid-wait to exercise the
|
|
// long-poll counter decrement path.
|
|
type FramedSession = {
|
|
socket: Socket
|
|
frames: Record<string, unknown>[]
|
|
done: Promise<void>
|
|
}
|
|
|
|
function openFramedSession(endpoint: string, request: Record<string, unknown>): FramedSession {
|
|
const frames: Record<string, unknown>[] = []
|
|
const socket = createConnection(endpoint)
|
|
let buffer = ''
|
|
socket.setEncoding('utf8')
|
|
const done = new Promise<void>((resolve, reject) => {
|
|
socket.once('error', (err) => {
|
|
// Why: ECONNRESET is expected when we deliberately destroy the socket
|
|
// mid-wait to probe the counter decrement; surface other errors.
|
|
if ((err as NodeJS.ErrnoException).code === 'ECONNRESET') {
|
|
resolve()
|
|
return
|
|
}
|
|
reject(err)
|
|
})
|
|
socket.on('close', () => resolve())
|
|
socket.on('data', (chunk: string) => {
|
|
buffer += chunk
|
|
let newlineIndex = buffer.indexOf('\n')
|
|
while (newlineIndex !== -1) {
|
|
const raw = buffer.slice(0, newlineIndex).trim()
|
|
buffer = buffer.slice(newlineIndex + 1)
|
|
if (raw) {
|
|
const frame = JSON.parse(raw) as Record<string, unknown>
|
|
frames.push(frame)
|
|
// Why: the server leaves the socket open after writing the terminal
|
|
// frame (short RPCs expect the client to close); close the client
|
|
// side so `done` resolves once we've captured the response.
|
|
if (frame._keepalive !== true) {
|
|
socket.end()
|
|
}
|
|
}
|
|
newlineIndex = buffer.indexOf('\n')
|
|
}
|
|
})
|
|
socket.on('connect', () => {
|
|
socket.write(`${JSON.stringify(withCurrentOrchestrationContract(request))}\n`)
|
|
})
|
|
})
|
|
return { socket, frames, done }
|
|
}
|
|
|
|
function withCurrentOrchestrationContract(
|
|
request: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
return typeof request.method === 'string' && request.method.startsWith('orchestration.')
|
|
? { ...request, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION }
|
|
: request
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
const start = Date.now()
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error('timed out waiting for condition')
|
|
}
|
|
await sleep(20)
|
|
}
|
|
}
|
|
|
|
function seedSupervisedAskWorkers(db: OrchestrationDb, workerHandles: string[]): void {
|
|
const run = db.createRun({
|
|
objective: 'Exercise ask admission',
|
|
coordinatorHandle: 'term_coord',
|
|
coordinatorPaneKey: 'tab_coord:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
|
})
|
|
for (const workerHandle of workerHandles) {
|
|
const task = db.createTask({ spec: 'Wait for coordinator input', runId: run.id })
|
|
db.createDispatchContext(task.id, workerHandle)
|
|
}
|
|
}
|
|
|
|
function connectWs(endpoint: string): Promise<WebSocket> {
|
|
return new Promise((resolve, reject) => {
|
|
const ws = new WebSocket(endpoint)
|
|
ws.once('open', () => resolve(ws))
|
|
ws.once('error', reject)
|
|
})
|
|
}
|
|
|
|
function nextWsMessage(ws: WebSocket): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
ws.once('message', (data) => {
|
|
resolve(typeof data === 'string' ? data : data.toString('utf-8'))
|
|
})
|
|
})
|
|
}
|
|
|
|
function waitForWsClose(ws: WebSocket): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (ws.readyState === ws.CLOSED) {
|
|
resolve()
|
|
return
|
|
}
|
|
ws.once('close', () => resolve())
|
|
})
|
|
}
|
|
|
|
type AuthenticatedMobileWs = {
|
|
ws: WebSocket
|
|
sharedKey: Uint8Array
|
|
}
|
|
|
|
async function authenticateMobileWsSession(pairingUrl: string): Promise<AuthenticatedMobileWs> {
|
|
const parsed = parsePairingCode(pairingUrl)
|
|
expect(parsed).toBeTruthy()
|
|
const ws = await connectWs(parsed!.endpoint)
|
|
const mobileKeys = generateKeyPair()
|
|
const serverPublicKey = Uint8Array.from(Buffer.from(parsed!.publicKeyB64, 'base64'))
|
|
const sharedKey = deriveSharedKey(mobileKeys.secretKey, serverPublicKey)
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'e2ee_hello',
|
|
publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64')
|
|
})
|
|
)
|
|
expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' })
|
|
|
|
ws.send(
|
|
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: parsed!.deviceToken }), sharedKey)
|
|
)
|
|
expect(JSON.parse(decrypt(await nextWsMessage(ws), sharedKey)!)).toEqual({
|
|
type: 'e2ee_authenticated'
|
|
})
|
|
|
|
return { ws, sharedKey }
|
|
}
|
|
|
|
async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
|
|
return (await authenticateMobileWsSession(pairingUrl)).ws
|
|
}
|
|
|
|
function sendEncryptedWsRequest(
|
|
session: AuthenticatedMobileWs,
|
|
request: Record<string, unknown>
|
|
): void {
|
|
session.ws.send(encrypt(JSON.stringify(request), session.sharedKey))
|
|
}
|
|
|
|
function createEncryptedWsResponseReader(session: AuthenticatedMobileWs): {
|
|
next: (
|
|
id: string,
|
|
predicate?: (response: Record<string, unknown>) => boolean
|
|
) => Promise<Record<string, unknown>>
|
|
dispose: () => void
|
|
} {
|
|
type Waiter = {
|
|
id: string
|
|
predicate: (response: Record<string, unknown>) => boolean
|
|
resolve: (response: Record<string, unknown>) => void
|
|
}
|
|
const queue: Record<string, unknown>[] = []
|
|
const waiters: Waiter[] = []
|
|
|
|
const takeQueued = (
|
|
id: string,
|
|
predicate: (response: Record<string, unknown>) => boolean
|
|
): Record<string, unknown> | null => {
|
|
const index = queue.findIndex((response) => response.id === id && predicate(response))
|
|
if (index === -1) {
|
|
return null
|
|
}
|
|
const [response] = queue.splice(index, 1)
|
|
return response ?? null
|
|
}
|
|
|
|
const onMessage = (data: WebSocket.RawData): void => {
|
|
const decrypted = decrypt(
|
|
typeof data === 'string' ? data : data.toString('utf-8'),
|
|
session.sharedKey
|
|
)
|
|
expect(decrypted).toBeTruthy()
|
|
const response = JSON.parse(decrypted!) as Record<string, unknown>
|
|
const waiterIndex = waiters.findIndex(
|
|
(waiter) => response.id === waiter.id && waiter.predicate(response)
|
|
)
|
|
if (waiterIndex === -1) {
|
|
queue.push(response)
|
|
return
|
|
}
|
|
const [waiter] = waiters.splice(waiterIndex, 1)
|
|
waiter?.resolve(response)
|
|
}
|
|
|
|
session.ws.on('message', onMessage)
|
|
|
|
return {
|
|
next: (id: string, predicate: (response: Record<string, unknown>) => boolean = () => true) => {
|
|
const queued = takeQueued(id, predicate)
|
|
if (queued) {
|
|
return Promise.resolve(queued)
|
|
}
|
|
return new Promise<Record<string, unknown>>((resolve) => {
|
|
waiters.push({ id, predicate, resolve })
|
|
})
|
|
},
|
|
dispose: () => {
|
|
session.ws.off('message', onMessage)
|
|
waiters.length = 0
|
|
queue.length = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
class FakeWebSocket extends EventEmitter {
|
|
readonly OPEN = 1
|
|
readyState = this.OPEN
|
|
}
|
|
|
|
describe('OrcaRuntimeRpcServer', () => {
|
|
const makeStore = (overrides?: { isUnread?: boolean }) => ({
|
|
getRepo: (id: string) =>
|
|
makeStore(overrides)
|
|
.getRepos()
|
|
.find((repo) => repo.id === id),
|
|
getRepos: () => [
|
|
{
|
|
id: 'repo-1',
|
|
path: '/tmp/repo',
|
|
displayName: 'repo',
|
|
badgeColor: 'blue',
|
|
addedAt: 1
|
|
}
|
|
],
|
|
addRepo: () => {},
|
|
updateRepo: (id: string, updates: Record<string, unknown>) =>
|
|
({
|
|
...makeStore(overrides).getRepo(id),
|
|
...updates
|
|
}) as never,
|
|
getAllWorktreeMeta: () => ({
|
|
'repo-1::/tmp/worktree-a': {
|
|
displayName: 'foo',
|
|
comment: '',
|
|
linkedIssue: 123,
|
|
linkedPR: null,
|
|
linkedLinearIssue: null,
|
|
isArchived: false,
|
|
isUnread: overrides?.isUnread ?? false,
|
|
isPinned: false,
|
|
sortOrder: 0,
|
|
lastActivityAt: 0
|
|
}
|
|
}),
|
|
getWorktreeMeta: (worktreeId: string) =>
|
|
worktreeId === 'repo-1::/tmp/worktree-a'
|
|
? (makeStore(overrides).getAllWorktreeMeta()[worktreeId] as never)
|
|
: undefined,
|
|
setWorktreeMeta: (_worktreeId: string, meta: Record<string, unknown>) =>
|
|
({
|
|
...makeStore(overrides).getAllWorktreeMeta()['repo-1::/tmp/worktree-a'],
|
|
...meta
|
|
}) as never,
|
|
removeWorktreeMeta: () => {},
|
|
getSettings: () => ({
|
|
workspaceDir: '/tmp/workspaces',
|
|
nestWorkspaces: false,
|
|
branchPrefix: 'none',
|
|
branchPrefixCustom: ''
|
|
})
|
|
})
|
|
|
|
it('writes runtime metadata with transport details when started', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
expect(metadata?.runtimeId).toBe(runtime.getRuntimeId())
|
|
expect(metadata?.authToken).toBeTruthy()
|
|
expect(metadata?.transports?.[0]?.endpoint).toBeTruthy()
|
|
expect(metadata?.transports).toEqual(server['transports'])
|
|
|
|
await server.stop()
|
|
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
|
|
runtimeId: runtime.getRuntimeId()
|
|
})
|
|
})
|
|
|
|
it('reclaims runtime metadata clobbered by a second instance that has since died', async () => {
|
|
// Why: #7848 — a launch that slips past the single-instance lock republishes
|
|
// orca-runtime.json with its own pid, so the CLI reports stale_bootstrap
|
|
// against this still-serving runtime once that instance exits.
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
await server.start()
|
|
const published = readRuntimeMetadata(userDataPath)
|
|
|
|
writeRuntimeMetadata(userDataPath, {
|
|
runtimeId: 'rt_second_instance',
|
|
pid: 99999999,
|
|
transports: [{ kind: 'unix', endpoint: join(userDataPath, 'o-99999999-rt2.sock') }],
|
|
authToken: 'second-instance-token',
|
|
startedAt: 1
|
|
})
|
|
server.checkRuntimeMetadataOwnership()
|
|
|
|
expect(readRuntimeMetadata(userDataPath)).toEqual(published)
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('leaves runtime metadata owned by a live sibling runtime untouched', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
// Why: a synthetic owned pid frees the always-alive process.pid to stand in for
|
|
// the sibling — Windows never assigns pid 1, so hardcoding it there reads as dead.
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
pid: 4242
|
|
})
|
|
await server.start()
|
|
|
|
writeRuntimeMetadata(userDataPath, {
|
|
runtimeId: 'rt_live_sibling',
|
|
pid: process.pid,
|
|
transports: [{ kind: 'unix', endpoint: join(userDataPath, `o-${process.pid}-rt2.sock`) }],
|
|
authToken: 'sibling-token',
|
|
startedAt: 1
|
|
})
|
|
server.checkRuntimeMetadataOwnership()
|
|
|
|
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_live_sibling' })
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('stops reclaiming runtime metadata after the server is stopped', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), userDataPath })
|
|
await server.start()
|
|
const watch = server['metadataOwnershipWatch']
|
|
if (!watch) {
|
|
throw new Error('start() must arm the metadata ownership watch')
|
|
}
|
|
// Why: the republish guard alone would keep this test green, so assert the timer teardown itself.
|
|
const watchStop = vi.spyOn(watch, 'stop')
|
|
await server.stop()
|
|
|
|
writeRuntimeMetadata(userDataPath, {
|
|
runtimeId: 'rt_second_instance',
|
|
pid: 99999999,
|
|
transports: [],
|
|
authToken: 'second-instance-token',
|
|
startedAt: 1
|
|
})
|
|
server.checkRuntimeMetadataOwnership()
|
|
|
|
expect(watchStop).toHaveBeenCalledTimes(1)
|
|
expect(server['metadataOwnershipWatch']).toBeNull()
|
|
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' })
|
|
})
|
|
|
|
it('creates a pairing offer for the active WebSocket transport', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
webClientRoot: userDataPath
|
|
})
|
|
|
|
await server.start()
|
|
|
|
const offer = server.createPairingOffer({ address: '100.64.1.20', name: 'CLI test' })
|
|
expect(offer.available).toBe(true)
|
|
if (offer.available) {
|
|
expect(offer.endpoint).toContain('100.64.1.20')
|
|
const parsed = parsePairingCode(offer.pairingUrl)
|
|
expect(parsed?.endpoint).toBe(offer.endpoint)
|
|
expect(parsed?.deviceToken).toBeTruthy()
|
|
expect(parsed?.publicKeyB64).toBeTruthy()
|
|
expect(parsed?.scope).toBe('runtime')
|
|
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime')
|
|
}
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('reports why pairing is unavailable before the WebSocket listener is ready', () => {
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')),
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
expect(server.createPairingOffer({ name: 'Early test' })).toMatchObject({
|
|
available: false,
|
|
reason: 'websocket_unavailable',
|
|
guidance: expect.any(String)
|
|
})
|
|
})
|
|
|
|
it('reports an E2EE identity initialization failure after the local transport starts', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
mkdirSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
try {
|
|
await server.start()
|
|
expect(server.createPairingOffer({ name: 'E2EE failure test' })).toMatchObject({
|
|
available: false,
|
|
reason: 'e2ee_key_unavailable',
|
|
guidance: expect.any(String)
|
|
})
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('reports a registry persistence failure without retaining a ghost credential', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
await server.start()
|
|
mkdirSync(join(userDataPath, DEVICE_REGISTRY_FILENAME))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
try {
|
|
expect(server.createPairingOffer({ name: 'Registry failure test' })).toMatchObject({
|
|
available: false,
|
|
reason: 'device_registry_unavailable',
|
|
guidance: expect.any(String)
|
|
})
|
|
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('rejects wildcard advertised addresses before minting a device credential', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
|
expect(server.createPairingOffer({ address: '0.0.0.0', name: 'Invalid test' })).toMatchObject(
|
|
{
|
|
available: false,
|
|
reason: 'invalid_advertised_endpoint',
|
|
guidance: expect.any(String)
|
|
}
|
|
)
|
|
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('includes a web client URL when the web bundle is served by the runtime', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
webClientRoot: userDataPath
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({ address: '100.64.1.20', name: 'Web test' })
|
|
expect(offer.available).toBe(true)
|
|
if (offer.available) {
|
|
expect(offer.webClientUrl).toBeTruthy()
|
|
const url = new URL(offer.webClientUrl!)
|
|
expect(url.protocol).toBe('http:')
|
|
expect(url.hostname).toBe('100.64.1.20')
|
|
expect(url.pathname).toBe('/web-index.html')
|
|
expect(url.search).toBe('')
|
|
expect(url.hash).toBe(`#pairing=${encodeURIComponent(offer.pairingUrl)}`)
|
|
}
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('preserves proxy path prefixes in web client URLs', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
webClientRoot: userDataPath
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: 'wss://runtime.example.com/orca',
|
|
name: 'Proxy test'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (offer.available) {
|
|
expect(offer.webClientUrl).toContain('https://runtime.example.com/orca/web-index.html')
|
|
}
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('formats pairing-address overrides for IPv6 and host-port tunnel endpoints', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
webClientRoot: userDataPath
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const ipv6 = server.createPairingOffer({ address: '::1', name: 'IPv6 test' })
|
|
expect(ipv6.available).toBe(true)
|
|
if (ipv6.available) {
|
|
expect(ipv6.endpoint).toMatch(/^ws:\/\/\[::1\]:\d+$/)
|
|
expect(parsePairingCode(ipv6.pairingUrl)?.endpoint).toBe(ipv6.endpoint)
|
|
}
|
|
|
|
const tunnel = server.createPairingOffer({
|
|
address: 'tunnel.example.com:443',
|
|
name: 'Tunnel test'
|
|
})
|
|
expect(tunnel.available).toBe(true)
|
|
if (tunnel.available) {
|
|
expect(tunnel.endpoint).toBe('ws://tunnel.example.com:443')
|
|
}
|
|
|
|
const fullUrl = server.createPairingOffer({
|
|
address: 'wss://runtime.example.com/orca',
|
|
name: 'Full URL test'
|
|
})
|
|
expect(fullUrl.available).toBe(true)
|
|
if (fullUrl.available) {
|
|
expect(fullUrl.endpoint).toBe('wss://runtime.example.com/orca')
|
|
}
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('creates mobile-scoped pairing offers for headless mobile pairing', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
webClientRoot: userDataPath
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '100.64.1.20',
|
|
name: 'Mobile test',
|
|
scope: 'mobile'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
|
|
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('mobile')
|
|
expect(offer.webClientUrl).toBeNull()
|
|
const parsed = parsePairingCode(offer.pairingUrl)
|
|
expect(parsed?.endpoint).toBe(offer.endpoint)
|
|
expect(parsed?.endpoint).toContain('100.64.1.20')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('adds only the exact optional relay object to GUI mobile pairing offers', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const relay = {
|
|
v: 1 as const,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2 as const
|
|
}
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay,
|
|
binding: {
|
|
relayHostId: relay.relayHostId,
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
name: 'Mobile test'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const parsed = parsePairingCode(offer.pairingUrl)
|
|
expect(parsed).toEqual(
|
|
expect.objectContaining({ endpoint: offer.endpoint, scope: 'mobile', relay })
|
|
)
|
|
expect(parsed).not.toHaveProperty('endpoints')
|
|
expect(offer.connectionMode).toBe('automatic')
|
|
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.relayBinding).toEqual({
|
|
relayHostId: relay.relayHostId,
|
|
relayDeviceId: offer.deviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('queues the old Relay binding when a stable provider changes accounts', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let relayHostId = 'AbCdEf0123_-xyZ9'
|
|
let ownerIdentityKey = 'user-a\0profile-a\0org'
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId,
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId,
|
|
relayDeviceId,
|
|
ownerIdentityKey
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(first.available).toBe(true)
|
|
if (!first.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const firstBinding = server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding
|
|
expect(firstBinding).toBeTruthy()
|
|
if (!firstBinding) {
|
|
throw new Error('Relay binding unavailable')
|
|
}
|
|
relayHostId = 'ZyXwVu9876_-abcD'
|
|
ownerIdentityKey = 'user-b\0profile-b\0org'
|
|
|
|
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(second.available).toBe(true)
|
|
if (!second.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(second.deviceId).toBe(first.deviceId)
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledWith(expect.objectContaining(firstBinding))
|
|
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)?.relayBinding).toEqual({
|
|
relayHostId,
|
|
relayDeviceId: second.deviceId,
|
|
ownerIdentityKey
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('refuses a silent LAN QR when relay invite minting fails under Anywhere', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: vi.fn().mockRejectedValue(new Error('relay offline')),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
// Why: Anywhere must not ship a scannable local-only code under the Relay label.
|
|
expect(offer.available).toBe(false)
|
|
if (offer.available) {
|
|
throw new Error('expected relay mint failure')
|
|
}
|
|
expect(offer.reason).toBe('relay_mint_failed')
|
|
expect(offer.relayFailure).toMatchObject({
|
|
code: 'relay_mint_failed',
|
|
stage: 'create_pairing_relay'
|
|
})
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('reports a missing Relay provider without creating a fallback QR', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(offer).toMatchObject({
|
|
available: false,
|
|
reason: 'relay_mint_failed',
|
|
relayFailure: {
|
|
code: 'relay_provider_unavailable',
|
|
stage: 'provider_missing',
|
|
message: 'Orca Relay is not available on this desktop'
|
|
}
|
|
})
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('preserves an existing Relay QR when a same-mode remint fails', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const createPairingRelay = vi
|
|
.fn()
|
|
.mockImplementationOnce(async (relayDeviceId: string) => ({
|
|
relay: {
|
|
v: 1 as const,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2 as const
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}))
|
|
.mockRejectedValueOnce(new Error('relay offline'))
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay,
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(first.available).toBe(true)
|
|
if (!first.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(second.available).toBe(false)
|
|
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding).toBeTruthy()
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(first.deviceId)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('coalesces concurrent mobile Relay mints for the shared pending credential', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveFirst: (() => void) | undefined
|
|
const firstMint = new Promise<void>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
|
await firstMint
|
|
return {
|
|
relay: {
|
|
v: 1 as const,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2 as const
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay,
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
|
|
const second = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
|
|
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledTimes(1))
|
|
resolveFirst?.()
|
|
const [firstOffer, secondOffer] = await Promise.all([first, second])
|
|
expect(firstOffer.available).toBe(true)
|
|
expect(secondOffer.available).toBe(true)
|
|
if (!firstOffer.available || !secondOffer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(secondOffer.deviceId).toBe(firstOffer.deviceId)
|
|
expect(secondOffer.pairingUrl).toBe(firstOffer.pairingUrl)
|
|
expect(createPairingRelay).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('supersedes an older concurrent Relay rotation for a different address', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveFirst: (() => void) | undefined
|
|
const firstMint = new Promise<void>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
|
if (createPairingRelay.mock.calls.length === 1) {
|
|
await firstMint
|
|
}
|
|
return {
|
|
relay: {
|
|
v: 1 as const,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2 as const
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay,
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
rotate: true
|
|
})
|
|
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
|
|
const second = server.createMobilePairingOffer({
|
|
address: '100.64.1.21',
|
|
rotate: true
|
|
})
|
|
resolveFirst?.()
|
|
await expect(first).resolves.toMatchObject({
|
|
available: false,
|
|
relayFailure: { code: 'relay_request_superseded' }
|
|
})
|
|
const secondOffer = await second
|
|
expect(secondOffer.available).toBe(true)
|
|
if (!secondOffer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(secondOffer.endpoint).toContain('100.64.1.21')
|
|
expect(createPairingRelay).toHaveBeenCalledTimes(2)
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('supersedes an older concurrent Relay mint for a different address without rotate', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveFirst: (() => void) | undefined
|
|
const firstMint = new Promise<void>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
|
|
if (createPairingRelay.mock.calls.length === 1) {
|
|
await firstMint
|
|
}
|
|
return {
|
|
relay: {
|
|
v: 1 as const,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2 as const
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay,
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
|
|
const second = server.createMobilePairingOffer({ address: '100.64.1.21' })
|
|
resolveFirst?.()
|
|
await expect(first).resolves.toMatchObject({
|
|
available: false,
|
|
relayFailure: { code: 'relay_request_superseded' }
|
|
})
|
|
const secondOffer = await second
|
|
expect(secondOffer.available).toBe(true)
|
|
if (!secondOffer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(secondOffer.endpoint).toContain('100.64.1.21')
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('lets LAN supersede a pending Relay mint without waiting for it', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveRelay: (() => void) | undefined
|
|
const relayGate = new Promise<void>((resolve) => {
|
|
resolveRelay = resolve
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => {
|
|
await relayGate
|
|
return {
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
},
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const relayOffer = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
await vi.waitFor(() =>
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
|
|
)
|
|
const localOffer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(localOffer.available).toBe(true)
|
|
if (!localOffer.available) {
|
|
throw new Error('LAN pairing unavailable')
|
|
}
|
|
expect(localOffer.connectionMode).toBe('local-only')
|
|
resolveRelay?.()
|
|
const staleRelayOffer = await relayOffer
|
|
expect(staleRelayOffer.available).toBe(false)
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(
|
|
localOffer.deviceId
|
|
)
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('revokes a Relay invite when binding persistence throws', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const registry = server.getDeviceRegistry()
|
|
if (!registry) {
|
|
throw new Error('Device registry unavailable')
|
|
}
|
|
vi.spyOn(registry, 'setRelayBinding').mockImplementation(() => {
|
|
throw new Error('disk full')
|
|
})
|
|
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(offer.available).toBe(false)
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
expect(registry.getPendingDevice('mobile')).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('revokes a Relay result from a provider replaced during minting', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveRelay: (() => void) | undefined
|
|
const relayGate = new Promise<void>((resolve) => {
|
|
resolveRelay = resolve
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => {
|
|
await relayGate
|
|
return {
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
},
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
await vi.waitFor(() =>
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
|
|
)
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: vi.fn(),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
resolveRelay?.()
|
|
await expect(offerPromise).resolves.toMatchObject({ available: false })
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('retains a minted Relay binding on the device when cleanup cannot be queued', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
let resolveRelay: (() => void) | undefined
|
|
const relayGate = new Promise<void>((resolve) => {
|
|
resolveRelay = resolve
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => {
|
|
await relayGate
|
|
return {
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}
|
|
},
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const registry = server.getDeviceRegistry()
|
|
if (!registry) {
|
|
throw new Error('Device registry unavailable')
|
|
}
|
|
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
await vi.waitFor(() => expect(registry.getPendingDevice('mobile')).not.toBeNull())
|
|
const deviceId = registry.getPendingDevice('mobile')?.deviceId
|
|
vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => {
|
|
throw new Error('disk full')
|
|
})
|
|
// Why: swapping the provider supersedes the in-flight mint.
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: vi.fn(),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
resolveRelay?.()
|
|
await expect(offerPromise).resolves.toMatchObject({ available: false })
|
|
expect(registry.getDevice(deviceId ?? '')?.relayBinding).toMatchObject({
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId: deviceId
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('queues cloud cleanup when a minted Relay binding cannot be persisted', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async () => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId: 'wrong-device',
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(offer.available).toBe(false)
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('persists local-only pairing and never mints or later binds Relay', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const createPairingRelay = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay,
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay')
|
|
expect(createPairingRelay).not.toHaveBeenCalled()
|
|
expect(offer.connectionMode).toBe('local-only')
|
|
expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe(
|
|
'local-only'
|
|
)
|
|
expect(
|
|
server.setMobileRelayBinding(offer.deviceId, {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId: offer.deviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
})
|
|
).toBe(false)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('normalizes untrusted pairing modes to automatic at the runtime boundary', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = await server.createMobilePairingOffer({
|
|
connectionMode: 'renderer-controlled-value' as never
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(offer.connectionMode).toBe('automatic')
|
|
expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe(
|
|
'automatic'
|
|
)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('revokes and rotates a pending Relay code when switching it to local-only', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const anywhere = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(anywhere.available).toBe(true)
|
|
if (!anywhere.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const local = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(local.available).toBe(true)
|
|
if (!local.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(local.deviceId).not.toBe(anywhere.deviceId)
|
|
expect(server.getDeviceRegistry()?.getDevice(anywhere.deviceId)).toBeNull()
|
|
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
|
|
expect(parsePairingCode(local.pairingUrl)).not.toHaveProperty('relay')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('rotates a pending local-only code when switching it back to Anywhere', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const local = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(local.available).toBe(true)
|
|
if (!local.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const anywhere = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(anywhere.available).toBe(true)
|
|
if (!anywhere.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
// Why: a QR displayed under the local-only pledge must not become an
|
|
// anywhere-capable credential; the policy switch mints a fresh token.
|
|
expect(anywhere.deviceId).not.toBe(local.deviceId)
|
|
expect(server.getDeviceRegistry()?.getDevice(local.deviceId)).toBeNull()
|
|
expect(anywhere.connectionMode).toBe('automatic')
|
|
expect(parsePairingCode(anywhere.pairingUrl)).toHaveProperty('relay')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('reuses the pending token when the requested mode is unchanged', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const onDeviceRevokeQueued = vi.fn()
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued,
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(first.available).toBe(true)
|
|
if (!first.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
// Why: a same-mode remint (e.g. two windows converging after a
|
|
// preference sync) must not race rotations off each other's token.
|
|
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(second.available).toBe(true)
|
|
if (!second.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(second.deviceId).toBe(first.deviceId)
|
|
expect(onDeviceRevokeQueued).not.toHaveBeenCalled()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('records cloud cleanup before rotating or deleting the local mobile credential', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const registryPresence: boolean[] = []
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: async (relayDeviceId) => ({
|
|
relay: {
|
|
v: 1,
|
|
directorUrl: 'https://relay.example.com',
|
|
cellUrl: 'https://cell.example.com',
|
|
assignmentEpoch: 7,
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
inviteToken: 'A'.repeat(43),
|
|
inviteExpiresAt: Date.now() + 60_000,
|
|
e2eeFraming: 2
|
|
},
|
|
binding: {
|
|
relayHostId: 'AbCdEf0123_-xyZ9',
|
|
relayDeviceId,
|
|
ownerIdentityKey: 'user\0profile\0org'
|
|
}
|
|
}),
|
|
onDeviceRevokeQueued: (item) => {
|
|
registryPresence.push(server.getDeviceRegistry()?.getDevice(item.relayDeviceId) !== null)
|
|
},
|
|
getEndpoints: vi.fn(),
|
|
provisionRelay: vi.fn()
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
|
|
expect(first.available).toBe(true)
|
|
if (!first.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const second = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
rotate: true
|
|
})
|
|
expect(second.available).toBe(true)
|
|
if (!second.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)).toBeNull()
|
|
await expect(server.revokeMobileDevice(second.deviceId)).resolves.toBe(true)
|
|
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)).toBeNull()
|
|
expect(registryPresence).toEqual([true, true])
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('binds pairing RPC providers to the immutable authenticated socket context', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const getEndpoints = vi.fn().mockResolvedValue({ v: 1, relay: null })
|
|
const provisionRelay = vi.fn().mockResolvedValue({
|
|
v: 1,
|
|
reqId: 'install-1',
|
|
authorizationMode: 'authenticated-direct',
|
|
currentVersion: 1,
|
|
resumeExpiresAt: Date.now() + 60_000
|
|
})
|
|
server.setMobileRelayPairingProvider({
|
|
createPairingRelay: vi.fn(),
|
|
onDeviceRevokeQueued: vi.fn(),
|
|
getEndpoints,
|
|
provisionRelay
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
scope: 'mobile'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const session = await authenticateMobileWsSession(offer.pairingUrl)
|
|
const responses = createEncryptedWsResponseReader(session)
|
|
sendEncryptedWsRequest(session, {
|
|
id: 'endpoints-1',
|
|
method: 'pairing.getEndpoints',
|
|
params: { installReqId: 'status-1' }
|
|
})
|
|
await expect(responses.next('endpoints-1')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: { v: 1, relay: null }
|
|
})
|
|
sendEncryptedWsRequest(session, {
|
|
id: 'provision-1',
|
|
method: 'pairing.provisionRelay',
|
|
params: { reqId: 'install-1', newResumeTokenHash: 'A'.repeat(43) }
|
|
})
|
|
await expect(responses.next('provision-1')).resolves.toMatchObject({ ok: true })
|
|
|
|
const [endpointContext, endpointParams] = getEndpoints.mock.calls[0]!
|
|
expect(endpointContext).toEqual({
|
|
deviceId: offer.deviceId,
|
|
connectionId: expect.any(String),
|
|
transport: { transport: 'direct' }
|
|
})
|
|
expect(endpointParams).toEqual({ installReqId: 'status-1' })
|
|
expect(provisionRelay).toHaveBeenCalledWith(endpointContext, {
|
|
reqId: 'install-1',
|
|
newResumeTokenHash: 'A'.repeat(43)
|
|
})
|
|
responses.dispose()
|
|
session.ws.close()
|
|
await waitForWsClose(session.ws)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('cleans up pre-auth E2EE WebSocket state when the socket closes', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'mobile-test',
|
|
scope: 'mobile'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const parsed = parsePairingCode(offer.pairingUrl)!
|
|
const ws = await connectWs(parsed.endpoint)
|
|
const mobileKeys = generateKeyPair()
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'e2ee_hello',
|
|
publicKeyB64: Buffer.from(mobileKeys.publicKey).toString('base64')
|
|
})
|
|
)
|
|
expect(JSON.parse(await nextWsMessage(ws))).toEqual({ type: 'e2ee_ready' })
|
|
expect(server['mobileSocketWiring']?.channelCount).toBe(1)
|
|
expect(server['mobileSocketWiring']?.connectionCount).toBe(1)
|
|
|
|
ws.close()
|
|
await waitForWsClose(ws)
|
|
await waitFor(
|
|
() =>
|
|
server['mobileSocketWiring']?.channelCount === 0 &&
|
|
server['mobileSocketWiring']?.connectionCount === 0
|
|
)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('terminates active WebSockets for a revoked mobile device', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
const disconnectSpy = vi.spyOn(runtime, 'onClientDisconnected')
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'mobile-test',
|
|
scope: 'mobile'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const first = await authenticateMobileWs(offer.pairingUrl)
|
|
const second = await authenticateMobileWs(offer.pairingUrl)
|
|
|
|
await expect(server.revokeMobileDevice(offer.deviceId)).resolves.toBe(true)
|
|
await Promise.all([waitForWsClose(first), waitForWsClose(second)])
|
|
await waitFor(
|
|
() =>
|
|
server['mobileSocketWiring']?.channelCount === 0 &&
|
|
server['mobileSocketWiring']?.connectionCount === 0
|
|
)
|
|
|
|
expect(disconnectSpy).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
disconnectSpy.mockRestore()
|
|
await server.stop()
|
|
}
|
|
}, 15_000)
|
|
|
|
it('does not revoke runtime-scoped devices through mobile revocation', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'runtime-test',
|
|
scope: 'runtime'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
|
|
await expect(server.revokeMobileDevice(offer.deviceId)).resolves.toBe(false)
|
|
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.scope).toBe('runtime')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('terminates active WebSockets for a revoked runtime access grant', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'runtime-test',
|
|
scope: 'runtime'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const first = await authenticateMobileWs(offer.pairingUrl)
|
|
const second = await authenticateMobileWs(offer.pairingUrl)
|
|
|
|
expect(server.revokeRuntimeAccess(offer.deviceId)).toBe(true)
|
|
await Promise.all([waitForWsClose(first), waitForWsClose(second)])
|
|
await waitFor(
|
|
() =>
|
|
server['mobileSocketWiring']?.channelCount === 0 &&
|
|
server['mobileSocketWiring']?.connectionCount === 0
|
|
)
|
|
|
|
expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)).toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
}, 15_000)
|
|
|
|
it('rotates unused runtime pairing links without revoking already-used grants', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
try {
|
|
const first = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'runtime-test',
|
|
rotate: true,
|
|
scope: 'runtime'
|
|
})
|
|
const second = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'runtime-test',
|
|
rotate: true,
|
|
scope: 'runtime'
|
|
})
|
|
expect(first.available).toBe(true)
|
|
expect(second.available).toBe(true)
|
|
if (!first.available || !second.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
|
|
expect(first.deviceId).not.toBe(second.deviceId)
|
|
expect(parsePairingCode(first.pairingUrl)?.deviceToken).not.toBe(
|
|
parsePairingCode(second.pairingUrl)?.deviceToken
|
|
)
|
|
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)).toBeNull()
|
|
|
|
server.getDeviceRegistry()?.updateLastSeen(second.deviceId)
|
|
const third = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'runtime-test',
|
|
rotate: true,
|
|
scope: 'runtime'
|
|
})
|
|
expect(third.available).toBe(true)
|
|
if (!third.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
|
|
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)).not.toBeNull()
|
|
expect(server.getDeviceRegistry()?.getDevice(third.deviceId)).not.toBeNull()
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
}, 15_000)
|
|
|
|
it('caps WebSocket long-polls and aborts them when the socket closes', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: false,
|
|
longPollCap: 1
|
|
})
|
|
const device = server['deviceRegistry'] ?? null
|
|
expect(device).toBeNull()
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime')
|
|
const ws = new FakeWebSocket()
|
|
server['mobileSocketWiring'] = {
|
|
getConnectionId: () => 'conn-test'
|
|
} as unknown as NonNullable<(typeof server)['mobileSocketWiring']>
|
|
const replies: Record<string, unknown>[] = []
|
|
|
|
try {
|
|
const first = server['handleWebSocketMessage'](
|
|
JSON.stringify(
|
|
withCurrentOrchestrationContract({
|
|
id: 'req_wait',
|
|
method: 'orchestration.check',
|
|
deviceToken: entry.token,
|
|
params: { terminal: 'term_wait', wait: true, timeoutMs: 10_000 }
|
|
})
|
|
),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {},
|
|
undefined,
|
|
ws as unknown as WebSocket
|
|
)
|
|
|
|
await waitFor(() => server['activeLongPolls'] === 1)
|
|
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify(
|
|
withCurrentOrchestrationContract({
|
|
id: 'req_busy',
|
|
method: 'orchestration.check',
|
|
deviceToken: entry.token,
|
|
params: { terminal: 'term_busy', wait: true, timeoutMs: 10_000 }
|
|
})
|
|
),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {},
|
|
undefined,
|
|
ws as unknown as WebSocket
|
|
)
|
|
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_busy',
|
|
ok: false,
|
|
error: expect.objectContaining({ code: 'runtime_busy' })
|
|
})
|
|
)
|
|
expect(server['activeLongPolls']).toBe(1)
|
|
|
|
ws.readyState = 3
|
|
ws.emit('close')
|
|
await first
|
|
|
|
expect(server['activeLongPolls']).toBe(0)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_wait', ok: true }))
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('applies the ask sub-cap on the WebSocket path and releases both counters on close', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
seedSupervisedAskWorkers(db, ['term_w0', 'term_w1', 'term_w2'])
|
|
// Why: cap 4 → ask sub-cap 2, so the third ask must be shed while waits keep the other half.
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: false,
|
|
longPollCap: 4
|
|
})
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
// Why: 'runtime' scope, not 'mobile' — orchestration.ask is absent from the mobile allowlist.
|
|
const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime')
|
|
const ws = new FakeWebSocket()
|
|
server['mobileSocketWiring'] = {
|
|
getConnectionId: () => 'conn-test'
|
|
} as unknown as NonNullable<(typeof server)['mobileSocketWiring']>
|
|
const replies: Record<string, unknown>[] = []
|
|
const push = (response: string): void => {
|
|
replies.push(JSON.parse(response) as Record<string, unknown>)
|
|
}
|
|
const dispatch = (id: string, method: string, params: unknown): Promise<void> =>
|
|
server['handleWebSocketMessage'](
|
|
JSON.stringify(
|
|
withCurrentOrchestrationContract({ id, method, deviceToken: entry.token, params })
|
|
),
|
|
push,
|
|
() => {},
|
|
undefined,
|
|
ws as unknown as WebSocket
|
|
)
|
|
|
|
try {
|
|
const asks = [0, 1].map((i) =>
|
|
dispatch(`req_ask_${i}`, 'orchestration.ask', {
|
|
from: `term_w${i}`,
|
|
to: 'term_coord',
|
|
question: 'proceed?',
|
|
timeoutMs: 10_000
|
|
})
|
|
)
|
|
// Why: gate on the pre-existing total so a missing sub-cap fails on the shed below, not here.
|
|
await waitFor(() => server['activeLongPolls'] === 2)
|
|
|
|
await dispatch('req_ask_overflow', 'orchestration.ask', {
|
|
from: 'term_w2',
|
|
to: 'term_coord',
|
|
question: 'proceed?',
|
|
timeoutMs: 10_000
|
|
})
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_ask_overflow',
|
|
ok: false,
|
|
error: expect.objectContaining({
|
|
code: 'runtime_busy',
|
|
message: 'orchestration.ask capacity reached; retry with backoff'
|
|
})
|
|
})
|
|
)
|
|
// Shedding the ask must not burn a slot from the reserved half.
|
|
expect(server['activeLongPolls']).toBe(2)
|
|
expect(server['activeAskLongPolls']).toBe(2)
|
|
|
|
const wait = dispatch('req_check_wait', 'orchestration.check', {
|
|
terminal: 'term_other',
|
|
wait: true,
|
|
timeoutMs: 10_000
|
|
})
|
|
await waitFor(() => server['activeLongPolls'] === 3)
|
|
expect(server['activeAskLongPolls']).toBe(2)
|
|
|
|
ws.readyState = 3
|
|
ws.emit('close')
|
|
await Promise.all([...asks, wait])
|
|
|
|
expect(server['activeLongPolls']).toBe(0)
|
|
expect(server['activeAskLongPolls']).toBe(0)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_ask_0', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_check_wait', ok: true }))
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('shares one socket close listener across concurrent WebSocket dispatches', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime')
|
|
const ws = new FakeWebSocket()
|
|
server['mobileSocketWiring'] = {
|
|
getConnectionId: () => 'conn-test'
|
|
} as unknown as NonNullable<(typeof server)['mobileSocketWiring']>
|
|
let activeDispatches = 0
|
|
;(
|
|
server as unknown as {
|
|
dispatcher: {
|
|
dispatchStreaming: (
|
|
request: unknown,
|
|
reply: unknown,
|
|
context: { signal?: AbortSignal }
|
|
) => Promise<void>
|
|
}
|
|
}
|
|
).dispatcher = {
|
|
dispatchStreaming: vi.fn(
|
|
async (
|
|
_request: unknown,
|
|
_reply: unknown,
|
|
context: { signal?: AbortSignal }
|
|
): Promise<void> => {
|
|
activeDispatches += 1
|
|
await new Promise<void>((resolve) => {
|
|
context.signal?.addEventListener(
|
|
'abort',
|
|
() => {
|
|
activeDispatches -= 1
|
|
resolve()
|
|
},
|
|
{ once: true }
|
|
)
|
|
})
|
|
}
|
|
)
|
|
} as never
|
|
|
|
const pending = Array.from({ length: 12 }, (_entry, index) =>
|
|
server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: `req_${index}`,
|
|
method: 'status.get',
|
|
deviceToken: entry.token
|
|
}),
|
|
() => {},
|
|
() => {},
|
|
undefined,
|
|
ws as unknown as WebSocket
|
|
)
|
|
)
|
|
|
|
await waitFor(() => activeDispatches === 12)
|
|
expect(ws.listenerCount('close')).toBe(1)
|
|
expect(ws.listenerCount('error')).toBe(1)
|
|
|
|
ws.readyState = 3
|
|
ws.emit('close')
|
|
await Promise.all(pending)
|
|
|
|
expect(activeDispatches).toBe(0)
|
|
expect(ws.listenerCount('close')).toBe(0)
|
|
expect(ws.listenerCount('error')).toBe(0)
|
|
})
|
|
|
|
it('limits mobile-scoped WebSocket tokens to the mobile RPC surface', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
|
|
const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true })
|
|
const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true })
|
|
const expectedCodexResetScope = {
|
|
target: { runtime: 'host' as const, wslDistro: null },
|
|
accountId: 'codex-account',
|
|
accountRevision: 42,
|
|
offerRevision: 'v1:offer'
|
|
}
|
|
const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue({
|
|
outcome: 'reset',
|
|
scope: expectedCodexResetScope,
|
|
snapshot: { claude: null, codex: null }
|
|
})
|
|
const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true })
|
|
const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] })
|
|
const getRuntimeGitStatus = vi
|
|
.fn()
|
|
.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
|
|
const getRuntimeGitUpstreamStatus = vi
|
|
.fn()
|
|
.mockResolvedValue({ hasUpstream: true, ahead: 1, behind: 0 })
|
|
const rebaseRuntimeGitFromBase = vi.fn().mockResolvedValue({ ok: true })
|
|
const abortRuntimeGitMerge = vi.fn().mockResolvedValue({ ok: true })
|
|
const abortRuntimeGitRebase = vi.fn().mockResolvedValue({ ok: true })
|
|
const bulkStageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
|
|
const bulkUnstageRuntimeGitPaths = vi.fn().mockResolvedValue({ ok: true })
|
|
const getRuntimeGitDiff = vi.fn().mockResolvedValue({
|
|
kind: 'text',
|
|
originalContent: 'before\n',
|
|
modifiedContent: 'after\n',
|
|
originalIsBinary: false,
|
|
modifiedIsBinary: false
|
|
})
|
|
const openMobileDiff = vi.fn().mockResolvedValue({
|
|
worktree: 'wt-1',
|
|
relativePath: 'docs/readme.md',
|
|
kind: 'markdown',
|
|
opened: true
|
|
})
|
|
const browserTabCreate = vi.fn().mockResolvedValue({ page: 'page-1' })
|
|
const browserSetViewport = vi.fn().mockResolvedValue({ ok: true })
|
|
const browserDialogAccept = vi.fn().mockResolvedValue({ ok: true })
|
|
const browserDialogDismiss = vi.fn().mockResolvedValue({ ok: true })
|
|
const listGitHubProjects = vi.fn().mockResolvedValue({ ok: true, projects: [] })
|
|
const listGitHubLabelsBySlug = vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] })
|
|
const listGitHubAssignableUsersBySlug = vi
|
|
.fn()
|
|
.mockResolvedValue({ ok: true, users: [{ login: 'alex' }] })
|
|
const listGitHubIssueTypesBySlug = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
types: [{ id: 'type-1', name: 'Bug', color: 'RED', description: null }]
|
|
})
|
|
const updateGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
|
|
const clearGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
|
|
const updateGitHubIssueBySlug = vi.fn().mockResolvedValue({ ok: true })
|
|
const updateGitHubIssueTypeBySlug = vi.fn().mockResolvedValue({ ok: true })
|
|
const updateGitHubPullRequestBySlug = vi.fn().mockResolvedValue({ ok: true })
|
|
const updateRepoIssue = vi.fn().mockResolvedValue({ ok: true })
|
|
const listRepoLabels = vi.fn().mockResolvedValue(['bug'])
|
|
const listRepoAssignableUsers = vi.fn().mockResolvedValue([{ login: 'alex' }])
|
|
const addRepoIssueComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } })
|
|
const addRepoPRReviewComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 3 } })
|
|
const addRepoPRReviewCommentReply = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
comment: { id: 4 }
|
|
})
|
|
const getRepoPRFileContents = vi.fn().mockResolvedValue({
|
|
original: 'before',
|
|
modified: 'after',
|
|
originalIsBinary: false,
|
|
modifiedIsBinary: false
|
|
})
|
|
const rerunRepoPRChecks = vi.fn().mockResolvedValue({ ok: true, count: 1 })
|
|
const resolveRepoReviewThread = vi.fn().mockResolvedValue(true)
|
|
const setRepoPRFileViewed = vi.fn().mockResolvedValue(true)
|
|
const requestRepoPRReviewers = vi.fn().mockResolvedValue({ ok: true })
|
|
const mergeRepoPR = vi.fn().mockResolvedValue({ ok: true })
|
|
const addGitLabRepoIssueComment = vi.fn().mockResolvedValue({ ok: true })
|
|
const addGitLabRepoMRComment = vi.fn().mockResolvedValue({ ok: true })
|
|
const resolveGitLabRepoMRDiscussion = vi.fn().mockResolvedValue({ ok: true })
|
|
const mergeGitLabRepoMR = vi.fn().mockResolvedValue({ ok: true })
|
|
const addGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
comment: { id: 1, author: 'me', body: 'done', createdAt: '2026-01-01T00:00:00Z', url: '' }
|
|
})
|
|
const updateGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
|
|
const deleteGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
|
|
const linearSearchIssues = vi.fn().mockResolvedValue([])
|
|
const linearSelectWorkspace = vi.fn().mockReturnValue({
|
|
connected: true,
|
|
selectedWorkspaceId: 'workspace-1'
|
|
})
|
|
const linearTeamLabels = vi.fn().mockResolvedValue([{ id: 'label-1', name: 'bug' }])
|
|
const linearTeamMembers = vi.fn().mockResolvedValue([{ id: 'member-1', displayName: 'Alex' }])
|
|
const linearAddIssueComment = vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' })
|
|
const runtime = {
|
|
getRuntimeId: () => 'test-runtime',
|
|
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }),
|
|
pushRuntimeGit,
|
|
selectClaudeAccount,
|
|
selectCodexAccount,
|
|
consumeCodexRateLimitResetCredit,
|
|
removeClaudeAccount,
|
|
readTerminal,
|
|
getRuntimeGitStatus,
|
|
getRuntimeGitUpstreamStatus,
|
|
rebaseRuntimeGitFromBase,
|
|
abortRuntimeGitMerge,
|
|
abortRuntimeGitRebase,
|
|
bulkStageRuntimeGitPaths,
|
|
bulkUnstageRuntimeGitPaths,
|
|
getRuntimeGitDiff,
|
|
openMobileDiff,
|
|
browserTabCreate,
|
|
browserSetViewport,
|
|
browserDialogAccept,
|
|
browserDialogDismiss,
|
|
listGitHubProjects,
|
|
listGitHubLabelsBySlug,
|
|
listGitHubAssignableUsersBySlug,
|
|
listGitHubIssueTypesBySlug,
|
|
updateGitHubProjectItemField,
|
|
clearGitHubProjectItemField,
|
|
updateGitHubIssueBySlug,
|
|
updateGitHubIssueTypeBySlug,
|
|
updateGitHubPullRequestBySlug,
|
|
updateRepoIssue,
|
|
listRepoLabels,
|
|
listRepoAssignableUsers,
|
|
addRepoIssueComment,
|
|
addRepoPRReviewComment,
|
|
addRepoPRReviewCommentReply,
|
|
getRepoPRFileContents,
|
|
rerunRepoPRChecks,
|
|
resolveRepoReviewThread,
|
|
setRepoPRFileViewed,
|
|
requestRepoPRReviewers,
|
|
mergeRepoPR,
|
|
addGitLabRepoIssueComment,
|
|
addGitLabRepoMRComment,
|
|
resolveGitLabRepoMRDiscussion,
|
|
mergeGitLabRepoMR,
|
|
addGitHubIssueCommentBySlug,
|
|
updateGitHubIssueCommentBySlug,
|
|
deleteGitHubIssueCommentBySlug,
|
|
linearSearchIssues,
|
|
linearSelectWorkspace,
|
|
linearTeamLabels,
|
|
linearTeamMembers,
|
|
linearAddIssueComment,
|
|
getClientSettings: vi.fn(() => ({ defaultTuiAgent: 'codex', agentCmdOverrides: {} })),
|
|
updateClientSettings: vi.fn(() => ({ defaultTaskSource: 'linear' }))
|
|
} as unknown as OrcaRuntimeService
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile')
|
|
const replies: Record<string, unknown>[] = []
|
|
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_forbidden',
|
|
// files.delete is a real registered RPC intentionally kept off the
|
|
// mobile allowlist — mobile clients must never delete host files.
|
|
method: 'files.delete',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_allowed',
|
|
method: 'status.get',
|
|
deviceToken: mobile.token
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_settings_get',
|
|
method: 'settings.get',
|
|
deviceToken: mobile.token
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_settings_update',
|
|
method: 'settings.update',
|
|
deviceToken: mobile.token,
|
|
params: { defaultTaskSource: 'linear' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_projects',
|
|
method: 'github.project.listAccessible',
|
|
deviceToken: mobile.token,
|
|
params: {}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_issue_types',
|
|
method: 'github.project.listIssueTypesBySlug',
|
|
deviceToken: mobile.token,
|
|
params: { owner: 'stablyai', repo: 'orca' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_labels',
|
|
method: 'github.project.listLabelsBySlug',
|
|
deviceToken: mobile.token,
|
|
params: { owner: 'stablyai', repo: 'orca' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_assignees',
|
|
method: 'github.project.listAssignableUsersBySlug',
|
|
deviceToken: mobile.token,
|
|
params: { owner: 'stablyai', repo: 'orca', seedLogins: ['alex'] }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_update_issue',
|
|
method: 'github.project.updateIssueBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
updates: { title: 'New title' }
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_update_issue_type',
|
|
method: 'github.project.updateIssueTypeBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
issueTypeId: 'type-1'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_update_field',
|
|
method: 'github.project.updateItemField',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
projectId: 'project-1',
|
|
itemId: 'item-1',
|
|
fieldId: 'field-1',
|
|
value: { kind: 'text', text: 'Ready' }
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_clear_field',
|
|
method: 'github.project.clearItemField',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
projectId: 'project-1',
|
|
itemId: 'item-1',
|
|
fieldId: 'field-1'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_update_pr',
|
|
method: 'github.project.updatePullRequestBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 456,
|
|
updates: { state: 'closed' }
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_add_comment',
|
|
method: 'github.project.addIssueCommentBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
body: 'done'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_update_comment',
|
|
method: 'github.project.updateIssueCommentBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
commentId: 101,
|
|
body: 'edited'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_project_delete_comment',
|
|
method: 'github.project.deleteIssueCommentBySlug',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
commentId: 101
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_update_issue',
|
|
method: 'github.updateIssue',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
number: 123,
|
|
updates: { title: 'New title', addLabels: ['bug'] }
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_labels',
|
|
method: 'github.listLabels',
|
|
deviceToken: mobile.token,
|
|
params: { repo: 'id:repo-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_assignees',
|
|
method: 'github.listAssignableUsers',
|
|
deviceToken: mobile.token,
|
|
params: { repo: 'id:repo-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_add_comment',
|
|
method: 'github.addIssueComment',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
number: 123,
|
|
body: 'done'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_add_review_comment',
|
|
method: 'github.addPRReviewComment',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
commitId: 'abc123',
|
|
path: 'src/app.ts',
|
|
line: 10,
|
|
body: 'please fix'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_reply_review_comment',
|
|
method: 'github.addPRReviewCommentReply',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
commentId: 99,
|
|
body: 'fixed',
|
|
threadId: 'thread-1',
|
|
path: 'src/app.ts',
|
|
line: 10
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_pr_file_contents',
|
|
method: 'github.prFileContents',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
path: 'src/app.ts',
|
|
status: 'modified',
|
|
headSha: 'abc123',
|
|
baseSha: 'def456'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_rerun_checks',
|
|
method: 'github.rerunPRChecks',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
headSha: 'abc123',
|
|
failedOnly: true
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_resolve_thread',
|
|
method: 'github.resolveReviewThread',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
threadId: 'thread-1',
|
|
resolve: true
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_file_viewed',
|
|
method: 'github.setPRFileViewed',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
pullRequestId: 'PR_kw',
|
|
path: 'src/app.ts',
|
|
viewed: true
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_request_reviewers',
|
|
method: 'github.requestPRReviewers',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
reviewers: ['alex']
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_github_merge_pr',
|
|
method: 'github.mergePR',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
prNumber: 456,
|
|
method: 'squash'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_gitlab_add_issue_comment',
|
|
method: 'gitlab.addIssueComment',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
number: 123,
|
|
body: 'done'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_gitlab_add_mr_comment',
|
|
method: 'gitlab.addMRComment',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
iid: 456,
|
|
body: 'ship it'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_gitlab_resolve_mr_discussion',
|
|
method: 'gitlab.resolveMRDiscussion',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
iid: 456,
|
|
discussionId: 'discussion-1',
|
|
resolved: true
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_gitlab_merge_mr',
|
|
method: 'gitlab.mergeMR',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
repo: 'id:repo-1',
|
|
iid: 456,
|
|
method: 'merge'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_linear_search',
|
|
method: 'linear.searchIssues',
|
|
deviceToken: mobile.token,
|
|
params: { query: 'auth', limit: 10, workspaceId: 'workspace-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_linear_select_workspace',
|
|
method: 'linear.selectWorkspace',
|
|
deviceToken: mobile.token,
|
|
params: { workspaceId: 'workspace-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_linear_team_labels',
|
|
method: 'linear.teamLabels',
|
|
deviceToken: mobile.token,
|
|
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_linear_team_members',
|
|
method: 'linear.teamMembers',
|
|
deviceToken: mobile.token,
|
|
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_linear_add_comment',
|
|
method: 'linear.addIssueComment',
|
|
deviceToken: mobile.token,
|
|
params: { issueId: 'issue-1', workspaceId: 'workspace-1', body: 'done' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_status',
|
|
method: 'git.status',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_push',
|
|
method: 'git.push',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', publish: true }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_upstream',
|
|
method: 'git.upstreamStatus',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_rebase_from_base',
|
|
method: 'git.rebaseFromBase',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', baseRef: 'origin/main' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_bulk_stage',
|
|
method: 'git.bulkStage',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', filePaths: ['a.ts', 'b.ts'] }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_abort_merge',
|
|
method: 'git.abortMerge',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_abort_rebase',
|
|
method: 'git.abortRebase',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_bulk_unstage',
|
|
method: 'git.bulkUnstage',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', filePaths: ['c.ts'] }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_select_claude',
|
|
method: 'accounts.selectClaude',
|
|
deviceToken: mobile.token,
|
|
params: { accountId: 'claude-account' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_select_codex',
|
|
method: 'accounts.selectCodex',
|
|
deviceToken: mobile.token,
|
|
params: { accountId: null }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_consume_codex_reset',
|
|
method: 'accounts.consumeCodexResetCredit',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
|
expectedScope: expectedCodexResetScope
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_remove_claude',
|
|
method: 'accounts.removeClaude',
|
|
deviceToken: mobile.token,
|
|
params: { accountId: 'claude-account' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_terminal_read',
|
|
method: 'terminal.read',
|
|
deviceToken: mobile.token,
|
|
params: { terminal: 'term-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_files_open_diff',
|
|
method: 'files.openDiff',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', relativePath: 'docs/readme.md', staged: true }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_git_diff',
|
|
method: 'git.diff',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', filePath: 'docs/readme.md', staged: false }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_browser_tab_create',
|
|
method: 'browser.tabCreate',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', url: 'about:blank' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_browser_viewport',
|
|
method: 'browser.viewport',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', page: 'page-1', width: 390, height: 844 }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_browser_certificate_proceed',
|
|
method: 'browser.certificate.proceed',
|
|
deviceToken: mobile.token,
|
|
params: {
|
|
worktree: 'id:wt-1',
|
|
page: 'page-1',
|
|
challengeId: 'challenge-1'
|
|
}
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_browser_dialog_accept',
|
|
method: 'browser.dialogAccept',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', page: 'page-1', text: 'ok' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_browser_dialog_dismiss',
|
|
method: 'browser.dialogDismiss',
|
|
deviceToken: mobile.token,
|
|
params: { worktree: 'id:wt-1', page: 'page-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_forbidden',
|
|
ok: false,
|
|
error: expect.objectContaining({ code: 'forbidden' })
|
|
})
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_get', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_update', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_projects', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_issue_types', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_project_labels', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_assignees', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_update_issue', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_update_issue_type', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_update_field', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_clear_field', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_update_pr', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_add_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_update_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_project_delete_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_update_issue', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_labels', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_assignees', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_add_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_add_review_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_reply_review_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_pr_file_contents', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_rerun_checks', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_resolve_thread', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_file_viewed', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_github_request_reviewers', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_merge_pr', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_gitlab_add_issue_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_gitlab_add_mr_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_gitlab_merge_mr', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_linear_search', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_linear_select_workspace', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_linear_team_labels', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_linear_team_members', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_linear_add_comment', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_git_rebase_from_base', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_bulk_stage', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_abort_merge', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_git_abort_rebase', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_git_bulk_unstage', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_consume_codex_reset', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_files_open_diff', ok: true }))
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_diff', ok: true }))
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_browser_tab_create', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_browser_viewport', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_browser_certificate_proceed',
|
|
ok: false,
|
|
error: expect.objectContaining({ code: 'forbidden' })
|
|
})
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({ id: 'req_browser_dialog_dismiss', ok: true })
|
|
)
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_remove_claude',
|
|
ok: false,
|
|
error: expect.objectContaining({ code: 'forbidden' })
|
|
})
|
|
)
|
|
expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account')
|
|
expect(selectCodexAccount).toHaveBeenCalledWith(null)
|
|
expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
|
|
'11111111-1111-4111-8111-111111111111',
|
|
expectedCodexResetScope
|
|
)
|
|
expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined })
|
|
expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1')
|
|
expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined)
|
|
expect(getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1')
|
|
expect(bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts'])
|
|
expect(abortRuntimeGitMerge).toHaveBeenCalledWith('id:wt-1')
|
|
expect(abortRuntimeGitRebase).toHaveBeenCalledWith('id:wt-1')
|
|
expect(bulkUnstageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['c.ts'])
|
|
expect(openMobileDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', true)
|
|
expect(getRuntimeGitDiff).toHaveBeenCalledWith('id:wt-1', 'docs/readme.md', false, undefined)
|
|
expect(browserTabCreate).toHaveBeenCalledWith({ worktree: 'id:wt-1', url: 'about:blank' })
|
|
expect(browserSetViewport).toHaveBeenCalledWith({
|
|
worktree: 'id:wt-1',
|
|
page: 'page-1',
|
|
width: 390,
|
|
height: 844
|
|
})
|
|
expect(browserDialogAccept).toHaveBeenCalledWith({
|
|
worktree: 'id:wt-1',
|
|
page: 'page-1',
|
|
text: 'ok'
|
|
})
|
|
expect(browserDialogDismiss).toHaveBeenCalledWith({
|
|
worktree: 'id:wt-1',
|
|
page: 'page-1'
|
|
})
|
|
expect(listGitHubIssueTypesBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca'
|
|
})
|
|
expect(listGitHubLabelsBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca'
|
|
})
|
|
expect(listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
seedLogins: ['alex']
|
|
})
|
|
expect(updateGitHubIssueBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
updates: { title: 'New title' }
|
|
})
|
|
expect(updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
issueTypeId: 'type-1'
|
|
})
|
|
expect(updateGitHubPullRequestBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 456,
|
|
updates: { state: 'closed' }
|
|
})
|
|
expect(addGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
number: 123,
|
|
body: 'done'
|
|
})
|
|
expect(updateGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
commentId: 101,
|
|
body: 'edited'
|
|
})
|
|
expect(deleteGitHubIssueCommentBySlug).toHaveBeenCalledWith({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
commentId: 101
|
|
})
|
|
expect(updateRepoIssue).toHaveBeenCalledWith('id:repo-1', 123, {
|
|
title: 'New title',
|
|
addLabels: ['bug']
|
|
})
|
|
expect(listRepoLabels).toHaveBeenCalledWith('id:repo-1')
|
|
expect(listRepoAssignableUsers).toHaveBeenCalledWith('id:repo-1')
|
|
expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', null)
|
|
expect(addRepoPRReviewComment).toHaveBeenCalledWith('id:repo-1', {
|
|
prNumber: 456,
|
|
commitId: 'abc123',
|
|
path: 'src/app.ts',
|
|
line: 10,
|
|
startLine: undefined,
|
|
body: 'please fix',
|
|
prRepo: null
|
|
})
|
|
expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', {
|
|
prNumber: 456,
|
|
commentId: 99,
|
|
body: 'fixed',
|
|
threadId: 'thread-1',
|
|
path: 'src/app.ts',
|
|
line: 10,
|
|
prRepo: null
|
|
})
|
|
expect(getRepoPRFileContents).toHaveBeenCalledWith('id:repo-1', {
|
|
prNumber: 456,
|
|
path: 'src/app.ts',
|
|
oldPath: undefined,
|
|
status: 'modified',
|
|
headSha: 'abc123',
|
|
baseSha: 'def456',
|
|
prRepo: null
|
|
})
|
|
expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, {
|
|
headSha: 'abc123',
|
|
failedOnly: true,
|
|
prRepo: null
|
|
})
|
|
expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true, null)
|
|
expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', {
|
|
pullRequestId: 'PR_kw',
|
|
path: 'src/app.ts',
|
|
viewed: true,
|
|
prRepo: null
|
|
})
|
|
expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'], null)
|
|
expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null)
|
|
expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined)
|
|
expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined)
|
|
expect(resolveGitLabRepoMRDiscussion).toHaveBeenCalledWith(
|
|
'id:repo-1',
|
|
456,
|
|
'discussion-1',
|
|
true,
|
|
undefined
|
|
)
|
|
expect(mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 456, 'merge', undefined)
|
|
expect(updateGitHubProjectItemField).toHaveBeenCalledWith({
|
|
projectId: 'project-1',
|
|
itemId: 'item-1',
|
|
fieldId: 'field-1',
|
|
value: { kind: 'text', text: 'Ready' }
|
|
})
|
|
expect(clearGitHubProjectItemField).toHaveBeenCalledWith({
|
|
projectId: 'project-1',
|
|
itemId: 'item-1',
|
|
fieldId: 'field-1'
|
|
})
|
|
expect(linearSearchIssues).toHaveBeenCalledWith('auth', 10, 'workspace-1')
|
|
expect(linearSelectWorkspace).toHaveBeenCalledWith('workspace-1')
|
|
expect(linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1')
|
|
expect(linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1')
|
|
expect(linearAddIssueComment).toHaveBeenCalledWith('issue-1', 'done', 'workspace-1')
|
|
expect(removeClaudeAccount).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects WebSocket requests whose request token differs from the authenticated channel token', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = {
|
|
getRuntimeId: () => 'test-runtime',
|
|
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' })
|
|
} as unknown as OrcaRuntimeService
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const channelDevice = server['deviceRegistry']!.addDevice('phone', 'mobile')
|
|
const requestDevice = server['deviceRegistry']!.addDevice('cli', 'runtime')
|
|
const replies: Record<string, unknown>[] = []
|
|
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_mismatch',
|
|
method: 'status.get',
|
|
deviceToken: requestDevice.token
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {},
|
|
undefined,
|
|
undefined,
|
|
channelDevice.token
|
|
)
|
|
|
|
expect(replies).toContainEqual(
|
|
expect.objectContaining({
|
|
id: 'req_mismatch',
|
|
ok: false,
|
|
error: expect.objectContaining({ code: 'unauthorized' })
|
|
})
|
|
)
|
|
})
|
|
|
|
it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
|
|
const runtime = {
|
|
getRuntimeId: () => 'test-runtime',
|
|
pushRuntimeGit
|
|
} as unknown as OrcaRuntimeService
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const runtimeDevice = server['deviceRegistry']!.addDevice('cli', 'runtime')
|
|
const replies: Record<string, unknown>[] = []
|
|
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id: 'req_push',
|
|
method: 'git.push',
|
|
deviceToken: runtimeDevice.token,
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
|
|
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_push', ok: true }))
|
|
expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', undefined, undefined, undefined)
|
|
})
|
|
|
|
it('leaves the last published metadata in place when a runtime stops', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
pid: 1001
|
|
})
|
|
|
|
await server.start()
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
expect(metadata?.pid).toBe(1001)
|
|
|
|
await server.stop()
|
|
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
|
|
pid: 1001,
|
|
runtimeId: runtime.getRuntimeId()
|
|
})
|
|
})
|
|
|
|
it('closes the socket if metadata publication fails during startup', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
const writeMetadataSpy = vi
|
|
.spyOn(runtimeMetadataModule, 'writeRuntimeMetadata')
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('write failed')
|
|
})
|
|
const endpoint = createRuntimeTransportMetadata(
|
|
userDataPath,
|
|
process.pid,
|
|
process.platform,
|
|
runtime.getRuntimeId()
|
|
).endpoint
|
|
|
|
await expect(server.start()).rejects.toThrow('write failed')
|
|
expect(readRuntimeMetadata(userDataPath)).toBeNull()
|
|
expect(existsSync(endpoint)).toBe(false)
|
|
expect(server['transports']).toEqual([])
|
|
expect(server['activeTransports']).toEqual([])
|
|
|
|
writeMetadataSpy.mockRestore()
|
|
})
|
|
|
|
it('serves status.get for authenticated callers', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_1',
|
|
authToken: metadata!.authToken,
|
|
method: 'status.get'
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
id: 'req_1',
|
|
ok: true,
|
|
_meta: {
|
|
runtimeId: runtime.getRuntimeId()
|
|
}
|
|
})
|
|
expect((response.result as { graphStatus: string }).graphStatus).toBe('unavailable')
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('stamps the authenticated device scope onto status.get for WebSocket clients', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const mobile = server['deviceRegistry']!.addDevice('phone', 'mobile')
|
|
const runtimeDevice = server['deviceRegistry']!.addDevice('browser', 'runtime')
|
|
|
|
const sendStatus = async (token: string): Promise<Record<string, unknown>> => {
|
|
const replies: Record<string, unknown>[] = []
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({ id: 'req_status', method: 'status.get', deviceToken: token }),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
return replies[0]!
|
|
}
|
|
|
|
const mobileReply = await sendStatus(mobile.token)
|
|
expect(mobileReply).toMatchObject({ id: 'req_status', ok: true })
|
|
// Why: the mobile-scope web client reads this to refuse the full app.
|
|
expect((mobileReply.result as { deviceScope?: string }).deviceScope).toBe('mobile')
|
|
|
|
const runtimeReply = await sendStatus(runtimeDevice.token)
|
|
expect((runtimeReply.result as { deviceScope?: string }).deviceScope).toBe('runtime')
|
|
|
|
// Other methods stay unmodified — only status.get carries the scope.
|
|
const replies: Record<string, unknown>[] = []
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({ id: 'req_forbidden', method: 'files.delete', deviceToken: mobile.token }),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
expect(replies[0]).toMatchObject({
|
|
id: 'req_forbidden',
|
|
ok: false,
|
|
error: { code: 'forbidden' }
|
|
})
|
|
})
|
|
|
|
it('rejects requests with the wrong auth token', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_1',
|
|
authToken: 'wrong',
|
|
method: 'status.get'
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
id: 'req_1',
|
|
ok: false,
|
|
error: {
|
|
code: 'unauthorized'
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('rejects malformed requests before dispatch', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
authToken: metadata!.authToken,
|
|
method: 'status.get'
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
id: 'unknown',
|
|
ok: false,
|
|
error: {
|
|
code: 'bad_request'
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('serves terminal.list and terminal.show for live runtime terminals', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
const writes: string[] = []
|
|
runtime.setPtyController({
|
|
write: (_ptyId, data) => {
|
|
writes.push(data)
|
|
return true
|
|
},
|
|
kill: () => true,
|
|
getForegroundProcess: async () => null
|
|
})
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'hello\n', 123)
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_list',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.list',
|
|
params: {
|
|
worktree: 'id:repo-1::/tmp/worktree-a'
|
|
}
|
|
})
|
|
expect(listResponse).toMatchObject({
|
|
id: 'req_list',
|
|
ok: true,
|
|
result: {
|
|
terminals: [expect.objectContaining({ ptyId: 'pty-1' })]
|
|
}
|
|
})
|
|
|
|
const handle = (
|
|
(
|
|
listResponse.result as {
|
|
terminals: { handle: string }[]
|
|
totalCount: number
|
|
truncated: boolean
|
|
}
|
|
).terminals[0] ?? { handle: '' }
|
|
).handle
|
|
expect(handle).toBeTruthy()
|
|
|
|
const showResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_show',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.show',
|
|
params: {
|
|
terminal: handle
|
|
}
|
|
})
|
|
expect(showResponse).toMatchObject({
|
|
id: 'req_show',
|
|
ok: true
|
|
})
|
|
|
|
const readResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_read',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.read',
|
|
params: {
|
|
terminal: handle
|
|
}
|
|
})
|
|
expect(readResponse).toMatchObject({
|
|
id: 'req_read',
|
|
ok: true
|
|
})
|
|
|
|
const sendResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_send',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.send',
|
|
params: {
|
|
terminal: handle,
|
|
text: 'continue',
|
|
enter: true
|
|
}
|
|
})
|
|
expect(sendResponse).toMatchObject({
|
|
id: 'req_send',
|
|
ok: true
|
|
})
|
|
expect(writes).toEqual(['continue', '\r'])
|
|
|
|
const waitPromise = sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.wait',
|
|
params: {
|
|
terminal: handle,
|
|
for: 'exit',
|
|
timeoutMs: 1000
|
|
}
|
|
})
|
|
runtime.onPtyExit('pty-1', 9)
|
|
const waitResponse = await waitPromise
|
|
expect(waitResponse).toMatchObject({
|
|
id: 'req_wait',
|
|
ok: true,
|
|
result: {
|
|
wait: {
|
|
handle,
|
|
condition: 'exit',
|
|
satisfied: true,
|
|
status: 'exited',
|
|
exitCode: 9
|
|
}
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('serves terminal.list with visual split-group and pane nesting', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
const worktreeId = 'repo-1::/tmp/worktree-a'
|
|
const leftLeaf = '11111111-1111-4111-8111-111111111111'
|
|
const topLeaf = '22222222-2222-4222-8222-222222222222'
|
|
const bottomLeaf = '33333333-3333-4333-8333-333333333333'
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-left',
|
|
worktreeId,
|
|
title: 'Left',
|
|
activeLeafId: leftLeaf,
|
|
layout: { type: 'leaf', leafId: leftLeaf }
|
|
},
|
|
{
|
|
tabId: 'tab-right',
|
|
worktreeId,
|
|
title: 'Right',
|
|
activeLeafId: bottomLeaf,
|
|
layout: {
|
|
type: 'split',
|
|
direction: 'vertical',
|
|
first: { type: 'leaf', leafId: topLeaf },
|
|
second: { type: 'leaf', leafId: bottomLeaf }
|
|
}
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-left',
|
|
worktreeId,
|
|
leafId: leftLeaf,
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-left',
|
|
title: 'Left'
|
|
},
|
|
{
|
|
tabId: 'tab-right',
|
|
worktreeId,
|
|
leafId: topLeaf,
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-top',
|
|
title: 'Right top'
|
|
},
|
|
{
|
|
tabId: 'tab-right',
|
|
worktreeId,
|
|
leafId: bottomLeaf,
|
|
paneRuntimeId: 2,
|
|
ptyId: 'pty-bottom',
|
|
title: 'Right bottom'
|
|
}
|
|
],
|
|
mobileSessionTabs: [
|
|
{
|
|
worktree: worktreeId,
|
|
publicationEpoch: 'test',
|
|
snapshotVersion: 1,
|
|
activeGroupId: 'group-right',
|
|
activeTabId: `tab-right::${bottomLeaf}`,
|
|
activeTabType: 'terminal',
|
|
tabGroups: [
|
|
{ id: 'group-left', activeTabId: 'tab-left', tabOrder: ['tab-left'] },
|
|
{ id: 'group-right', activeTabId: 'tab-right', tabOrder: ['tab-right'] }
|
|
],
|
|
tabGroupLayout: {
|
|
type: 'split',
|
|
direction: 'horizontal',
|
|
first: { type: 'leaf', groupId: 'group-left' },
|
|
second: { type: 'leaf', groupId: 'group-right' }
|
|
},
|
|
tabs: [
|
|
{
|
|
type: 'terminal',
|
|
id: `tab-left::${leftLeaf}`,
|
|
title: 'Left',
|
|
parentTabId: 'tab-left',
|
|
leafId: leftLeaf,
|
|
ptyId: 'pty-left',
|
|
parentLayout: {
|
|
root: { type: 'leaf', leafId: leftLeaf },
|
|
activeLeafId: leftLeaf,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: { [leftLeaf]: 'pty-left' }
|
|
},
|
|
isActive: false
|
|
},
|
|
{
|
|
type: 'terminal',
|
|
id: `tab-right::${topLeaf}`,
|
|
title: 'Right top',
|
|
parentTabId: 'tab-right',
|
|
leafId: topLeaf,
|
|
ptyId: 'pty-top',
|
|
parentLayout: {
|
|
root: {
|
|
type: 'split',
|
|
direction: 'vertical',
|
|
first: { type: 'leaf', leafId: topLeaf },
|
|
second: { type: 'leaf', leafId: bottomLeaf }
|
|
},
|
|
activeLeafId: bottomLeaf,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: {
|
|
[topLeaf]: 'pty-top',
|
|
[bottomLeaf]: 'pty-bottom'
|
|
}
|
|
},
|
|
isActive: false
|
|
},
|
|
{
|
|
type: 'terminal',
|
|
id: `tab-right::${bottomLeaf}`,
|
|
title: 'Right bottom',
|
|
parentTabId: 'tab-right',
|
|
leafId: bottomLeaf,
|
|
ptyId: 'pty-bottom',
|
|
parentLayout: {
|
|
root: {
|
|
type: 'split',
|
|
direction: 'vertical',
|
|
first: { type: 'leaf', leafId: topLeaf },
|
|
second: { type: 'leaf', leafId: bottomLeaf }
|
|
},
|
|
activeLeafId: bottomLeaf,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: {
|
|
[topLeaf]: 'pty-top',
|
|
[bottomLeaf]: 'pty-bottom'
|
|
}
|
|
},
|
|
isActive: true
|
|
}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_list_layout',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.list',
|
|
params: { worktree: `id:${worktreeId}` }
|
|
})
|
|
const result = listResponse.result as {
|
|
visualLayouts?: unknown[]
|
|
terminals: { handle: string; tabId: string; leafId: string }[]
|
|
}
|
|
const handleByLeaf = new Map(
|
|
result.terminals.map((terminal) => [terminal.leafId, terminal.handle])
|
|
)
|
|
|
|
expect(listResponse).toMatchObject({
|
|
id: 'req_list_layout',
|
|
ok: true
|
|
})
|
|
expect(result.visualLayouts).toMatchObject([
|
|
{
|
|
worktreeId,
|
|
worktreePath: '/tmp/worktree-a',
|
|
root: {
|
|
type: 'split',
|
|
direction: 'horizontal',
|
|
first: {
|
|
type: 'group',
|
|
groupId: 'group-left',
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-left',
|
|
panes: {
|
|
type: 'terminal',
|
|
handle: handleByLeaf.get(leftLeaf),
|
|
leafId: leftLeaf
|
|
}
|
|
}
|
|
]
|
|
},
|
|
second: {
|
|
type: 'group',
|
|
groupId: 'group-right',
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-right',
|
|
panes: {
|
|
type: 'pane-split',
|
|
direction: 'vertical',
|
|
first: {
|
|
type: 'terminal',
|
|
handle: handleByLeaf.get(topLeaf),
|
|
leafId: topLeaf
|
|
},
|
|
second: {
|
|
type: 'terminal',
|
|
handle: handleByLeaf.get(bottomLeaf),
|
|
leafId: bottomLeaf,
|
|
active: true
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
])
|
|
|
|
const resolvePaneResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_resolve_pane',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.resolvePane',
|
|
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId }
|
|
})
|
|
expect(resolvePaneResponse).toMatchObject({
|
|
id: 'req_resolve_pane',
|
|
ok: true,
|
|
result: {
|
|
terminal: {
|
|
handle: handleByLeaf.get(bottomLeaf),
|
|
tabId: 'tab-right',
|
|
leafId: bottomLeaf,
|
|
ptyId: 'pty-bottom',
|
|
worktreeId
|
|
}
|
|
}
|
|
})
|
|
|
|
const wrongOwnerResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_resolve_pane_wrong_owner',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.resolvePane',
|
|
params: { paneKey: `tab-right:${bottomLeaf}`, worktreeId: 'other-worktree' }
|
|
})
|
|
expect(wrongOwnerResponse).toMatchObject({
|
|
id: 'req_resolve_pane_wrong_owner',
|
|
ok: false,
|
|
error: { message: 'terminal_not_found' }
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('mirrors laptop-created remote runtime terminals into phone session tabs over RPC', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
|
runtime.setPtyController({
|
|
spawn,
|
|
write: () => true,
|
|
kill: () => true,
|
|
getForegroundProcess: async () => null
|
|
})
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
const authToken = metadata!.authToken
|
|
const leafId = '11111111-1111-4111-8111-111111111111'
|
|
const createResponse = await sendRequest(endpoint, {
|
|
id: 'laptop_create',
|
|
authToken,
|
|
method: 'terminal.create',
|
|
params: {
|
|
worktree: 'id:repo-1::/tmp/worktree-a',
|
|
command: "claude 'work on the issue'",
|
|
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' },
|
|
tabId: 'laptop-tab',
|
|
leafId,
|
|
presentation: 'background'
|
|
}
|
|
})
|
|
|
|
expect(createResponse).toMatchObject({
|
|
id: 'laptop_create',
|
|
ok: true,
|
|
result: {
|
|
terminal: {
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
surface: 'background'
|
|
}
|
|
}
|
|
})
|
|
expect(
|
|
(createResponse.result as { terminal?: { warning?: string } } | undefined)?.terminal?.warning
|
|
).toBeUndefined()
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
terminalColorQueryReplies: { foreground: '#ffffff', background: '#282c34' }
|
|
})
|
|
)
|
|
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456)
|
|
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456)
|
|
|
|
const listResponse = await sendRequest(endpoint, {
|
|
id: 'phone_list',
|
|
authToken,
|
|
method: 'session.tabs.list',
|
|
params: {
|
|
worktree: 'id:repo-1::/tmp/worktree-a'
|
|
}
|
|
})
|
|
|
|
const terminal = (
|
|
createResponse.result as {
|
|
terminal: { handle: string }
|
|
}
|
|
).terminal
|
|
expect(listResponse).toMatchObject({
|
|
id: 'phone_list',
|
|
ok: true,
|
|
result: {
|
|
tabs: [
|
|
{
|
|
type: 'terminal',
|
|
id: `laptop-tab::${leafId}`,
|
|
parentTabId: 'laptop-tab',
|
|
leafId,
|
|
status: 'ready',
|
|
terminal: terminal.handle,
|
|
agentStatus: {
|
|
state: 'working',
|
|
paneKey: `laptop-tab:${leafId}`,
|
|
terminalHandle: terminal.handle
|
|
}
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const readResponse = await sendRequest(endpoint, {
|
|
id: 'phone_read',
|
|
authToken,
|
|
method: 'terminal.read',
|
|
params: {
|
|
terminal: terminal.handle
|
|
}
|
|
})
|
|
expect(readResponse).toMatchObject({
|
|
id: 'phone_read',
|
|
ok: true,
|
|
result: {
|
|
terminal: {
|
|
tail: ['Claude is working...']
|
|
}
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('streams laptop-created runtime terminals to a paired phone WebSocket client', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const writes: string[] = []
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
const spawn = vi.fn().mockResolvedValue({ id: 'paired-laptop-pty' })
|
|
runtime.setPtyController({
|
|
spawn,
|
|
write: (_ptyId, data) => {
|
|
writes.push(data)
|
|
return true
|
|
},
|
|
kill: () => true,
|
|
getForegroundProcess: async () => null
|
|
})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
const phoneOffer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'phone',
|
|
scope: 'mobile'
|
|
})
|
|
expect(phoneOffer.available).toBe(true)
|
|
if (!phoneOffer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
expect(parsePairingCode(phoneOffer.pairingUrl)?.scope).toBe('mobile')
|
|
const phone = await authenticateMobileWsSession(phoneOffer.pairingUrl)
|
|
const phoneResponses = createEncryptedWsResponseReader(phone)
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const laptopEndpoint = metadata!.transports[0]!.endpoint
|
|
const laptopAuthToken = metadata!.authToken
|
|
const worktree = 'id:repo-1::/tmp/worktree-a'
|
|
const leafId = '11111111-1111-4111-8111-111111111111'
|
|
|
|
try {
|
|
sendEncryptedWsRequest(phone, {
|
|
id: 'phone_subscribe_tabs',
|
|
method: 'session.tabs.subscribe',
|
|
params: { worktree }
|
|
})
|
|
await expect(
|
|
phoneResponses.next('phone_subscribe_tabs', (response) => {
|
|
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
|
|
return result?.type === 'snapshot' && result.tabs?.length === 0
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
streaming: true
|
|
})
|
|
|
|
const blockedUpdate = phoneResponses.next('phone_subscribe_tabs', (response) => {
|
|
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
|
|
const tab = result?.tabs?.[0] as { agentStatus?: { state?: string } } | undefined
|
|
return result?.type === 'updated' && tab?.agentStatus?.state === 'blocked'
|
|
})
|
|
const createResponse = await sendRequest(laptopEndpoint, {
|
|
id: 'laptop_create',
|
|
authToken: laptopAuthToken,
|
|
method: 'terminal.create',
|
|
params: {
|
|
worktree,
|
|
command: "claude 'work on the issue'",
|
|
tabId: 'laptop-tab',
|
|
leafId,
|
|
activate: true
|
|
}
|
|
})
|
|
const terminal = (
|
|
createResponse.result as {
|
|
terminal: { handle: string }
|
|
}
|
|
).terminal
|
|
runtime.onPtyData('paired-laptop-pty', '\x1b]0;Claude waiting for permission\x07', 456)
|
|
runtime.onPtyData('paired-laptop-pty', 'Need approval\r\n', 457)
|
|
|
|
await expect(blockedUpdate).resolves.toMatchObject({
|
|
ok: true,
|
|
streaming: true,
|
|
result: {
|
|
type: 'updated',
|
|
tabs: [
|
|
{
|
|
type: 'terminal',
|
|
id: `laptop-tab::${leafId}`,
|
|
parentTabId: 'laptop-tab',
|
|
leafId,
|
|
status: 'ready',
|
|
terminal: terminal.handle,
|
|
agentStatus: {
|
|
state: 'blocked',
|
|
paneKey: `laptop-tab:${leafId}`,
|
|
terminalHandle: terminal.handle
|
|
}
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
sendEncryptedWsRequest(phone, {
|
|
id: 'phone_read',
|
|
method: 'terminal.read',
|
|
params: { terminal: terminal.handle }
|
|
})
|
|
await expect(phoneResponses.next('phone_read')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
terminal: {
|
|
tail: ['Need approval']
|
|
}
|
|
}
|
|
})
|
|
|
|
sendEncryptedWsRequest(phone, {
|
|
id: 'phone_send',
|
|
method: 'terminal.send',
|
|
params: {
|
|
terminal: terminal.handle,
|
|
text: 'approved'
|
|
}
|
|
})
|
|
await expect(phoneResponses.next('phone_send')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
send: {
|
|
accepted: true
|
|
}
|
|
}
|
|
})
|
|
expect(writes).toEqual(['approved'])
|
|
} finally {
|
|
phoneResponses.dispose()
|
|
phone.ws.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('authorizes a mobile artifact tap after first-connect backfill even once the raw window scrolls', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
runtime.setPtyController({
|
|
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
|
write: () => true,
|
|
kill: () => true,
|
|
getCwd: async () => '/tmp/worktree-a',
|
|
getForegroundProcess: async () => null
|
|
})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
// Real artifact under the temp root so the grant path stats it.
|
|
const artifactPath = join(tmpdir(), `orca-artifact-${process.pid}-${Date.now()}.json`)
|
|
await writeFile(artifactPath, '{"ok":true}')
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Agent',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
// Path printed before any mobile client exists: tracking is inactive, so
|
|
// only the retained raw window knows it at connect time.
|
|
runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100)
|
|
|
|
await server.start()
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'phone',
|
|
scope: 'mobile'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
// Full direct E2EE authentication drives MobileSocketWiring.onReady (the
|
|
// relay transport attaches through the same wiring), which must backfill
|
|
// candidates from the raw window without any direct activation call.
|
|
const phone = await authenticateMobileWsSession(offer.pairingUrl)
|
|
const phoneResponses = createEncryptedWsResponseReader(phone)
|
|
try {
|
|
// Post-connect pathless output scrolls the artifact out of the raw
|
|
// 64KiB window; only the connect-time backfilled candidate can answer.
|
|
runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200)
|
|
|
|
sendEncryptedWsRequest(phone, {
|
|
id: 'phone_terminals',
|
|
method: 'terminal.list',
|
|
params: { worktree: 'id:repo-1::/tmp/worktree-a' }
|
|
})
|
|
const listResponse = await phoneResponses.next('phone_terminals')
|
|
const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]!
|
|
.handle
|
|
expect(handle).toBeTruthy()
|
|
|
|
sendEncryptedWsRequest(phone, {
|
|
id: 'phone_tap',
|
|
method: 'files.resolveTerminalPath',
|
|
params: {
|
|
worktree: 'id:repo-1::/tmp/worktree-a',
|
|
pathText: artifactPath,
|
|
terminal: handle
|
|
}
|
|
})
|
|
await expect(phoneResponses.next('phone_tap')).resolves.toMatchObject({
|
|
ok: true,
|
|
result: {
|
|
exists: true,
|
|
isDirectory: false,
|
|
openTarget: {
|
|
kind: 'absolute-file',
|
|
provider: 'local',
|
|
grantId: expect.any(String)
|
|
}
|
|
}
|
|
})
|
|
} finally {
|
|
phoneResponses.dispose()
|
|
phone.ws.close()
|
|
await server.stop()
|
|
await rm(artifactPath, { force: true })
|
|
}
|
|
})
|
|
|
|
it('completes remote E2EE authentication against a runtime proxy without activateRecentPtyPathCandidateTracking', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
// Why: a remote-host runtime proxy only implements RPC-forwarded methods;
|
|
// activation is a local-host concern, so the proxy legitimately lacks
|
|
// activateRecentPtyPathCandidateTracking and onReady must not throw.
|
|
const runtimeProxy = {
|
|
getRuntimeId: () => 'proxy-runtime-test',
|
|
getStartedAt: () => 1,
|
|
getStatus: () => ({ graphStatus: 'unavailable' }),
|
|
cleanupSubscriptionsForConnection: () => {},
|
|
cancelMobileDictationForConnection: () => {},
|
|
onClientDisconnected: () => {}
|
|
} as unknown as OrcaRuntimeService
|
|
expect(
|
|
(runtimeProxy as { activateRecentPtyPathCandidateTracking?: unknown })
|
|
.activateRecentPtyPathCandidateTracking
|
|
).toBeUndefined()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: runtimeProxy,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
const offer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'remote',
|
|
scope: 'runtime'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
if (!offer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
// Real E2EE pairing + authentication drives MobileSocketWiring.onReady
|
|
// before e2ee_authenticated is sent; a throwing onReady never authenticates.
|
|
const session = await authenticateMobileWsSession(offer.pairingUrl)
|
|
const responses = createEncryptedWsResponseReader(session)
|
|
try {
|
|
sendEncryptedWsRequest(session, { id: 'proxy_status', method: 'status.get' })
|
|
await expect(responses.next('proxy_status')).resolves.toMatchObject({
|
|
id: 'proxy_status',
|
|
ok: true,
|
|
result: { graphStatus: 'unavailable' }
|
|
})
|
|
} finally {
|
|
responses.dispose()
|
|
session.ws.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const writes: { terminal: string; text: string }[] = []
|
|
const runtime = new OrcaRuntimeService(makeStore() as never)
|
|
const spawn = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ id: 'multiplex-background-pty' })
|
|
.mockResolvedValueOnce({ id: 'multiplex-active-pty' })
|
|
runtime.setPtyController({
|
|
spawn,
|
|
write: (ptyId, data) => {
|
|
writes.push({ terminal: ptyId, text: data })
|
|
return true
|
|
},
|
|
kill: () => true,
|
|
getForegroundProcess: async () => null
|
|
})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
const phoneOffer = server.createPairingOffer({
|
|
address: '127.0.0.1',
|
|
name: 'phone',
|
|
scope: 'mobile'
|
|
})
|
|
expect(phoneOffer.available).toBe(true)
|
|
if (!phoneOffer.available) {
|
|
throw new Error('WebSocket pairing unavailable')
|
|
}
|
|
const pairing = parsePairingCode(phoneOffer.pairingUrl)
|
|
expect(pairing).toBeTruthy()
|
|
if (!pairing) {
|
|
throw new Error('Pairing URL did not parse')
|
|
}
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const laptopEndpoint = metadata!.transports[0]!.endpoint
|
|
const laptopAuthToken = metadata!.authToken
|
|
const worktree = 'id:repo-1::/tmp/worktree-a'
|
|
const backgroundLeafId = '11111111-1111-4111-8111-111111111111'
|
|
const activeLeafId = '22222222-2222-4222-8222-222222222222'
|
|
const backgroundCreateResponse = await sendRequest(laptopEndpoint, {
|
|
id: 'laptop_create_background',
|
|
authToken: laptopAuthToken,
|
|
method: 'terminal.create',
|
|
params: {
|
|
worktree,
|
|
command: 'background',
|
|
tabId: 'multiplex-background-tab',
|
|
leafId: backgroundLeafId
|
|
}
|
|
})
|
|
const activeCreateResponse = await sendRequest(laptopEndpoint, {
|
|
id: 'laptop_create_active',
|
|
authToken: laptopAuthToken,
|
|
method: 'terminal.create',
|
|
params: {
|
|
worktree,
|
|
command: 'active',
|
|
tabId: 'multiplex-active-tab',
|
|
leafId: activeLeafId,
|
|
activate: true
|
|
}
|
|
})
|
|
const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } })
|
|
.terminal
|
|
const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } })
|
|
.terminal
|
|
|
|
const responses: Record<string, unknown>[] = []
|
|
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
|
const onError = vi.fn()
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
pairing,
|
|
'terminal.multiplex',
|
|
{},
|
|
15_000,
|
|
{
|
|
onResponse: (response) => responses.push(response as Record<string, unknown>),
|
|
onBinary: (bytes) => binaryFrames.push(bytes),
|
|
onError
|
|
}
|
|
)
|
|
|
|
try {
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
responses.some(
|
|
(response) => (response.result as { type?: string } | undefined)?.type === 'ready'
|
|
)
|
|
).toBe(true)
|
|
)
|
|
subscription.sendBinary(
|
|
encodeTerminalStreamFrame({
|
|
seq: 1,
|
|
opcode: TerminalStreamOpcode.Subscribe,
|
|
streamId: 0,
|
|
payload: encodeTerminalStreamJson({
|
|
streamId: 21,
|
|
terminal: backgroundTerminal.handle,
|
|
client: { id: 'desktop-background', type: 'desktop' },
|
|
capabilities: { ackOutput: 1 }
|
|
})
|
|
})
|
|
)
|
|
subscription.sendBinary(
|
|
encodeTerminalStreamFrame({
|
|
seq: 2,
|
|
opcode: TerminalStreamOpcode.Subscribe,
|
|
streamId: 0,
|
|
payload: encodeTerminalStreamJson({
|
|
streamId: 22,
|
|
terminal: activeTerminal.handle,
|
|
client: { id: 'desktop-active', type: 'desktop' },
|
|
capabilities: { ackOutput: 1 }
|
|
})
|
|
})
|
|
)
|
|
await vi.waitFor(() => {
|
|
const subscribedStreamIds = responses
|
|
.map((response) => response.result as { type?: string; streamId?: number } | undefined)
|
|
.filter((result) => result?.type === 'subscribed')
|
|
.map((result) => result?.streamId)
|
|
expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22]))
|
|
})
|
|
binaryFrames.splice(0)
|
|
|
|
const backgroundOutput = 'B'.repeat(700 * 1024)
|
|
runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1)
|
|
await vi.waitFor(() => {
|
|
const backgroundFrames = binaryFrames
|
|
.map((frame) => decodeTerminalStreamFrame(frame))
|
|
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
|
const backgroundBytes = backgroundFrames.reduce(
|
|
(total, frame) => total + (frame?.payload.byteLength ?? 0),
|
|
0
|
|
)
|
|
expect(backgroundBytes).toBeGreaterThan(0)
|
|
expect(backgroundBytes).toBeLessThan(backgroundOutput.length)
|
|
})
|
|
|
|
const frameCountBeforeActive = binaryFrames.length
|
|
runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2)
|
|
await vi.waitFor(() => {
|
|
const activeOutput = binaryFrames
|
|
.slice(frameCountBeforeActive)
|
|
.map((frame) => decodeTerminalStreamFrame(frame))
|
|
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22)
|
|
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
|
|
.join('')
|
|
expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY')
|
|
})
|
|
|
|
subscription.sendBinary(
|
|
encodeTerminalStreamFrame({
|
|
seq: 3,
|
|
opcode: TerminalStreamOpcode.Input,
|
|
streamId: 22,
|
|
payload: encodeTerminalStreamText('still interactive\r')
|
|
})
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(writes).toContainEqual({
|
|
terminal: 'multiplex-active-pty',
|
|
text: 'still interactive\r'
|
|
})
|
|
)
|
|
|
|
const backgroundBytesBeforeAck = binaryFrames
|
|
.map((frame) => decodeTerminalStreamFrame(frame))
|
|
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
|
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
|
subscription.sendBinary(
|
|
encodeTerminalStreamFrame({
|
|
seq: 4,
|
|
opcode: TerminalStreamOpcode.Ack,
|
|
streamId: 21,
|
|
payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck })
|
|
})
|
|
)
|
|
await vi.waitFor(() => {
|
|
const backgroundBytesAfterAck = binaryFrames
|
|
.map((frame) => decodeTerminalStreamFrame(frame))
|
|
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21)
|
|
.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0)
|
|
expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck)
|
|
})
|
|
expect(onError).not.toHaveBeenCalled()
|
|
} finally {
|
|
subscription.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('serves worktree.ps from the runtime summary builder', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Claude',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'hello\n', 555)
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_ps',
|
|
authToken: metadata!.authToken,
|
|
method: 'worktree.ps'
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
id: 'req_ps',
|
|
ok: true,
|
|
result: {
|
|
worktrees: [
|
|
{
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
repoId: 'repo-1',
|
|
repo: 'repo',
|
|
path: '/tmp/worktree-a',
|
|
branch: 'feature/foo',
|
|
linkedIssue: 123,
|
|
sortOrder: 0,
|
|
unread: true,
|
|
liveTerminalCount: 1,
|
|
hasAttachedPty: true,
|
|
lastOutputAt: 555,
|
|
preview: 'hello'
|
|
}
|
|
],
|
|
totalCount: 1,
|
|
truncated: false
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('bounds worktree.list responses with limit metadata', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_worktrees',
|
|
authToken: metadata!.authToken,
|
|
method: 'worktree.list',
|
|
params: {
|
|
limit: 1
|
|
}
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
id: 'req_worktrees',
|
|
ok: true,
|
|
result: {
|
|
totalCount: 1,
|
|
truncated: false
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
it('rejects oversized RPC frames instead of buffering them indefinitely', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
|
|
await server.start()
|
|
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
const socket = createConnection(metadata!.transports[0]!.endpoint)
|
|
let buffer = ''
|
|
socket.setEncoding('utf8')
|
|
socket.once('error', reject)
|
|
socket.on('data', (chunk: string) => {
|
|
buffer += chunk
|
|
const newlineIndex = buffer.indexOf('\n')
|
|
if (newlineIndex === -1) {
|
|
return
|
|
}
|
|
socket.end()
|
|
resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>)
|
|
})
|
|
socket.on('connect', () => {
|
|
socket.write(`${'x'.repeat(1024 * 1024 + 1)}\n`)
|
|
})
|
|
})
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'request_too_large'
|
|
}
|
|
})
|
|
|
|
await server.stop()
|
|
})
|
|
|
|
// Why: §6 tests for the transport keepalive + long-poll counter path in §3.1.
|
|
// Exercise the real socket (not a mock) so we catch buffer/flush regressions
|
|
// that a unit-level test would miss.
|
|
describe('long-poll transport (§3.1)', () => {
|
|
it('emits keepalive frames while a check --wait handler blocks', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
// Why: 50ms keepalive lets us collect ≥3 frames within a 300ms wait
|
|
// window without slowing the suite.
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 50
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: {
|
|
terminal: 'term_nobody',
|
|
wait: true,
|
|
timeoutMs: 300
|
|
}
|
|
})
|
|
await session.done
|
|
|
|
const keepalives = session.frames.filter((f) => f._keepalive === true)
|
|
const terminals = session.frames.filter((f) => f.ok !== undefined)
|
|
expect(terminals).toHaveLength(1)
|
|
expect(terminals[0]).toMatchObject({ id: 'req_wait', ok: true })
|
|
// Why: 300ms wait with 50ms keepalive → expect roughly 5 keepalives;
|
|
// assert ≥3 to tolerate scheduler jitter without flaking.
|
|
expect(keepalives.length).toBeGreaterThanOrEqual(3)
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('emits keepalive frames while orchestration.ask blocks for a reply', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const askerPaneKey = 'tab_asker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
|
handle === 'term_asker' ? askerPaneKey : null
|
|
)
|
|
const run = db.createRun({
|
|
objective: 'Keepalive test',
|
|
coordinatorHandle: 'term_nobody',
|
|
coordinatorPaneKey: 'tab_coord:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
|
})
|
|
const task = db.createTask({ spec: 'Wait for an answer', runId: run.id })
|
|
db.createDispatchContext(task.id, 'term_asker', askerPaneKey)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 50
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
// Why: no reply is ever sent, so ask blocks the full window on the same
|
|
// hold-the-socket path check --wait uses. Without ask in the long-poll
|
|
// set the 30s idle timer would tear this down before it keepalives.
|
|
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_ask',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.ask',
|
|
params: {
|
|
to: 'term_nobody',
|
|
from: 'term_asker',
|
|
question: 'ping?',
|
|
timeoutMs: 300
|
|
}
|
|
})
|
|
await session.done
|
|
|
|
const keepalives = session.frames.filter((f) => f._keepalive === true)
|
|
const terminals = session.frames.filter((f) => f.ok !== undefined)
|
|
expect(terminals).toHaveLength(1)
|
|
expect(terminals[0]).toMatchObject({
|
|
id: 'req_ask',
|
|
ok: true,
|
|
result: { timedOut: true }
|
|
})
|
|
expect(keepalives.length).toBeGreaterThanOrEqual(3)
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('emits keepalive frames while terminal.wait blocks and returns its structured timeout', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 30
|
|
})
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Terminal 1',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
runtime.onPtyData('pty-1', 'Starting MCP servers...\n', 123)
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_list',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.list'
|
|
})
|
|
const handle = (
|
|
listResponse.result as {
|
|
terminals: { handle: string }[]
|
|
}
|
|
).terminals[0]!.handle
|
|
|
|
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_terminal_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.wait',
|
|
params: {
|
|
terminal: handle,
|
|
for: 'tui-idle',
|
|
timeoutMs: 150
|
|
}
|
|
})
|
|
await session.done
|
|
|
|
const keepalives = session.frames.filter((f) => f._keepalive === true)
|
|
const terminalFrames = session.frames.filter((f) => f.ok !== undefined)
|
|
expect(keepalives.length).toBeGreaterThanOrEqual(2)
|
|
expect(terminalFrames).toHaveLength(1)
|
|
expect(terminalFrames[0]).toMatchObject({
|
|
id: 'req_terminal_wait',
|
|
ok: false,
|
|
error: { code: 'timeout' }
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('releases terminal.wait long-poll slot when the client closes mid-wait', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 1
|
|
})
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Terminal 1',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const listResponse = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_list',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.list'
|
|
})
|
|
const handle = (
|
|
listResponse.result as {
|
|
terminals: { handle: string }[]
|
|
}
|
|
).terminals[0]!.handle
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
|
|
const session = openFramedSession(endpoint, {
|
|
id: 'req_terminal_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.wait',
|
|
params: { terminal: handle, for: 'exit', timeoutMs: 10_000 }
|
|
})
|
|
await waitFor(() => server['activeLongPolls'] === 1)
|
|
|
|
session.socket.destroy()
|
|
await session.done
|
|
await waitFor(() => server['activeLongPolls'] === 0)
|
|
|
|
const admitted = openFramedSession(endpoint, {
|
|
id: 'req_terminal_wait_2',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.wait',
|
|
params: { terminal: handle, for: 'tui-idle', timeoutMs: 50 }
|
|
})
|
|
await admitted.done
|
|
expect(admitted.frames.find((f) => f.ok !== undefined)).toMatchObject({
|
|
id: 'req_terminal_wait_2',
|
|
ok: false,
|
|
error: { code: 'timeout' }
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('releases long-poll slot when client closes mid-wait', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 2
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
|
|
// Fill the cap with two long waits (10s each — we'll kill them).
|
|
const a = openFramedSession(endpoint, {
|
|
id: 'req_a',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_a', wait: true, timeoutMs: 10_000 }
|
|
})
|
|
const b = openFramedSession(endpoint, {
|
|
id: 'req_b',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_b', wait: true, timeoutMs: 10_000 }
|
|
})
|
|
// Let the two waits land in the handler and increment the counter.
|
|
await sleep(100)
|
|
expect(server['activeLongPolls']).toBe(2)
|
|
|
|
// Kill one client mid-wait; counter must drop to 1.
|
|
a.socket.destroy()
|
|
await a.done
|
|
// Give Node one tick to fire the close event on the server socket.
|
|
await sleep(50)
|
|
expect(server['activeLongPolls']).toBe(1)
|
|
|
|
// The freed slot must admit a new long-poll immediately.
|
|
const c = openFramedSession(endpoint, {
|
|
id: 'req_c',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_c', wait: true, timeoutMs: 100 }
|
|
})
|
|
await c.done
|
|
const cTerminal = c.frames.find((f) => f.ok !== undefined)
|
|
expect(cTerminal).toMatchObject({ ok: true, id: 'req_c' })
|
|
|
|
b.socket.destroy()
|
|
await b.done
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('destroys active Unix socket connections when the runtime stops', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 1
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
|
|
const session = openFramedSession(endpoint, {
|
|
id: 'req_stop',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_stop', wait: true, timeoutMs: 10_000 }
|
|
})
|
|
await waitFor(() => server['activeLongPolls'] === 1)
|
|
|
|
const stopResult = await Promise.race([
|
|
server.stop().then(() => 'stopped'),
|
|
sleep(500).then(() => 'timeout')
|
|
])
|
|
|
|
expect(stopResult).toBe('stopped')
|
|
await session.done
|
|
await waitFor(() => server['activeLongPolls'] === 0)
|
|
expect(session.socket.destroyed).toBe(true)
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('responds runtime_busy once the long-poll cap is saturated', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 1
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
|
|
const a = openFramedSession(endpoint, {
|
|
id: 'req_a',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_a', wait: true, timeoutMs: 5_000 }
|
|
})
|
|
await sleep(100)
|
|
expect(server['activeLongPolls']).toBe(1)
|
|
|
|
// Second long-poll overflows the cap → runtime_busy.
|
|
const overflow = await sendRequest(endpoint, {
|
|
id: 'req_overflow',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_b', wait: true, timeoutMs: 5_000 }
|
|
})
|
|
expect(overflow).toMatchObject({
|
|
id: 'req_overflow',
|
|
ok: false,
|
|
error: { code: 'runtime_busy' }
|
|
})
|
|
// The failing request must not have counted against the cap.
|
|
expect(server['activeLongPolls']).toBe(1)
|
|
|
|
// Short RPCs still succeed even when the long-poll cap is full.
|
|
const short = await sendRequest(endpoint, {
|
|
id: 'req_short',
|
|
authToken: metadata!.authToken,
|
|
method: 'status.get'
|
|
})
|
|
expect(short).toMatchObject({ id: 'req_short', ok: true })
|
|
|
|
a.socket.destroy()
|
|
await a.done
|
|
} finally {
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('reserves long-poll headroom for terminal.wait when orchestration.ask floods', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
seedSupervisedAskWorkers(db, ['term_w0', 'term_w1', 'term_w2', 'term_w3'])
|
|
// Why: cap 4 → ask sub-cap 2, so 4 concurrent asks can only take half the budget.
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 4
|
|
})
|
|
runtime.attachWindow(1)
|
|
runtime.syncWindowGraph(1, {
|
|
tabs: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
title: 'Terminal 1',
|
|
activeLeafId: 'pane:1',
|
|
layout: null
|
|
}
|
|
],
|
|
leaves: [
|
|
{
|
|
tabId: 'tab-1',
|
|
worktreeId: 'repo-1::/tmp/worktree-a',
|
|
leafId: 'pane:1',
|
|
paneRuntimeId: 1,
|
|
ptyId: 'pty-1'
|
|
}
|
|
]
|
|
})
|
|
await server.start()
|
|
|
|
const asks: ReturnType<typeof openFramedSession>[] = []
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
const listResponse = await sendRequest(endpoint, {
|
|
id: 'req_list',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.list'
|
|
})
|
|
const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]!
|
|
.handle
|
|
|
|
// Four workers block in ask; distinct `from` handles so no reply wakes another.
|
|
for (let i = 0; i < 4; i++) {
|
|
asks.push(
|
|
openFramedSession(endpoint, {
|
|
id: `req_ask_${i}`,
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.ask',
|
|
params: {
|
|
from: `term_w${i}`,
|
|
to: 'term_coord',
|
|
question: 'proceed?',
|
|
timeoutMs: 10_000
|
|
}
|
|
})
|
|
)
|
|
}
|
|
// Let every ask reach the admission fence before probing the reserved half.
|
|
await waitFor(() => server['activeLongPolls'] >= 2)
|
|
await sleep(100)
|
|
|
|
// The reserved half still admits a terminal.wait from any other client.
|
|
const admitted = openFramedSession(endpoint, {
|
|
id: 'req_terminal_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'terminal.wait',
|
|
params: { terminal: handle, for: 'tui-idle', timeoutMs: 50 }
|
|
})
|
|
await admitted.done
|
|
expect(admitted.frames.find((f) => f.ok !== undefined)).toMatchObject({
|
|
id: 'req_terminal_wait',
|
|
ok: false,
|
|
error: { code: 'timeout' }
|
|
})
|
|
|
|
// …and a check --wait too, which shares the same reserved class.
|
|
const check = openFramedSession(endpoint, {
|
|
id: 'req_check_wait',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_other', wait: true, timeoutMs: 100 }
|
|
})
|
|
await check.done
|
|
expect(check.frames.find((f) => f.ok !== undefined)).toMatchObject({
|
|
id: 'req_check_wait',
|
|
ok: true
|
|
})
|
|
|
|
// Overflow asks are shed, not queued: the sub-cap holds at half the budget.
|
|
expect(server['activeAskLongPolls']).toBe(2)
|
|
const shed = asks
|
|
.map((a) => a.frames.find((f) => f.ok !== undefined))
|
|
.filter((f) => f !== undefined)
|
|
expect(shed).toHaveLength(2)
|
|
expect(shed[0]).toMatchObject({ ok: false, error: { code: 'runtime_busy' } })
|
|
} finally {
|
|
for (const ask of asks) {
|
|
ask.socket.destroy()
|
|
}
|
|
await Promise.all(asks.map((ask) => ask.done))
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('keeps the full cap available to terminal.wait and check --wait', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const db = new OrchestrationDb(':memory:')
|
|
runtime.setOrchestrationDb(db)
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 1000,
|
|
longPollCap: 4
|
|
})
|
|
await server.start()
|
|
|
|
const waits: ReturnType<typeof openFramedSession>[] = []
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const endpoint = metadata!.transports[0]!.endpoint
|
|
|
|
// The ask sub-cap must not narrow the budget for the reserved class.
|
|
for (let i = 0; i < 4; i++) {
|
|
waits.push(
|
|
openFramedSession(endpoint, {
|
|
id: `req_wait_${i}`,
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: `term_${i}`, wait: true, timeoutMs: 10_000 }
|
|
})
|
|
)
|
|
}
|
|
await waitFor(() => server['activeLongPolls'] === 4)
|
|
expect(server['activeAskLongPolls']).toBe(0)
|
|
|
|
const overflow = await sendRequest(endpoint, {
|
|
id: 'req_overflow',
|
|
authToken: metadata!.authToken,
|
|
method: 'orchestration.check',
|
|
params: { terminal: 'term_overflow', wait: true, timeoutMs: 5_000 }
|
|
})
|
|
expect(overflow).toMatchObject({ ok: false, error: { code: 'runtime_busy' } })
|
|
} finally {
|
|
for (const wait of waits) {
|
|
wait.socket.destroy()
|
|
}
|
|
await Promise.all(waits.map((wait) => wait.done))
|
|
db.close()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('does not emit keepalive frames for short RPCs', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
// Why: a 10ms interval means any frame in the first ~100ms of a short
|
|
// RPC would show up; `status.get` returns in <10ms so no keepalive
|
|
// should ever fire. Locks in the "keepalive is long-poll-only" invariant
|
|
// so a future refactor can't silently re-broaden the timer.
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime,
|
|
userDataPath,
|
|
keepaliveIntervalMs: 10
|
|
})
|
|
await server.start()
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const session = openFramedSession(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_short',
|
|
authToken: metadata!.authToken,
|
|
method: 'status.get'
|
|
})
|
|
await session.done
|
|
|
|
const keepalives = session.frames.filter((f) => f._keepalive === true)
|
|
const terminals = session.frames.filter((f) => f.ok !== undefined)
|
|
expect(terminals).toHaveLength(1)
|
|
expect(terminals[0]).toMatchObject({ id: 'req_short', ok: true })
|
|
expect(keepalives).toHaveLength(0)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('returns an internal_error envelope when the dispatcher throws', async () => {
|
|
// Why: handlers are designed to return error envelopes, never to throw,
|
|
// but a bug somewhere in the RPC stack (e.g. JSON.stringify choking on
|
|
// a response with circular refs) must still produce a terminal frame.
|
|
// Without the `.catch` on handleMessage's promise, a throw would leave
|
|
// the client hanging until the 30s idle timer and leak the dispatch's
|
|
// AbortController in the transport's in-flight set.
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const runtime = new OrcaRuntimeService()
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
|
await server.start()
|
|
|
|
// Force the dispatcher to throw a non-envelope error.
|
|
const originalDispatch = server['dispatcher'].dispatch.bind(server['dispatcher'])
|
|
server['dispatcher'].dispatch = vi.fn().mockRejectedValue(new Error('boom'))
|
|
|
|
try {
|
|
const metadata = readRuntimeMetadata(userDataPath)
|
|
const response = await sendRequest(metadata!.transports[0]!.endpoint, {
|
|
id: 'req_throw',
|
|
authToken: metadata!.authToken,
|
|
method: 'status.get'
|
|
})
|
|
expect(response).toMatchObject({
|
|
id: 'req_throw',
|
|
ok: false,
|
|
error: { code: 'internal_error', message: 'boom' }
|
|
})
|
|
} finally {
|
|
server['dispatcher'].dispatch = originalDispatch
|
|
await server.stop()
|
|
}
|
|
})
|
|
})
|
|
|
|
// Why: §6 test for the idempotent + hard-fail schema migration. A broken
|
|
// migration must crash startup loudly rather than serve traffic against a
|
|
// schema missing the delivered_at column.
|
|
describe('orchestration DB migration (§3.2)', () => {
|
|
it('is idempotent when delivered_at already exists', () => {
|
|
// First open creates the column; second open should be a no-op.
|
|
const db1 = new OrchestrationDb(':memory:')
|
|
db1.close()
|
|
// File path reuse is meaningless with :memory:, so use a tmp file.
|
|
const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite')
|
|
const a = new OrchestrationDb(tmpPath)
|
|
a.close()
|
|
// Second construction must not throw "duplicate column name".
|
|
expect(() => {
|
|
const b = new OrchestrationDb(tmpPath)
|
|
b.close()
|
|
}).not.toThrow()
|
|
})
|
|
|
|
it('hard-fails startup when the migration cannot be applied', () => {
|
|
// Simulate a migration error by monkey-patching the SQLite wrapper's exec.
|
|
// If ALTER TABLE throws for any reason (e.g. disk full, permissions),
|
|
// the constructor must propagate — not swallow and serve half-broken.
|
|
//
|
|
// Why the pre-seeded v2 DB: after the schema bundle, fresh DBs are
|
|
// initialized directly at v3 via createTables() (which already includes
|
|
// `delivered_at`), so the v2 → v3 ALTER is a no-op for new installs.
|
|
// To exercise the hard-fail path we need a DB that actually has work
|
|
// to migrate — a v2-shape file without the delivered_at column — so
|
|
// the guarded ALTER runs and the stub can fire.
|
|
const tmpPath = join(mkdtempSync(join(tmpdir(), 'orca-orch-mig-')), 'orch.sqlite')
|
|
const seed = new Database(tmpPath)
|
|
seed.exec(`
|
|
CREATE TABLE messages (
|
|
id TEXT NOT NULL,
|
|
from_handle TEXT NOT NULL,
|
|
to_handle TEXT NOT NULL,
|
|
subject TEXT NOT NULL,
|
|
body TEXT NOT NULL DEFAULT '',
|
|
type TEXT NOT NULL DEFAULT 'status'
|
|
CHECK(type IN (
|
|
'status', 'dispatch', 'worker_done', 'merge_ready',
|
|
'escalation', 'handoff', 'decision_gate', 'heartbeat'
|
|
)),
|
|
priority TEXT NOT NULL DEFAULT 'normal'
|
|
CHECK(priority IN ('normal', 'high', 'urgent')),
|
|
thread_id TEXT,
|
|
payload TEXT,
|
|
read INTEGER NOT NULL DEFAULT 0,
|
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`)
|
|
seed.pragma('user_version = 2')
|
|
seed.close()
|
|
|
|
const realPrototype = Database.prototype as unknown as {
|
|
exec: (sql: string) => unknown
|
|
}
|
|
const originalExec = realPrototype.exec
|
|
realPrototype.exec = function (sql: string) {
|
|
if (sql.includes('ALTER TABLE messages ADD COLUMN delivered_at')) {
|
|
throw new Error('simulated migration failure')
|
|
}
|
|
return originalExec.call(this, sql)
|
|
}
|
|
try {
|
|
expect(() => new OrchestrationDb(tmpPath)).toThrow('simulated migration failure')
|
|
} finally {
|
|
realPrototype.exec = originalExec
|
|
}
|
|
})
|
|
})
|
|
})
|