Files
orca/src/shared/remote-runtime-subscription-frame-router.ts
T
841503152c fix(runtime-environments): don't crash when a server removed via the CLI still responds (#22517)
* 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>
2026-09-25 14:48:54 -07:00

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)
)
)
}
}
}