feat(cli): validate runtime RPC envelope with Zod at decode boundary

Add RuntimeRpcEnvelopeSchema and apply it inside sendRequest so every
response frame is validated against the id/ok/result/error/_meta shape
before the CLI hands it to the caller. The payload (`result`) is left
as unknown — the TResult generic remains the caller's responsibility —
so only the envelope itself is the contract this schema enforces.

Motivation: the CLI and the Orca main runtime are separate processes
and can drift in version (older CLI vs newer app, or vice versa during
dev HMR). A malformed or partial frame used to risk mis-typed field
access downstream; it now surfaces as a single structured
`invalid_runtime_response` error.

Behavior:
- Well-formed success and failure frames continue to decode unchanged.
- Failure frames without `_meta` are accepted (the runtime may fail
  before resolving its own runtimeId).
- Valid JSON that does not match the envelope shape now rejects with
  `invalid_runtime_response`, matching the existing error code for
  non-JSON frames.

Tests: adds a pure schema test file
(src/cli/runtime/envelope-schema.test.ts) covering accept/reject cases.
The existing integration tests in runtime-client.test.ts continue to
pass unchanged.
This commit is contained in:
Neil
2026-04-25 13:46:29 -07:00
parent 9452a1cbb0
commit bc7900a8f1
3 changed files with 131 additions and 13 deletions
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { RuntimeRpcEnvelopeSchema } from './envelope-schema'
describe('RuntimeRpcEnvelopeSchema', () => {
it('accepts a well-formed success envelope', () => {
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
id: 'req-1',
ok: true,
result: { anything: 1 },
_meta: { runtimeId: 'runtime-1' }
})
expect(parsed.success).toBe(true)
})
it('accepts a well-formed failure envelope', () => {
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
id: 'req-1',
ok: false,
error: { code: 'not_found', message: 'not_found' },
_meta: { runtimeId: 'runtime-1' }
})
expect(parsed.success).toBe(true)
})
it('accepts a failure envelope without _meta', () => {
// Why: the runtime may fail before it has resolved its own runtimeId, in
// which case _meta is omitted. The schema must tolerate that rather than
// rejecting a legitimate failure as an invalid frame.
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
id: 'req-1',
ok: false,
error: { code: 'runtime_unavailable', message: 'runtime_unavailable' }
})
expect(parsed.success).toBe(true)
})
it('rejects a frame missing the ok discriminator', () => {
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
id: 'req-1',
result: {},
_meta: { runtimeId: 'runtime-1' }
})
expect(parsed.success).toBe(false)
})
it('rejects a frame with a non-string id', () => {
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
id: 123,
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
expect(parsed.success).toBe(false)
})
it('rejects a frame with unrelated fields only', () => {
const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' })
expect(parsed.success).toBe(false)
})
})
+39
View File
@@ -0,0 +1,39 @@
// Why: the Orca runtime is a separate process and may drift in version from
// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A
// Zod schema at the decode boundary means a malformed frame surfaces as a
// single legible error instead of a silent mis-typed access downstream.
//
// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left
// unknown here — method-level types are checked by the caller via generics —
// so only the frame is validated, not the payload.
import { z } from 'zod'
const MetaSuccess = z.object({
runtimeId: z.string()
})
const MetaFailure = z
.object({
runtimeId: z.union([z.string(), z.null()])
})
.optional()
const Success = z.object({
id: z.string(),
ok: z.literal(true),
result: z.unknown(),
_meta: MetaSuccess
})
const Failure = z.object({
id: z.string(),
ok: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
data: z.unknown().optional()
}),
_meta: MetaFailure
})
export const RuntimeRpcEnvelopeSchema = z.discriminatedUnion('ok', [Success, Failure])
+32 -13
View File
@@ -1,6 +1,7 @@
import { createConnection } from 'net'
import { randomUUID } from 'crypto'
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
import { RuntimeRpcEnvelopeSchema } from './envelope-schema'
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
export async function sendRequest<TResult>(
@@ -43,27 +44,25 @@ export async function sendRequest<TResult>(
const message = buffer.slice(0, newlineIndex)
socket.end()
clearTimeout(timeout)
let response: RuntimeRpcResponse<TResult>
try {
const response = JSON.parse(message) as RuntimeRpcResponse<TResult>
if (response.id !== requestId) {
const raw: unknown = JSON.parse(message)
// Why: validate the envelope shape (id, ok, result/error, _meta) at
// the decode boundary so version skew between the CLI and the Orca
// main runtime surfaces as a single invalid_runtime_response instead
// of a downstream mis-typed field access. `result` is left as
// unknown — the TResult generic is the caller's responsibility.
const parsed = RuntimeRpcEnvelopeSchema.safeParse(raw)
if (!parsed.success) {
reject(
new RuntimeClientError(
'invalid_runtime_response',
'The Orca runtime returned a mismatched response id.'
'The Orca runtime returned an invalid response frame.'
)
)
return
}
if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) {
reject(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime changed while the request was in flight. Retry the command.'
)
)
return
}
resolve(response)
response = parsed.data as RuntimeRpcResponse<TResult>
} catch {
reject(
new RuntimeClientError(
@@ -71,7 +70,27 @@ export async function sendRequest<TResult>(
'The Orca runtime returned an invalid response frame.'
)
)
return
}
if (response.id !== requestId) {
reject(
new RuntimeClientError(
'invalid_runtime_response',
'The Orca runtime returned a mismatched response id.'
)
)
return
}
if (response._meta?.runtimeId && response._meta.runtimeId !== metadata.runtimeId) {
reject(
new RuntimeClientError(
'runtime_unavailable',
'The Orca runtime changed while the request was in flight. Retry the command.'
)
)
return
}
resolve(response)
})
socket.on('connect', () => {
socket.write(