mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
* fix(runtime-environments): don't crash when a server removed via the CLI still responds orca environment rm edits the environment store behind the running app, so the next ok response on a live socket called markEnvironmentUsed, which threw 'Unknown environment' out of an unguarded socket callback. Main-process callers now use markEnvironmentUsedIfPresent, which skips a missing environment and keeps every other store error; the status owner pauses shared control instead of re-establishing it for a removed server. * fix(runtime-environments): guard usage bookkeeping at the main-process boundary Keep one strict store contract and move the leniency to the caller that cannot report a failure to anyone. - Revert markEnvironmentUsedIfPresent: drawing the line around one error string left corrupt, unreadable and oversized store files still fatal on the same unguarded socket callback. - Add recordRuntimeEnvironmentUsage, a named main-process boundary that says lastUsedAt is advisory and swallows every store failure. Route only the three sites with no observer through it (subscription onResponse in transport- and support-routing, and the status owner's verified hook, where a throw skips settleWaiters and hangs refresh callers). Awaited request paths stay strict. - Guard onResponse/onBinary in the subscription frame router the way the sibling request router already guards validateStatus, so no consumer throw can reach the ws 'message' emitter and become main_uncaught_exception. - Drop the status-owner `capable && present` gate: pauseStandingRetry no-ops while subscriptions exist, and removal teardown belongs to #21048's watcher. Co-authored-by: mmarabel <mmarabel@users.noreply.github.com> * test(runtime-environments): cover the real socket path a consumer throw escapes The existing tests invoke the captured onResponse directly, which never touches the surface that actually kills the app. Drive a real WebSocket server through subscribeRemoteRuntimeRequest so the throw travels ws 'message' -> handleFrame -> consumer; without the frame-router guard vitest reports it as an unhandled error, which is main_uncaught_exception in production. --------- Co-authored-by: Neil <neil@stably.ai> Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
174 lines
5.6 KiB
TypeScript
174 lines
5.6 KiB
TypeScript
import type WebSocket from 'ws'
|
|
import { decrypt, decryptBytes, encrypt } from './e2ee-crypto'
|
|
import {
|
|
classifyRemoteRuntimeReadyFrame,
|
|
parseRemoteRuntimeAuthenticatedFrame,
|
|
type RemoteRuntimeHandshakeState
|
|
} from './remote-runtime-client-handshake'
|
|
import { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
|
import { RuntimeRpcEnvelopeSchema, type RuntimeRpcResponse } from './runtime-rpc-envelope'
|
|
import { parseRemoteRuntimeJsonText } from './remote-runtime-request-frames'
|
|
|
|
type SubscriptionFrameRouterOptions<TResult> = {
|
|
sharedKey: Uint8Array
|
|
serializedAuth: string
|
|
serializedRequest: string
|
|
requestId: string
|
|
send: (frame: string) => void
|
|
fail: (error: RemoteRuntimeClientError) => void
|
|
onAuthenticated: () => void
|
|
// Responses to requests the caller sent over this same socket; unmatched ids stay a hard failure.
|
|
resolvePendingRequest?: (response: RuntimeRpcResponse<unknown>) => boolean
|
|
callbacks: {
|
|
onResponse: (response: RuntimeRpcResponse<TResult>) => void
|
|
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
|
|
}
|
|
}
|
|
|
|
export class RemoteRuntimeSubscriptionFrameRouter<TResult> {
|
|
state: RemoteRuntimeHandshakeState = 'awaiting_ready'
|
|
|
|
constructor(private readonly options: SubscriptionFrameRouterOptions<TResult>) {}
|
|
|
|
handleFrame(data: WebSocket.RawData, isBinary: boolean): void {
|
|
if (isBinary) {
|
|
this.handleBinaryFrame(new Uint8Array(data as Buffer))
|
|
return
|
|
}
|
|
const frame = data.toString()
|
|
if (this.state === 'awaiting_ready') {
|
|
this.handleReadyFrame(frame)
|
|
return
|
|
}
|
|
const plaintext = decrypt(frame, this.options.sharedKey)
|
|
if (plaintext === null) {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned an undecryptable frame.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
if (this.state === 'awaiting_authenticated') {
|
|
this.handleAuthenticatedFrame(plaintext)
|
|
return
|
|
}
|
|
this.handleRpcFrame(plaintext)
|
|
}
|
|
|
|
private handleReadyFrame(frame: string): void {
|
|
const readyFrame = classifyRemoteRuntimeReadyFrame(frame)
|
|
if (readyFrame !== 'ready') {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
readyFrame === 'invalid'
|
|
? 'Remote Orca runtime returned an invalid E2EE handshake frame.'
|
|
: 'Remote Orca runtime returned an unexpected E2EE handshake frame.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
this.state = 'awaiting_authenticated'
|
|
this.options.send(encrypt(this.options.serializedAuth, this.options.sharedKey))
|
|
}
|
|
|
|
private handleAuthenticatedFrame(plaintext: string): void {
|
|
const authenticated = parseRemoteRuntimeAuthenticatedFrame(plaintext)
|
|
if (authenticated.kind === 'invalid') {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned an invalid E2EE auth frame.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
if (authenticated.kind !== 'authenticated') {
|
|
const code = authenticated.unauthorized ? 'unauthorized' : 'invalid_runtime_response'
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(code, 'Remote Orca runtime rejected the pairing token.')
|
|
)
|
|
return
|
|
}
|
|
this.state = 'ready'
|
|
this.options.send(encrypt(this.options.serializedRequest, this.options.sharedKey))
|
|
this.options.onAuthenticated()
|
|
}
|
|
|
|
private handleRpcFrame(plaintext: string): void {
|
|
let raw: unknown
|
|
try {
|
|
raw = parseRemoteRuntimeJsonText(plaintext)
|
|
} catch {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned an invalid response frame.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw)
|
|
if (!parsed.success || '_keepalive' in parsed.data) {
|
|
return
|
|
}
|
|
const response = parsed.data as RuntimeRpcResponse<TResult>
|
|
if (response.id === this.options.requestId) {
|
|
this.deliver(() => this.options.callbacks.onResponse(response))
|
|
return
|
|
}
|
|
if (this.options.resolvePendingRequest?.(response as RuntimeRpcResponse<unknown>)) {
|
|
return
|
|
}
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned a mismatched response id.'
|
|
)
|
|
)
|
|
}
|
|
|
|
private handleBinaryFrame(frame: Uint8Array<ArrayBufferLike>): void {
|
|
if (this.state !== 'ready') {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned binary data before authentication.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
const plaintext = decryptBytes(frame, this.options.sharedKey)
|
|
if (plaintext === null) {
|
|
this.options.fail(
|
|
new RemoteRuntimeClientError(
|
|
'invalid_runtime_response',
|
|
'Remote Orca runtime returned an undecryptable binary frame.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
this.deliver(() => this.options.callbacks.onBinary?.(plaintext))
|
|
}
|
|
|
|
// Why: `handleFrame` runs straight off the ws 'message' emitter, so a consumer throw becomes an
|
|
// uncaught exception that kills the process. The request router already routes one to
|
|
// `finishError`; mirror that here rather than leaving the subscription path unguarded.
|
|
private deliver(emit: () => void): void {
|
|
try {
|
|
emit()
|
|
} catch (error) {
|
|
this.options.fail(
|
|
error instanceof RemoteRuntimeClientError
|
|
? error
|
|
: new RemoteRuntimeClientError(
|
|
'runtime_error',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|