mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* fix(native-chat): a host admits structured sessions by client capability, not its own chat setting A host's experimentalStructuredNativeChat decided whether any paired client could reach agentSession.* at all, and whether session.tabs.* showed it structured tabs. That setting is the host user's own launch preference: whether a new agent opens as a chat or a terminal is decided by whoever launches it. Using it as admission control meant a client whose own preference was "structured chat" was refused on a host whose preference was "terminal", and chats opened while the setting was on were withheld from mobile once it was turned off. The gate now asks one thing: did the client advertise agent-session.structured.v1 (in-process callers negotiate nothing and are always admitted). Tab projection and restore follow the same rule. With the setting no longer gating anything, the separate cleanup gate (close, cancel, unsubscribe, release), which existed only so those kept working after the setting was switched off, is identical to the main gate and is folded into it. The settings listener that republished tabs when the setting changed is removed, since projection no longer depends on it. The host setting still picks the default for launches that start on the host itself (agent.launch from mobile, orchestration worker-start). * fix(native-chat): negotiate client-chosen launch mode so released phones and old servers keep terminals Hosts advertise agent-session.structured.client-launch-mode.v1: they admit structured sessions by client capability alone. A remote client that does not advertise it (phones released before agent.launch) asks createSupport to pick the launch mode, so the host keeps answering that with its own setting, exactly as before. Cleanup methods keep their own named gate so a future admission condition cannot make close or cancel refusable. * chore(native-chat): justify the two type assertions this change's lines touch * fix(native-chat): chats that already exist keep showing whatever the chat setting says The structured chat setting decides only what new agents open as. With it off, this machine's structured chats used to be hidden while the host, which no longer reads the setting, still reported them to the workspace activation gate, so a workspace holding only a chat opened empty. The local chat mirror and its startup restore now run whatever the setting says, the continue-after-restart offer follows the chats that exist, and the setting's copy says it applies to new agents. * test(native-chat): pin that a host advertises the client-chosen launch mode * fix(native-chat): mirror this machine's chats only where it holds them Round 1 ran the local chat mirror for everyone so existing chats show whatever the setting says. That gave every desktop a permanent session-tabs listener, which turns on the runtime's phone replication paths, plus two full session-tab censuses at startup, and made the browser client mirror its remote host a second time. The runtime now says whether it holds structured chats: its structured host is built only when saved chats were restored at startup or a client created one here, and it announces the moment one is built. The mirror, the startup restore and the continue-after-restart offer run only when the setting launches chats or the host holds some, and never in the browser client. A chat a paired client creates here with the setting off still appears at once. The chat behaviour settings show wherever chats exist, and the setting's copy says it picks what new agents open as. The toggle-off teardown this made dead is removed. * test(native-chat): record install listeners without a cast * fix(native-chat): mirror this machine's chats only once it holds one, not once its host is built Session history, resume preparation, terminal resume commands and replay-safe phone launches all build the structured host for users who never had a chat, which turned on the chat mirror and the structured-only settings rows until the next restart. The signal is now derived from the host's records (or a records file still owed its import) and pushed when the first chat is restored or created. A throwing listener no longer fails the install that fired it. * feat(native-chat): createSupport reports the saved selection a new chat on this host starts with A chat on a paired server starts with the server's saved model and options, which the desktop could not read, so its picker showed a guess. createSupport's answer, which the desktop already waits for before a paired launch, now also carries that seed as a new optional field (older clients ignore it). Create and createSupport read it through one resolver so they cannot drift. * refactor(protocol): move the Electron remote client capability list into its own module Merging main left protocol-version.ts one line over the max-lines limit on this branch. The list of capabilities the desktop advertises to a paired host moves, unchanged, into electron-remote-runtime-client-capabilities.ts, the module the next PR in the stack already uses for it; importers point there. * test(cross-version): stub the launch seed resolver createSupport now reads * fix(native-chat): the desktop tells its own host it picks each launch mode, so retrying an existing chat works with the setting off * docs(native-chat): name the real exit for the released-phone createSupport rule * test(cross-version): a released client still gets the host-setting createSupport answer; a launch-mode client gets supported plus the seed
763 lines
24 KiB
TypeScript
763 lines
24 KiB
TypeScript
import type { AddressInfo } from 'node:net'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import WebSocketClient, { WebSocketServer, type WebSocket } from 'ws'
|
|
import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing'
|
|
import {
|
|
decrypt,
|
|
decryptBytes,
|
|
deriveSharedKey,
|
|
encrypt,
|
|
generateKeyPair,
|
|
publicKeyFromBase64,
|
|
publicKeyToBase64
|
|
} from './e2ee-crypto'
|
|
import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client'
|
|
import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities'
|
|
import { MAX_TIMER_DELAY_MS } from './timer-delay'
|
|
import {
|
|
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
|
|
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY,
|
|
BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY
|
|
} from './protocol-version'
|
|
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities'
|
|
|
|
const servers: WebSocketServer[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
servers.splice(0).map(
|
|
(server) =>
|
|
new Promise<void>((resolve) => {
|
|
for (const client of server.clients) {
|
|
client.close()
|
|
}
|
|
server.close(() => resolve())
|
|
})
|
|
)
|
|
)
|
|
})
|
|
|
|
describe('subscribeRemoteRuntimeRequest', () => {
|
|
it('includes WebSocket close details when subscription admission is rejected', async () => {
|
|
const server = await createClosingServer(1013, 'Maximum connections reached')
|
|
|
|
await expect(
|
|
subscribeRemoteRuntimeRequest(server.pairing, 'terminal.subscribe', {}, 1000, {
|
|
onResponse: vi.fn(),
|
|
onError: vi.fn()
|
|
})
|
|
).rejects.toThrow(
|
|
'Remote Orca runtime closed the connection (1013: Maximum connections reached).'
|
|
)
|
|
})
|
|
|
|
it('sends encrypted binary frames on an established subscription socket', async () => {
|
|
const server = await createSubscriptionServer()
|
|
const onResponse = vi.fn()
|
|
const onError = vi.fn()
|
|
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'terminal.subscribe',
|
|
{ terminal: 't1' },
|
|
1000,
|
|
{
|
|
onResponse,
|
|
onError
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(onResponse).toHaveBeenCalledWith(
|
|
expect.objectContaining({ ok: true, result: { type: 'subscribed' } })
|
|
)
|
|
)
|
|
await expect(server.nextAuth).resolves.toEqual({
|
|
type: 'e2ee_auth',
|
|
deviceToken: 'device-token',
|
|
clientCapabilities: remoteRuntimeClientCapabilities()
|
|
})
|
|
const bytes = new Uint8Array([1, 2, 3])
|
|
expect(subscription.sendBinary(bytes)).toBe(true)
|
|
await expect(server.nextBinary).resolves.toEqual(bytes)
|
|
expect(onError).not.toHaveBeenCalled()
|
|
subscription.close()
|
|
})
|
|
|
|
it('binds optional capabilities and rejects a hard outbound queue overflow', async () => {
|
|
const server = await createSubscriptionServer()
|
|
const onResponse = vi.fn()
|
|
const onError = vi.fn()
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'network.browserTunnel',
|
|
{},
|
|
1000,
|
|
{ onResponse, onError },
|
|
{
|
|
clientCapabilities: [BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY],
|
|
outboundQueue: { softCapBytes: 0, maxQueuedBytes: 1, maxQueuedFrames: 1 }
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() => expect(onResponse).toHaveBeenCalled())
|
|
await expect(server.nextAuth).resolves.toEqual({
|
|
type: 'e2ee_auth',
|
|
deviceToken: 'device-token',
|
|
clientCapabilities: remoteRuntimeClientCapabilities([
|
|
BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY
|
|
])
|
|
})
|
|
expect(subscription.sendBinary(new Uint8Array([9]))).toBe(false)
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'remote_runtime_unavailable' })
|
|
)
|
|
})
|
|
|
|
it('accounts encrypted queues and native socket buffers in an injected aggregate budget', async () => {
|
|
const server = await createSubscriptionServer()
|
|
const releaseQueued = vi.fn()
|
|
const releaseSocket = vi.fn()
|
|
const canSend = vi.fn(() => false)
|
|
let readBufferedAmount: (() => number) | undefined
|
|
const outboundMemoryBudget = {
|
|
claimQueuedBytes: vi.fn(() => releaseQueued),
|
|
registerBufferedAmount: vi.fn((read: () => number) => {
|
|
readBufferedAmount = read
|
|
return { canSend, release: releaseSocket }
|
|
})
|
|
}
|
|
const onResponse = vi.fn()
|
|
const onClose = vi.fn()
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'network.browserTunnel',
|
|
{},
|
|
1000,
|
|
{ onResponse, onError: vi.fn(), onClose },
|
|
{
|
|
outboundMemoryBudget,
|
|
outboundQueue: { softCapBytes: 1, maxQueuedBytes: 1024, maxQueuedFrames: 8 }
|
|
}
|
|
)
|
|
await vi.waitFor(() => expect(onResponse).toHaveBeenCalled())
|
|
|
|
expect(subscription.sendBinary(new Uint8Array([9]))).toBe(true)
|
|
expect(outboundMemoryBudget.registerBufferedAmount).toHaveBeenCalledOnce()
|
|
expect(readBufferedAmount?.()).toBeGreaterThanOrEqual(0)
|
|
expect(canSend).toHaveBeenCalledWith(expect.any(Number), false)
|
|
expect(outboundMemoryBudget.claimQueuedBytes).toHaveBeenCalledWith(expect.any(Number))
|
|
|
|
subscription.close()
|
|
subscription.close()
|
|
expect(releaseQueued).toHaveBeenCalledOnce()
|
|
expect(releaseSocket).not.toHaveBeenCalled()
|
|
await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
|
expect(releaseSocket).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps native socket bytes charged through failure cleanup and a later close', async () => {
|
|
const server = await createSubscriptionServer()
|
|
const releaseSocket = vi.fn()
|
|
const onError = vi.fn()
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'network.browserTunnel',
|
|
{},
|
|
1000,
|
|
{ onResponse: vi.fn(), onError },
|
|
{
|
|
outboundMemoryBudget: {
|
|
claimQueuedBytes: vi.fn(() => vi.fn()),
|
|
registerBufferedAmount: vi.fn(() => ({ canSend: () => false, release: releaseSocket }))
|
|
},
|
|
outboundQueue: { softCapBytes: 1, maxQueuedBytes: 1, maxQueuedFrames: 1 }
|
|
}
|
|
)
|
|
|
|
expect(subscription.sendBinary(new Uint8Array([9]))).toBe(false)
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'remote_runtime_unavailable' })
|
|
)
|
|
subscription.close()
|
|
expect(releaseSocket).not.toHaveBeenCalled()
|
|
|
|
await vi.waitFor(() => expect(releaseSocket).toHaveBeenCalledOnce())
|
|
})
|
|
|
|
it('detaches subscription socket listeners after close', async () => {
|
|
const offSpy = vi.spyOn(WebSocketClient.prototype, 'off')
|
|
try {
|
|
const server = await createSubscriptionServer()
|
|
const onResponse = vi.fn()
|
|
const onError = vi.fn()
|
|
const onClose = vi.fn()
|
|
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'terminal.subscribe',
|
|
{ terminal: 't1' },
|
|
1000,
|
|
{
|
|
onResponse,
|
|
onError,
|
|
onClose
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() => expect(onResponse).toHaveBeenCalled())
|
|
subscription.close()
|
|
await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
|
|
|
const removedEvents = offSpy.mock.calls.map(([event]) => event)
|
|
expect(removedEvents).toEqual(expect.arrayContaining(['open', 'error', 'close', 'message']))
|
|
expect(subscription.sendBinary(new Uint8Array([9]))).toBe(false)
|
|
expect(onError).not.toHaveBeenCalled()
|
|
} finally {
|
|
offSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('closes a half-open subscription socket via client liveness so callers can resubscribe', async () => {
|
|
// Why: dedicated stream sockets (terminal.multiplex, browser.screencast)
|
|
// must not hang forever when a tunnel goes half-open — no close frame, no
|
|
// pongs, no data (#7718). Client liveness surfaces onError/onClose so the
|
|
// renderer's onTransportClose resubscribe path can run.
|
|
const server = await createSubscriptionServer({ disableAutoPong: true })
|
|
const onResponse = vi.fn()
|
|
const onError = vi.fn()
|
|
const onClose = vi.fn()
|
|
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'terminal.multiplex',
|
|
{},
|
|
1000,
|
|
{ onResponse, onError, onClose },
|
|
{ pingIntervalMs: 50, livenessTimeoutMs: 200 }
|
|
)
|
|
|
|
await vi.waitFor(() => expect(onResponse).toHaveBeenCalled())
|
|
await vi.waitFor(
|
|
() =>
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'remote_runtime_unavailable' })
|
|
),
|
|
{ timeout: 5000 }
|
|
)
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
subscription.close()
|
|
})
|
|
|
|
it('closes established subscription sockets after terminal protocol errors', async () => {
|
|
const offSpy = vi.spyOn(WebSocketClient.prototype, 'off')
|
|
try {
|
|
const server = await createSubscriptionServer({ sendMismatchedResponseAfterSubscribe: true })
|
|
const onResponse = vi.fn()
|
|
const onError = vi.fn()
|
|
const onClose = vi.fn()
|
|
|
|
const subscription = await subscribeRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'terminal.subscribe',
|
|
{ terminal: 't1' },
|
|
1000,
|
|
{
|
|
onResponse,
|
|
onError,
|
|
onClose
|
|
}
|
|
)
|
|
|
|
await vi.waitFor(() => expect(onResponse).toHaveBeenCalled())
|
|
await vi.waitFor(() =>
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'invalid_runtime_response' })
|
|
)
|
|
)
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
|
|
const removedEvents = offSpy.mock.calls.map(([event]) => event)
|
|
expect(removedEvents).toEqual(expect.arrayContaining(['open', 'error', 'close', 'message']))
|
|
expect(subscription.sendBinary(new Uint8Array([9]))).toBe(false)
|
|
} finally {
|
|
offSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('sendRemoteRuntimeRequest', () => {
|
|
it('keeps generic native authentication free of Electron placement support', async () => {
|
|
let receivedAuth: Record<string, unknown> | null = null
|
|
const server = await createOneShotServer({
|
|
onAuth: (auth) => {
|
|
receivedAuth = auth
|
|
}
|
|
})
|
|
|
|
await sendRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'session.tabs.list',
|
|
{ worktree: 'id:worktree-a' },
|
|
1000
|
|
)
|
|
|
|
expect(receivedAuth).toMatchObject({
|
|
clientCapabilities: remoteRuntimeClientCapabilities()
|
|
})
|
|
})
|
|
|
|
it('advertises browser placement support only when the Electron caller opts in', async () => {
|
|
let receivedAuth: Record<string, unknown> | null = null
|
|
const server = await createOneShotServer({
|
|
onAuth: (auth) => {
|
|
receivedAuth = auth
|
|
}
|
|
})
|
|
|
|
await sendRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'session.tabs.list',
|
|
{ worktree: 'id:worktree-a' },
|
|
1000,
|
|
undefined,
|
|
undefined,
|
|
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
|
)
|
|
|
|
expect(receivedAuth).toMatchObject({
|
|
clientCapabilities: expect.arrayContaining([
|
|
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
|
|
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY
|
|
])
|
|
})
|
|
})
|
|
|
|
it.each([-1, 1.5, MAX_TIMER_DELAY_MS + 1, Number.MAX_SAFE_INTEGER + 1])(
|
|
'rejects invalid timer delay %s before reading pairing data',
|
|
async (timeoutMs) => {
|
|
await expect(
|
|
sendRemoteRuntimeRequest({} as PairingOffer, 'status.get', {}, timeoutMs)
|
|
).rejects.toMatchObject({ code: 'invalid_argument' })
|
|
}
|
|
)
|
|
|
|
it('includes WebSocket close details when one-shot admission is rejected', async () => {
|
|
const server = await createClosingServer(1013, 'Maximum connections reached')
|
|
|
|
await expect(sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)).rejects.toThrow(
|
|
'Remote Orca runtime closed the connection (1013: Maximum connections reached).'
|
|
)
|
|
})
|
|
|
|
it('classifies a non-Orca handshake as a host identity mismatch', async () => {
|
|
const server = await createInvalidHandshakeServer()
|
|
|
|
await expect(
|
|
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_runtime_response',
|
|
pairingStage: 'host-identity'
|
|
})
|
|
})
|
|
|
|
it('classifies an undecryptable post-auth frame as a runtime failure', async () => {
|
|
const server = await createOneShotServer({ sendUndecryptableResponse: true })
|
|
|
|
await expect(
|
|
sendRemoteRuntimeRequest(server.pairing, 'status.get', {}, 1000)
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_runtime_response',
|
|
pairingStage: 'runtime'
|
|
})
|
|
})
|
|
|
|
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
|
|
const server = await createOneShotServer()
|
|
|
|
const response = await sendRemoteRuntimeRequest<{ satisfied: boolean }>(
|
|
server.pairing,
|
|
'terminal.wait',
|
|
{ terminal: 't1', for: 'tui-idle', timeoutMs: 550 },
|
|
300
|
|
)
|
|
|
|
expect(response).toMatchObject({
|
|
ok: true,
|
|
result: { satisfied: true }
|
|
})
|
|
})
|
|
|
|
it('aborts and closes an in-flight one-shot socket', async () => {
|
|
let requestObserved: () => void = () => {}
|
|
const observed = new Promise<void>((resolve) => {
|
|
requestObserved = resolve
|
|
})
|
|
const server = await createOneShotServer({ onRequest: requestObserved })
|
|
const closeSpy = vi.spyOn(WebSocketClient.prototype, 'close')
|
|
const controller = new AbortController()
|
|
try {
|
|
const request = sendRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'skills.install',
|
|
{},
|
|
60_000,
|
|
undefined,
|
|
controller.signal
|
|
)
|
|
await observed
|
|
|
|
controller.abort()
|
|
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
|
expect(closeSpy).toHaveBeenCalled()
|
|
} finally {
|
|
closeSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('preserves structured failure data for remote computer-use recovery hints', async () => {
|
|
const server = await createOneShotServer({
|
|
response: (requestId) => ({
|
|
id: requestId,
|
|
ok: false,
|
|
error: {
|
|
code: 'app_not_found',
|
|
message: 'app not found: Gmail',
|
|
data: {
|
|
nextSteps: ['Target the desktop browser app/window that contains Gmail.']
|
|
}
|
|
},
|
|
_meta: { runtimeId: 'runtime-test' }
|
|
})
|
|
})
|
|
|
|
const response = await sendRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'computer.getAppState',
|
|
{ app: 'Gmail' },
|
|
1000
|
|
)
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'app_not_found',
|
|
data: {
|
|
nextSteps: [expect.stringContaining('desktop browser app/window')]
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
it('sends orchestration authentication fields in the admitted encrypted request', async () => {
|
|
let receivedRequest: Record<string, unknown> | null = null
|
|
const server = await createOneShotServer({
|
|
onRequest: (request) => {
|
|
receivedRequest = request
|
|
}
|
|
})
|
|
|
|
await sendRemoteRuntimeRequest(
|
|
server.pairing,
|
|
'orchestration.federationControl',
|
|
{ dispatch: 'ctx_1' },
|
|
1000,
|
|
{
|
|
orchestrationCapability: 'capability',
|
|
orchestrationContractVersion: 1,
|
|
orchestrationRequestId: 'mutation_1'
|
|
}
|
|
)
|
|
|
|
expect(receivedRequest).toMatchObject({
|
|
method: 'orchestration.federationControl',
|
|
params: { dispatch: 'ctx_1' },
|
|
orchestrationCapability: 'capability',
|
|
orchestrationContractVersion: 1,
|
|
orchestrationRequestId: 'mutation_1'
|
|
})
|
|
})
|
|
|
|
it('omits optional request fields for hosts that predate them', async () => {
|
|
let receivedRequest: Record<string, unknown> | null = null
|
|
const server = await createOneShotServer({
|
|
onRequest: (request) => {
|
|
receivedRequest = request
|
|
}
|
|
})
|
|
|
|
await sendRemoteRuntimeRequest(server.pairing, 'status.get', undefined, 1000)
|
|
|
|
expect(receivedRequest).toMatchObject({
|
|
deviceToken: 'device-token',
|
|
method: 'status.get'
|
|
})
|
|
expect(receivedRequest).not.toHaveProperty('params')
|
|
expect(receivedRequest).not.toHaveProperty('orchestrationCapability')
|
|
expect(receivedRequest).not.toHaveProperty('orchestrationContractVersion')
|
|
expect(receivedRequest).not.toHaveProperty('orchestrationRequestId')
|
|
})
|
|
|
|
it('detaches one-shot socket listeners after a successful response', async () => {
|
|
const offSpy = vi.spyOn(WebSocketClient.prototype, 'off')
|
|
try {
|
|
const server = await createOneShotServer()
|
|
|
|
await sendRemoteRuntimeRequest<{ satisfied: boolean }>(
|
|
server.pairing,
|
|
'terminal.wait',
|
|
{ terminal: 't1', for: 'tui-idle', timeoutMs: 550 },
|
|
300
|
|
)
|
|
|
|
const removedEvents = offSpy.mock.calls.map(([event]) => event)
|
|
expect(removedEvents).toEqual(expect.arrayContaining(['open', 'error', 'close', 'message']))
|
|
} finally {
|
|
offSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
async function createSubscriptionServer(
|
|
options: {
|
|
sendMismatchedResponseAfterSubscribe?: boolean
|
|
// Why: half-open simulation — the socket stays open but never answers
|
|
// protocol pings, like a wedged tunnel that swallows frames silently.
|
|
disableAutoPong?: boolean
|
|
} = {}
|
|
): Promise<{
|
|
pairing: PairingOffer
|
|
nextBinary: Promise<Uint8Array>
|
|
nextAuth: Promise<unknown>
|
|
}> {
|
|
const serverKeyPair = generateKeyPair()
|
|
let resolveBinary: (bytes: Uint8Array) => void = () => {}
|
|
const nextBinary = new Promise<Uint8Array>((resolve) => {
|
|
resolveBinary = resolve
|
|
})
|
|
let resolveAuth: (auth: unknown) => void = () => {}
|
|
const nextAuth = new Promise<unknown>((resolve) => {
|
|
resolveAuth = resolve
|
|
})
|
|
// host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here.
|
|
const wss = new WebSocketServer({
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
autoPong: options.disableAutoPong !== true
|
|
})
|
|
servers.push(wss)
|
|
|
|
wss.on('connection', (ws) => {
|
|
let sharedKey: Uint8Array | null = null
|
|
let authenticated = false
|
|
|
|
ws.on('message', (data, isBinary) => {
|
|
if (isBinary) {
|
|
if (!sharedKey) {
|
|
return
|
|
}
|
|
const plaintext = decryptBytes(new Uint8Array(data as Buffer), sharedKey)
|
|
if (plaintext) {
|
|
resolveBinary(plaintext)
|
|
}
|
|
return
|
|
}
|
|
|
|
const frame = data.toString()
|
|
if (!sharedKey) {
|
|
const hello = JSON.parse(frame) as { publicKeyB64: string }
|
|
sharedKey = deriveSharedKey(
|
|
serverKeyPair.secretKey,
|
|
publicKeyFromBase64(hello.publicKeyB64)
|
|
)
|
|
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
|
return
|
|
}
|
|
|
|
const plaintext = decrypt(frame, sharedKey)
|
|
if (!plaintext) {
|
|
return
|
|
}
|
|
if (!authenticated) {
|
|
resolveAuth(JSON.parse(plaintext))
|
|
authenticated = true
|
|
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' })
|
|
return
|
|
}
|
|
|
|
const request = JSON.parse(plaintext) as { id: string }
|
|
sendEncrypted(ws, sharedKey, {
|
|
id: request.id,
|
|
ok: true,
|
|
streaming: true,
|
|
result: { type: 'subscribed' },
|
|
_meta: { runtimeId: 'runtime-test' }
|
|
})
|
|
if (options.sendMismatchedResponseAfterSubscribe) {
|
|
sendEncrypted(ws, sharedKey, {
|
|
id: `${request.id}-mismatch`,
|
|
ok: true,
|
|
streaming: true,
|
|
result: { type: 'subscribed' },
|
|
_meta: { runtimeId: 'runtime-test' }
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
|
const address = wss.address() as AddressInfo
|
|
const pairing = parsePairingCode(
|
|
encodePairingOffer({
|
|
v: 2,
|
|
endpoint: `ws://127.0.0.1:${address.port}`,
|
|
deviceToken: 'device-token',
|
|
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
|
|
})
|
|
)
|
|
if (!pairing) {
|
|
throw new Error('Failed to create test pairing')
|
|
}
|
|
return { pairing, nextBinary, nextAuth }
|
|
}
|
|
|
|
function sendEncrypted(ws: WebSocket, sharedKey: Uint8Array, message: unknown): void {
|
|
ws.send(encrypt(JSON.stringify(message), sharedKey))
|
|
}
|
|
|
|
async function createClosingServer(
|
|
code: number,
|
|
reason: string
|
|
): Promise<{ pairing: PairingOffer }> {
|
|
const serverKeyPair = generateKeyPair()
|
|
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
|
servers.push(wss)
|
|
wss.on('connection', (ws) => {
|
|
ws.close(code, reason)
|
|
})
|
|
|
|
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
|
const address = wss.address() as AddressInfo
|
|
const pairing = parsePairingCode(
|
|
encodePairingOffer({
|
|
v: 2,
|
|
endpoint: `ws://127.0.0.1:${address.port}`,
|
|
deviceToken: 'device-token',
|
|
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
|
|
})
|
|
)
|
|
if (!pairing) {
|
|
throw new Error('Failed to create test pairing')
|
|
}
|
|
return { pairing }
|
|
}
|
|
|
|
async function createInvalidHandshakeServer(): Promise<{ pairing: PairingOffer }> {
|
|
const serverKeyPair = generateKeyPair()
|
|
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
|
servers.push(wss)
|
|
wss.on('connection', (ws) => {
|
|
ws.once('message', () => ws.send(JSON.stringify({ type: 'not_orca' })))
|
|
})
|
|
|
|
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
|
const address = wss.address() as AddressInfo
|
|
const pairing = parsePairingCode(
|
|
encodePairingOffer({
|
|
v: 2,
|
|
endpoint: `ws://127.0.0.1:${address.port}`,
|
|
deviceToken: 'device-token',
|
|
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
|
|
})
|
|
)
|
|
if (!pairing) {
|
|
throw new Error('Failed to create test pairing')
|
|
}
|
|
return { pairing }
|
|
}
|
|
|
|
async function createOneShotServer(
|
|
options: {
|
|
response?: (requestId: string) => unknown
|
|
onAuth?: (auth: Record<string, unknown>) => void
|
|
onRequest?: (request: Record<string, unknown>) => void
|
|
sendUndecryptableResponse?: boolean
|
|
} = {}
|
|
): Promise<{ pairing: PairingOffer }> {
|
|
const serverKeyPair = generateKeyPair()
|
|
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
|
servers.push(wss)
|
|
|
|
wss.on('connection', (ws) => {
|
|
let sharedKey: Uint8Array | null = null
|
|
let authenticated = false
|
|
|
|
ws.on('message', (data, isBinary) => {
|
|
if (isBinary) {
|
|
return
|
|
}
|
|
const frame = data.toString()
|
|
if (!sharedKey) {
|
|
const hello = JSON.parse(frame) as { publicKeyB64: string }
|
|
sharedKey = deriveSharedKey(
|
|
serverKeyPair.secretKey,
|
|
publicKeyFromBase64(hello.publicKeyB64)
|
|
)
|
|
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
|
|
return
|
|
}
|
|
|
|
const plaintext = decrypt(frame, sharedKey)
|
|
if (!plaintext) {
|
|
return
|
|
}
|
|
if (!authenticated) {
|
|
options.onAuth?.(JSON.parse(plaintext) as Record<string, unknown>)
|
|
authenticated = true
|
|
sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' })
|
|
return
|
|
}
|
|
|
|
const request = JSON.parse(plaintext) as { id: string } & Record<string, unknown>
|
|
options.onRequest?.(request)
|
|
if (options.sendUndecryptableResponse) {
|
|
ws.send('not-an-encrypted-frame')
|
|
return
|
|
}
|
|
const key = sharedKey
|
|
const keepalive = setInterval(() => {
|
|
sendEncrypted(ws, key, { _keepalive: true })
|
|
}, 100)
|
|
ws.once('close', () => clearInterval(keepalive))
|
|
setTimeout(() => {
|
|
clearInterval(keepalive)
|
|
sendEncrypted(
|
|
ws,
|
|
key,
|
|
options.response?.(request.id) ?? {
|
|
id: request.id,
|
|
ok: true,
|
|
result: { satisfied: true },
|
|
_meta: { runtimeId: 'runtime-test' }
|
|
}
|
|
)
|
|
}, 550)
|
|
})
|
|
})
|
|
|
|
await new Promise<void>((resolve) => wss.once('listening', resolve))
|
|
const address = wss.address() as AddressInfo
|
|
const pairing = parsePairingCode(
|
|
encodePairingOffer({
|
|
v: 2,
|
|
endpoint: `ws://127.0.0.1:${address.port}`,
|
|
deviceToken: 'device-token',
|
|
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey)
|
|
})
|
|
)
|
|
if (!pairing) {
|
|
throw new Error('Failed to create test pairing')
|
|
}
|
|
return { pairing }
|
|
}
|