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(