mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
The runtime RPC WebSocket listener bound to 0.0.0.0:6769 at startup, so a desktop with no paired device was reachable from the whole LAN before the user opted in. Default the bind to 127.0.0.1 and widen to all interfaces only on an explicit opt-in: - createMobilePairingOffer / getRuntimePairingUrl widen (ensureNetworkExposure) before advertising a LAN endpoint; the rebind reuses the resolved port so an already-issued offer stays valid, and concurrent offers share one rebind. - orca serve and E2E set exposeNetworkByDefault to bind wide at startup. - A previously-connected device (lastSeenAt > 0) rebinds wide at startup so reconnect after restart keeps working; a pending/never-connected offer does not persist exposure across a restart. The advertised pairing endpoint still resolves to a concrete interface address, never the 0.0.0.0 bind host.
5859 lines
198 KiB
TypeScript
5859 lines
198 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 { WebSocketTransport } from './rpc/ws-transport'
|
|
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('rejects unpaired terminal creates before runtime dispatch', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const createMobileSessionTerminal = vi.fn()
|
|
const runtime = {
|
|
getRuntimeId: () => 'test-runtime',
|
|
createMobileSessionTerminal
|
|
} as unknown as OrcaRuntimeService
|
|
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
|
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
|
|
const replies: Record<string, unknown>[] = []
|
|
const send = async (id: string, deviceToken?: string): Promise<void> => {
|
|
await server['handleWebSocketMessage'](
|
|
JSON.stringify({
|
|
id,
|
|
method: 'session.tabs.createTerminal',
|
|
...(deviceToken ? { deviceToken } : {}),
|
|
params: { worktree: 'id:wt-1' }
|
|
}),
|
|
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
|
() => {}
|
|
)
|
|
}
|
|
|
|
await send('req_missing')
|
|
await send('req_invalid', 'invalid-token')
|
|
|
|
expect(replies).toEqual([
|
|
expect.objectContaining({
|
|
id: 'req_missing',
|
|
error: expect.objectContaining({ code: 'unauthorized' }),
|
|
ok: false
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'req_invalid',
|
|
error: expect.objectContaining({ code: 'unauthorized' }),
|
|
ok: false
|
|
})
|
|
])
|
|
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => {
|
|
const wsTransportOf = (server: OrcaRuntimeRpcServer): WebSocketTransport | undefined =>
|
|
(server['activeTransports'] as unknown[]).find(
|
|
(transport): transport is WebSocketTransport => transport instanceof WebSocketTransport
|
|
)
|
|
|
|
it('binds the listener to loopback on a fresh desktop with no paired device', 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)
|
|
// Why: the exposure regression — before STA-2370 this bound 0.0.0.0 with zero devices paired.
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('widens the listener to all interfaces when a mobile pairing offer is created', 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 loopbackPort = wsTransportOf(server)?.resolvedPort
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('0.0.0.0')
|
|
// Why: the widen reuses the same port so the QR's already-advertised endpoint stays valid.
|
|
expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('binds all interfaces at startup when exposeNetworkByDefault is set (orca serve)', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0,
|
|
exposeNetworkByDefault: true
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('0.0.0.0')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('binds all interfaces at startup when a previously-connected device can reconnect', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
// Why: a device that has actually connected (lastSeenAt > 0) may reconnect, so the listener must be
|
|
// reachable at startup without waiting for a new pairing action.
|
|
const registry = new DeviceRegistry(userDataPath)
|
|
const device = registry.getOrCreatePendingDevice('Paired phone', 'mobile')
|
|
registry.updateLastSeen(device.deviceId)
|
|
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
expect(
|
|
server
|
|
.getDeviceRegistry()
|
|
?.listDevices()
|
|
.some((d) => d.lastSeenAt > 0)
|
|
).toBe(true)
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('stays on loopback at startup for a pending device that has never connected', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
// Why: a pending device (offer created but never connected: lastSeenAt === 0) is not a reconnect, so
|
|
// the listener must stay loopback — this distinguishes the reconnect widen from a blanket any-device
|
|
// widen (a revert to listDevices().length > 0 would wrongly expose the LAN here).
|
|
const registry = new DeviceRegistry(userDataPath)
|
|
registry.getOrCreatePendingDevice('Pending phone', 'mobile')
|
|
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(1)
|
|
expect(
|
|
server
|
|
.getDeviceRegistry()
|
|
?.listDevices()
|
|
.every((d) => d.lastSeenAt === 0)
|
|
).toBe(true)
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('keeps the same MobileSocketWiring instance across a pairing widen (relay capture stays valid)', 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 wiringBeforeWiden = server.getMobileSocketWiring()
|
|
expect(wiringBeforeWiden).not.toBeNull()
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(offer.available).toBe(true)
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
|
|
// Why: DesktopRelayService captures the wiring once at construction and hands it to every relay
|
|
// broker, so the widen must swap the transport under the SAME wiring — replacing the wiring would
|
|
// strand relay sockets on a dead object (lost connection IDs, binary handling, revocation targeting).
|
|
expect(server.getMobileSocketWiring()).toBe(wiringBeforeWiden)
|
|
|
|
// Why: object identity alone would pass even if the post-widen transport were never re-attached to the
|
|
// captured wiring. Prove the swap functionally — route a revocation through the pre-widen wiring and
|
|
// assert it reaches the NEW (0.0.0.0) transport, confirming attachTransport ran on the same wiring
|
|
// after the rebind. A revert that leaves the wiring pointed at the stopped loopback transport fails here.
|
|
const widenedTransport = wsTransportOf(server)
|
|
expect(widenedTransport).toBeDefined()
|
|
const terminateSpy = vi.spyOn(widenedTransport!, 'terminateClientConnections')
|
|
wiringBeforeWiden!.terminateDeviceConnections('device-token-xyz')
|
|
expect(terminateSpy).toHaveBeenCalledWith('device-token-xyz')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('coalesces concurrent pairing widens into a single rebind', 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(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
const widenSpy = vi.spyOn(
|
|
server as unknown as { widenWebSocketBind: () => Promise<void> },
|
|
'widenWebSocketBind'
|
|
)
|
|
|
|
await Promise.all([server.ensureNetworkExposure(), server.ensureNetworkExposure()])
|
|
|
|
// Why: two racing pairing offers must share one rebind via networkExposurePromise — two competing
|
|
// stop/start races would fight for the port and could strand the listener on a random one.
|
|
expect(widenSpy).toHaveBeenCalledTimes(1)
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
it('reports pairing unavailable but keeps a serving loopback listener when the widen bind fails', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
const loopbackPort = wsTransportOf(server)?.resolvedPort
|
|
// Why: force the wide bind to throw AFTER the loopback listener is stopped, then let the loopback
|
|
// recovery bind through — proving no stranded/closed socket and a retry-able state.
|
|
const target = server as unknown as {
|
|
startWebSocketTransport: (opts: { host: string }) => Promise<unknown>
|
|
wsBoundHost: string | null
|
|
}
|
|
const original = target.startWebSocketTransport.bind(server)
|
|
vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => {
|
|
if (opts.host === '0.0.0.0') {
|
|
throw new Error('injected wide bind failure')
|
|
}
|
|
return original(opts)
|
|
})
|
|
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
// Why: a failed widen must NOT advertise a LAN endpoint with no LAN listener behind it — the offer is
|
|
// reported unavailable (STA-2370). A revert that swallows the widen failure would return available:true.
|
|
expect(offer.available).toBe(false)
|
|
if (!offer.available) {
|
|
expect(offer.reason).toBe('network_exposure_failed')
|
|
}
|
|
// Why: the listener must keep serving on loopback (same port) rather than being left stranded/closed.
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
|
|
expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort)
|
|
// Why: wsBoundHost stays loopback so a later pairing offer retries the widen.
|
|
expect(target.wsBoundHost).toBe('127.0.0.1')
|
|
} finally {
|
|
await server.stop()
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('keeps the widened listener tracked when persisting pairing metadata fails', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
try {
|
|
// Why: fail metadata publication AFTER the wide bind already succeeded. The live 0.0.0.0 listener must
|
|
// stay tracked in activeTransports — a revert that runs loopback recovery here orphans a running wide
|
|
// listener (an untracked 0.0.0.0 socket outside stop(): the exact STA-2370 exposure).
|
|
const target = server as unknown as {
|
|
writeMetadata: () => void
|
|
wsBoundHost: string | null
|
|
activeTransports: unknown[]
|
|
}
|
|
let injected = false
|
|
const originalWrite = target.writeMetadata.bind(server)
|
|
vi.spyOn(target, 'writeMetadata').mockImplementation(() => {
|
|
if (!injected && target.wsBoundHost === '0.0.0.0') {
|
|
injected = true
|
|
throw new Error('injected metadata write failure')
|
|
}
|
|
return originalWrite()
|
|
})
|
|
|
|
const offer = await server.createMobilePairingOffer({
|
|
address: '100.64.1.20',
|
|
connectionMode: 'local-only'
|
|
})
|
|
expect(injected).toBe(true)
|
|
// Why: only metadata persistence failed; the wide bind succeeded, so pairing is available and the wide
|
|
// listener is tracked (not orphaned) — bound to all interfaces, exactly one WebSocket transport.
|
|
expect(offer.available).toBe(true)
|
|
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
|
|
expect(target.activeTransports.filter((t) => t instanceof WebSocketTransport)).toHaveLength(1)
|
|
} finally {
|
|
await server.stop()
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('does not strand a wide listener when stop() races an in-flight widen', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
|
|
const target = server as unknown as {
|
|
startWebSocketTransport: (opts: {
|
|
host: string
|
|
}) => Promise<{ transport: WebSocketTransport; endpoint: string }>
|
|
activeTransports: unknown[]
|
|
}
|
|
const original = target.startWebSocketTransport.bind(server)
|
|
let releaseWideStart: () => void = () => {}
|
|
const wideStartGate = new Promise<void>((resolve) => {
|
|
releaseWideStart = resolve
|
|
})
|
|
let wideStopSpy: ReturnType<typeof vi.spyOn> | null = null
|
|
vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => {
|
|
if (opts.host === '0.0.0.0') {
|
|
// Why: hold the widen mid-flight (loopback already stopped) so stop() must race the rebind.
|
|
await wideStartGate
|
|
const result = await original(opts)
|
|
wideStopSpy = vi.spyOn(result.transport, 'stop')
|
|
return result
|
|
}
|
|
return original(opts)
|
|
})
|
|
|
|
// Why: begin a pairing widen but do not await it, then start shutdown while it is still in-flight.
|
|
const widen = server.ensureNetworkExposure()
|
|
const stopping = server.stop()
|
|
releaseWideStart()
|
|
await Promise.all([widen.catch(() => {}), stopping])
|
|
|
|
try {
|
|
// Why: STA-2370 — stop() must fence and await the in-flight widen so the freshly-bound wide listener is
|
|
// snapshotted and stopped, never written back into the cleared arrays as a live 0.0.0.0 leak. A revert
|
|
// (no fence) leaves the wide transport in activeTransports after stop() and never calls its stop().
|
|
expect(target.activeTransports).toHaveLength(0)
|
|
expect(wideStopSpy).not.toBeNull()
|
|
expect(wideStopSpy).toHaveBeenCalled()
|
|
} finally {
|
|
errorSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('refuses to widen a pairing offer that arrives after the server has stopped', async () => {
|
|
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
|
const server = new OrcaRuntimeRpcServer({
|
|
runtime: new OrcaRuntimeService(),
|
|
userDataPath,
|
|
enableWebSocket: true,
|
|
wsPort: 0
|
|
})
|
|
|
|
await server.start()
|
|
const widenSpy = vi.spyOn(
|
|
server as unknown as { widenWebSocketBind: () => Promise<void> },
|
|
'widenWebSocketBind'
|
|
)
|
|
await server.stop()
|
|
|
|
// Why: STA-2370 — the `stopping` fence must reject a widen that arrives on its own AFTER shutdown, not
|
|
// only one already in-flight during stop() (covered above via the pending-exposure await). Reverting the
|
|
// `stopping` guard alone still passes the race test, but here ensureNetworkExposure would re-enter
|
|
// widenWebSocketBind and re-open a 0.0.0.0 listener on a server that is supposed to be down.
|
|
await server.ensureNetworkExposure()
|
|
expect(widenSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|