mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user