From bc7900a8f10bcd1fa6bc3cc1d4b4923ce1051bcc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Apr 2026 13:42:22 -0700 Subject: [PATCH] feat(cli): validate runtime RPC envelope with Zod at decode boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cli/runtime/envelope-schema.test.ts | 60 +++++++++++++++++++++++++ src/cli/runtime/envelope-schema.ts | 39 ++++++++++++++++ src/cli/runtime/transport.ts | 45 +++++++++++++------ 3 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 src/cli/runtime/envelope-schema.test.ts create mode 100644 src/cli/runtime/envelope-schema.ts diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts new file mode 100644 index 00000000000..57f92ada298 --- /dev/null +++ b/src/cli/runtime/envelope-schema.test.ts @@ -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) + }) +}) diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts new file mode 100644 index 00000000000..eeec3536005 --- /dev/null +++ b/src/cli/runtime/envelope-schema.ts @@ -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]) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 610f8c0d17f..bc39de0d332 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -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( @@ -43,27 +44,25 @@ export async function sendRequest( const message = buffer.slice(0, newlineIndex) socket.end() clearTimeout(timeout) + let response: RuntimeRpcResponse try { - const response = JSON.parse(message) as RuntimeRpcResponse - 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 } catch { reject( new RuntimeClientError( @@ -71,7 +70,27 @@ export async function sendRequest( '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(