diff --git a/README.md b/README.md index 9139221..932d34d 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ AI Agent CodeMode MCP Server │ ├─ search(code) → runs JS with preprocessed OpenAPI spec - │ → all $refs resolved inline, only essential fields kept + │ → ref-resolved paths view with only essential fields kept │ → agent discovers endpoints, schemas, parameters │ └─ execute(code) → runs JS with injected request client @@ -136,6 +136,16 @@ Each tool call gets a fresh sandbox with no state carried over between calls. | `allowedHeaders` | `string[]` | `undefined` | Header whitelist. When unset, a blocklist strips `Authorization`, `Cookie`, `Host`, `X-Forwarded-*`, `Proxy-*`. | | `maxRefDepth` | `number` | `50` | Max `$ref` resolution depth | +Successful spec preprocessing is cached. Concurrent searches share an in-flight async spec provider, while a failed preparation is retried by a later search. + +Data-only input first has a 100,000-node structural inspection limit. Built-in +executors additionally reject cycles, `bigint`, and custom `toJSON` hooks, then +preflight the fully expanded transport shape before marshalling it into a +sandbox. Shared aliases count once per transport occurrence, with limits of +500,000 expanded occurrences and 10 MiB of encoded input. Custom executors still +receive the alias-preserving structured clone and remain responsible for their +own transport limits. + #### `SandboxOptions` | Option | Type | Default | Description | @@ -174,7 +184,7 @@ Clean up sandbox resources. ### Inside `search` -The `spec` global is the preprocessed OpenAPI spec with all `$ref` pointers resolved inline: +The `spec` global is the preprocessed OpenAPI paths view; resolvable `$ref` pointers are expanded inline: ```javascript // Find endpoints by tag @@ -190,16 +200,14 @@ async () => { return results; } -// Get endpoint with requestBody schema (refs are already resolved) +// Get endpoint with requestBody schema (resolvable refs are expanded) async () => { const op = spec.paths['/v1/products']?.post; return { summary: op?.summary, requestBody: op?.requestBody }; } -// Spec metadata +// Spec shape async () => ({ - title: spec.info.title, - version: spec.info.version, endpoints: Object.keys(spec.paths).length, }) ``` @@ -257,9 +265,9 @@ async () => { CodeMode automatically preprocesses your OpenAPI spec before passing it to the search sandbox: -- **`$ref` resolution** — all `$ref` pointers are resolved inline (circular refs become `{ $circular: ref }`) -- **Field extraction** — only essential fields kept per operation: `summary`, `description`, `tags`, `operationId`, `parameters`, `requestBody`, `responses` -- **Metadata preserved** — `info`, `servers`, and `components.schemas` are kept alongside processed paths +- **`$ref` resolution** — resolvable `$ref` pointers are expanded inline; circular refs become `{ $circular: ref }`, and refs beyond `maxRefDepth` become `{ $circular: ref, $reason: "max depth exceeded" }` +- **Field extraction** — only essential fields kept per operation: `summary`, `description`, `tags`, `parameters`, `requestBody`, `responses` +- **Output shape** — only `{ paths }` is passed to search; `info`, `servers`, and `components` are omitted because operation fields and resolved references are represented in the paths view You can also use the preprocessing utilities directly: @@ -315,17 +323,37 @@ const codemode = new CodeMode({ Implement the `Executor` interface to use your own sandbox: +CodeMode passes a fresh structured clone of the processed spec to each `search()` call, so mutations made by a custom executor cannot change later searches. + +CodeMode calls `executeData()` for `search()` and `executeWithCapabilities()` for `execute()`. Implement the latter when the custom runtime needs to expose the request capability; the legacy `execute()` method remains available for direct executor use. + ```typescript -import { CodeMode, type Executor, type ExecuteResult } from '@robinbraemer/codemode'; +import { + CodeMode, + emptyExecuteStats, + type CapabilityManifest, + type ExecuteResult, + type Executor, +} from '@robinbraemer/codemode'; class MyExecutor implements Executor { - async execute(code: string, globals: Record): Promise { - // `code` is an async arrow function as a string: "async () => { ... }" - // `globals` contains named values to inject: - // - plain data (objects, arrays, primitives) → read-only values - // - functions → callable host functions - // - objects with function values → namespace with callable methods - return { result: ..., logs: [] }; + async executeData(_code: string, _input: Record): Promise { + // Run data-only code in the sandbox. + return { result: undefined, stats: emptyExecuteStats() }; + } + + async executeWithCapabilities( + _code: string, + _input: Record, + _capabilities: CapabilityManifest, + ): Promise { + // Run code with declared capabilities, such as `{namespace}.request()`. + return { result: undefined, stats: emptyExecuteStats() }; + } + + async execute(_code: string, _globals: Record): Promise { + // Legacy direct-executor entrypoint. + return { result: undefined, stats: emptyExecuteStats() }; } dispose() { /* clean up */ } diff --git a/packages/codemode/examples/petstore.ts b/packages/codemode/examples/petstore.ts index 8a5d8fd..51496d7 100644 --- a/packages/codemode/examples/petstore.ts +++ b/packages/codemode/examples/petstore.ts @@ -87,9 +87,6 @@ function step(label: string, result: { content: { text: string }[]; isError?: bo step( "Search: API overview", await codemode.search(`async () => ({ - title: spec.info.title, - version: spec.info.version, - servers: spec.servers, endpointCount: Object.keys(spec.paths).length, endpoints: Object.keys(spec.paths), })`), @@ -100,7 +97,7 @@ step( "Search: Pet endpoints", await codemode.search(`async () => { return Object.entries(spec.paths) - .filter(([p]) => p.startsWith('/pet')) + .filter(([p]) => p.startsWith('/api/v3/pet')) .flatMap(([path, methods]) => Object.entries(methods) .filter(([m]) => ['get','post','put','delete'].includes(m)) @@ -116,7 +113,10 @@ step( // 3. Search: Inspect Pet schema step( "Search: Pet schema", - await codemode.search(`async () => spec.components?.schemas?.Pet`), + await codemode.search(`async () => ({ + getPet: spec.paths['/api/v3/pet/{petId}']?.get?.responses, + createPet: spec.paths['/api/v3/pet']?.post?.requestBody, + })`), ); // 4. Execute: Find available pets diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 2996001..afdb364 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/codemode", - "version": "0.4.0", + "version": "0.4.1", "description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.", "type": "module", "main": "./dist/index.js", diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 43de2e6..32f89b3 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,4 +1,8 @@ import { createExecutor } from "./executor/auto.js"; +import { + dataOnlyViolationError, + findDataOnlyTransportViolation, +} from "./executor/data-only.js"; import { DEFAULT_MAX_CODE_BYTES } from "./limits.js"; import { createRequestBridge, @@ -91,6 +95,7 @@ export class CodeMode { // Cached processed spec & context for tool descriptions private processedSpec: Record | null = null; + private processedSpecPromise: Promise> | null = null; private specContext: { tags: string[]; endpointCount: number } | null = null; constructor(options: CodeModeOptions) { @@ -158,15 +163,24 @@ export class CodeMode { /** * Execute a search against the OpenAPI spec. * The code runs in a sandbox with `spec` available as a global. - * All $refs are pre-resolved inline. + * Resolvable $refs are expanded inline; circular and max-depth refs remain + * marked in the processed search view. */ async search(code: string): Promise { const sizeError = this.validateCodeSize(code); if (sizeError) return sizeError; const executor = await this.getExecutor(); const spec = await this.getProcessedSpec(); + const input = { spec }; + const violation = findDataOnlyTransportViolation(input); + if (violation) { + return this.formatResult({ + result: undefined, + error: dataOnlyViolationError(violation), + }); + } - const result = await executor.executeData(code, { spec }); + const result = await executor.executeData(code, { spec: structuredClone(spec) }); return this.formatResult(result); } @@ -227,21 +241,36 @@ export class CodeMode { } /** - * Get the processed spec (refs resolved, fields extracted). + * Get the processed spec (resolvable refs expanded, fields extracted). * Caches the result after first call. */ private async getProcessedSpec(): Promise> { if (this.processedSpec) return this.processedSpec; - const raw = await this.resolveSpec(); - this.processedSpec = processSpec(raw, this.options.maxRefDepth); - - // Extract context for tool descriptions - const tags = extractTags(raw); - const endpointCount = Object.keys(raw.paths ?? {}).length; - this.specContext = { tags, endpointCount }; + if (!this.processedSpecPromise) { + const processedSpecPromise = this.resolveSpec().then((raw) => { + const processedSpec = processSpec(raw, this.options.maxRefDepth); + + // Extract context for tool descriptions + const tags = extractTags(raw); + const endpointCount = Object.keys(raw.paths ?? {}).length; + this.specContext = { tags, endpointCount }; + this.processedSpec = processedSpec; + if (this.processedSpecPromise === processedSpecPromise) { + this.processedSpecPromise = null; + } + + return processedSpec; + }); + this.processedSpecPromise = processedSpecPromise; + void processedSpecPromise.catch(() => { + if (this.processedSpecPromise === processedSpecPromise) { + this.processedSpecPromise = null; + } + }); + } - return this.processedSpec; + return this.processedSpecPromise; } private async getExecutor(): Promise { diff --git a/packages/codemode/src/executor/data-only.ts b/packages/codemode/src/executor/data-only.ts index 876c5de..3caa78f 100644 --- a/packages/codemode/src/executor/data-only.ts +++ b/packages/codemode/src/executor/data-only.ts @@ -1,21 +1,52 @@ import type { ExecuteResult, ExecuteStats } from "../types.js"; const MAX_GUARD_NODES = 100_000; +// The largest representative pre-resolved OpenAPI fixture expands to 303,708 +// transport occurrences and 4,354,110 JSON bytes. These ceilings preserve that +// workload while rejecting alias fan-out before a built-in executor recreates +// a much larger tree (the depth-18 regression expands past 1M occurrences and +// 22.8 MB). +const MAX_TRANSPORT_NODES = 500_000; +const MAX_TRANSPORT_BYTES = 10 * 1024 * 1024; interface PendingNode { value: unknown; path: string; } -export function findFunctionPath(value: unknown, path = "input"): string | null { +type StructuralDataOnlyViolation = + | { kind: "function"; path: string } + | { kind: "accessor"; path: string } + | { kind: "graph-too-large"; path: string }; + +export type DataOnlyViolation = + | StructuralDataOnlyViolation + | { kind: "transport-graph-too-large"; path: string } + | { kind: "transport-bytes-too-large"; path: string } + | { kind: "cyclic"; path: string } + | { kind: "custom-to-json"; path: string } + | { kind: "bigint"; path: string }; + +export function findDataOnlyViolation( + value: unknown, + path = "input", +): StructuralDataOnlyViolation | null { + return findStructuralDataOnlyViolation(value, path, false); +} + +function findStructuralDataOnlyViolation( + value: unknown, + path: string, + allowFunctions: boolean, +): StructuralDataOnlyViolation | null { const seen = new WeakSet(); const pending: PendingNode[] = [{ value, path }]; let checked = 0; let queued = 1; - function checkBudget(nextPath: string): string | null { + function checkBudget(nextPath: string): StructuralDataOnlyViolation | null { checked += 1; - return checked > MAX_GUARD_NODES ? `${nextPath} (object graph too large)` : null; + return checked > MAX_GUARD_NODES ? { kind: "graph-too-large", path: nextPath } : null; } while (pending.length > 0) { @@ -24,7 +55,10 @@ export function findFunctionPath(value: unknown, path = "input"): string | null const budgetError = checkBudget(current.path); if (budgetError) return budgetError; - if (typeof current.value === "function") return current.path; + if (typeof current.value === "function") { + if (!allowFunctions) return { kind: "function", path: current.path }; + continue; + } if (current.value === null || typeof current.value !== "object") continue; if (seen.has(current.value)) continue; seen.add(current.value); @@ -40,11 +74,11 @@ export function findFunctionPath(value: unknown, path = "input"): string | null ? `${current.path}[${key}]` : `${current.path}.${key}`; if (!("value" in descriptor)) { - return `${childPath} (accessor property is not data-only)`; + return { kind: "accessor", path: childPath }; } queued += 1; if (queued > MAX_GUARD_NODES) { - return `${childPath} (object graph too large)`; + return { kind: "graph-too-large", path: childPath }; } pending.push({ value: descriptor.value, @@ -56,20 +90,381 @@ export function findFunctionPath(value: unknown, path = "input"): string | null return null; } +interface TransportGuardOptions { + allowFunctions?: boolean; +} + +interface VisitTransportNode { + kind: "visit"; + value: unknown; + path: string; + arrayElement: boolean; +} + +interface LeaveTransportNode { + kind: "leave"; + value: object; +} + +interface VisitArrayEntries { + kind: "array"; + value: unknown[]; + path: string; + index: number; +} + +interface VisitObjectEntries { + kind: "object"; + value: Record; + path: string; + keys: string[]; + index: number; + emittedProperty: boolean; +} + +type TransportFrame = + | VisitTransportNode + | LeaveTransportNode + | VisitArrayEntries + | VisitObjectEntries; + +export function findDataOnlyTransportViolation( + value: unknown, + path = "input", + options: TransportGuardOptions = {}, +): DataOnlyViolation | null { + const structuralViolation = findStructuralDataOnlyViolation( + value, + path, + options.allowFunctions ?? false, + ); + if (structuralViolation) return structuralViolation; + + const active = new WeakSet(); + const jsonSerializationIssues = new WeakMap(); + const keysByObject = new WeakMap(); + const bytesByString = new Map(); + const pending: TransportFrame[] = [{ + kind: "visit", + value, + path, + arrayElement: false, + }]; + let nodes = 0; + let bytes = 0; + + function addNode(nextPath: string): DataOnlyViolation | null { + nodes += 1; + return nodes > MAX_TRANSPORT_NODES + ? { kind: "transport-graph-too-large", path: nextPath } + : null; + } + + function addBytes(amount: number, nextPath: string): DataOnlyViolation | null { + bytes += amount; + return bytes > MAX_TRANSPORT_BYTES + ? { kind: "transport-bytes-too-large", path: nextPath } + : null; + } + + function encodedStringBytes(input: string): number { + const cached = bytesByString.get(input); + if (cached !== undefined) return cached; + const encodedBytes = jsonStringByteLength(input); + bytesByString.set(input, encodedBytes); + return encodedBytes; + } + + function objectKeys(input: object): string[] { + const cached = keysByObject.get(input); + if (cached) return cached; + const keys = Object.keys(input); + keysByObject.set(input, keys); + return keys; + } + + while (pending.length > 0) { + const frame = pending.pop(); + if (!frame) break; + + if (frame.kind === "leave") { + active.delete(frame.value); + continue; + } + + if (frame.kind === "array") { + if (frame.index >= frame.value.length) continue; + + const childPath = `${frame.path}[${frame.index}]`; + const commaError = addBytes(frame.index === 0 ? 0 : 1, childPath); + if (commaError) return commaError; + + const descriptor = Object.getOwnPropertyDescriptor( + frame.value, + String(frame.index), + ); + if (descriptor && !("value" in descriptor)) { + return { kind: "accessor", path: childPath }; + } + + pending.push({ ...frame, index: frame.index + 1 }); + pending.push({ + kind: "visit", + value: descriptor?.value, + path: childPath, + arrayElement: true, + }); + continue; + } + + if (frame.kind === "object") { + if (frame.index >= frame.keys.length) continue; + + const key = frame.keys[frame.index]!; + const descriptor = Object.getOwnPropertyDescriptor(frame.value, key); + const nextFrame = { ...frame, index: frame.index + 1 }; + if (!descriptor) { + pending.push(nextFrame); + continue; + } + + const childPath = `${frame.path}.${key}`; + if (!("value" in descriptor)) { + return { kind: "accessor", path: childPath }; + } + if ( + descriptor.value === undefined || + typeof descriptor.value === "function" || + typeof descriptor.value === "symbol" + ) { + pending.push(nextFrame); + continue; + } + + const propertyBytes = + (frame.emittedProperty ? 1 : 0) + encodedStringBytes(key) + 1; + const propertyError = addBytes(propertyBytes, childPath); + if (propertyError) return propertyError; + + pending.push({ ...nextFrame, emittedProperty: true }); + pending.push({ + kind: "visit", + value: descriptor.value, + path: childPath, + arrayElement: false, + }); + continue; + } + + const nodeError = addNode(frame.path); + if (nodeError) return nodeError; + + if ( + frame.value === undefined || + typeof frame.value === "function" || + typeof frame.value === "symbol" + ) { + const omittedValueError = addBytes(frame.arrayElement ? 4 : 0, frame.path); + if (omittedValueError) return omittedValueError; + continue; + } + if (frame.value === null) { + const nullError = addBytes(4, frame.path); + if (nullError) return nullError; + continue; + } + if (typeof frame.value === "string") { + const stringError = addBytes(encodedStringBytes(frame.value), frame.path); + if (stringError) return stringError; + continue; + } + if (typeof frame.value === "number") { + const numberError = addBytes( + Number.isFinite(frame.value) ? String(frame.value).length : 4, + frame.path, + ); + if (numberError) return numberError; + continue; + } + if (typeof frame.value === "boolean") { + const booleanError = addBytes(frame.value ? 4 : 5, frame.path); + if (booleanError) return booleanError; + continue; + } + if (typeof frame.value === "bigint") { + return { kind: "bigint", path: frame.path }; + } + + const jsonSerializationIssue = findJSONSerializationIssue( + frame.value, + jsonSerializationIssues, + ); + if (jsonSerializationIssue) { + return { kind: jsonSerializationIssue, path: `${frame.path}.toJSON` }; + } + if (active.has(frame.value)) { + return { kind: "cyclic", path: frame.path }; + } + active.add(frame.value); + + const delimitersError = addBytes(2, frame.path); + if (delimitersError) return delimitersError; + pending.push({ kind: "leave", value: frame.value }); + if (Array.isArray(frame.value)) { + pending.push({ + kind: "array", + value: frame.value, + path: frame.path, + index: 0, + }); + } else { + pending.push({ + kind: "object", + value: frame.value as Record, + path: frame.path, + keys: objectKeys(frame.value), + index: 0, + emittedProperty: false, + }); + } + } + + return null; +} + +type JSONSerializationIssue = "accessor" | "custom-to-json"; + +function findJSONSerializationIssue( + value: object, + cache: WeakMap, +): JSONSerializationIssue | null { + if (cache.has(value)) return cache.get(value) ?? null; + + let issue: JSONSerializationIssue | null; + if (hasNativeDateToJSON(value)) { + issue = null; + } else { + const descriptor = Object.getOwnPropertyDescriptor(value, "toJSON"); + if (descriptor) { + issue = !("value" in descriptor) + ? "accessor" + : typeof descriptor.value === "function" + ? "custom-to-json" + : null; + } else { + const prototype = Object.getPrototypeOf(value) as object | null; + issue = prototype ? findJSONSerializationIssue(prototype, cache) : null; + } + } + cache.set(value, issue); + return issue; +} + +function hasNativeDateToJSON(value: object): boolean { + try { + Date.prototype.getTime.call(value); + } catch { + return false; + } + + let current: object | null = value; + while (current) { + const descriptor = Object.getOwnPropertyDescriptor(current, "toJSON"); + if (descriptor) { + return "value" in descriptor && descriptor.value === Date.prototype.toJSON; + } + current = Object.getPrototypeOf(current) as object | null; + } + + return false; +} + +function jsonStringByteLength(value: string): number { + let bytes = 2; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if ( + codeUnit === 0x22 || + codeUnit === 0x5c || + codeUnit === 0x08 || + codeUnit === 0x09 || + codeUnit === 0x0a || + codeUnit === 0x0c || + codeUnit === 0x0d + ) { + bytes += 2; + } else if (codeUnit < 0x20) { + bytes += 6; + } else if (codeUnit <= 0x7f) { + bytes += 1; + } else if (codeUnit <= 0x7ff) { + bytes += 2; + } else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 6; + } + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + bytes += 6; + } else { + bytes += 3; + } + } + return bytes; +} + export function dataOnlyFunctionError(functionPath: string): string { return `data-only execution does not accept function values at ${functionPath}`; } +export function dataOnlyViolationError(violation: DataOnlyViolation): string { + switch (violation.kind) { + case "function": + return dataOnlyFunctionError(violation.path); + case "accessor": + return `data-only execution does not accept accessor properties at ${violation.path}`; + case "graph-too-large": + return `data-only execution input graph exceeds ${MAX_GUARD_NODES} nodes at ${violation.path}`; + case "transport-graph-too-large": + return `data-only execution expanded transport graph exceeds ${MAX_TRANSPORT_NODES} nodes at ${violation.path}`; + case "transport-bytes-too-large": + return `data-only execution encoded input exceeds ${MAX_TRANSPORT_BYTES} bytes at ${violation.path}`; + case "cyclic": + return `data-only execution does not accept cyclic object graphs at ${violation.path}`; + case "custom-to-json": + return `data-only execution does not accept custom toJSON serialization at ${violation.path}`; + case "bigint": + return `data-only execution does not accept bigint values at ${violation.path}`; + } +} + export function rejectDataOnlyFunctions( input: Record, stats: ExecuteStats, ): ExecuteResult | null { - const functionPath = findFunctionPath(input); - if (!functionPath) return null; + const violation = findDataOnlyViolation(input); + if (!violation) return null; + + return { + result: undefined, + error: dataOnlyViolationError(violation), + stats, + }; +} + +export function rejectDataOnlyTransport( + input: Record, + stats: ExecuteStats, +): ExecuteResult | null { + const violation = findDataOnlyTransportViolation(input); + if (!violation) return null; return { result: undefined, - error: dataOnlyFunctionError(functionPath), + error: dataOnlyViolationError(violation), stats, }; } diff --git a/packages/codemode/src/executor/isolated-vm.ts b/packages/codemode/src/executor/isolated-vm.ts index fda7cb6..9c60402 100644 --- a/packages/codemode/src/executor/isolated-vm.ts +++ b/packages/codemode/src/executor/isolated-vm.ts @@ -2,7 +2,10 @@ import { DEFAULT_MAX_RESULT_BYTES, } from "../limits.js"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; -import { findFunctionPath, rejectDataOnlyFunctions } from "./data-only.js"; +import { + dataOnlyViolationError, + findDataOnlyTransportViolation, +} from "./data-only.js"; const UTF8_BYTE_LENGTH_SOURCE = `function(value) { let bytes = 0; @@ -46,11 +49,22 @@ export class IsolatedVMExecutor implements Executor { code: string, globals: Record, ): Promise { - if (hasHostFunctions(globals)) { + return await this.executeGuarded(code, globals, false); + } + + private async executeGuarded( + code: string, + globals: Record, + dataOnly: boolean, + ): Promise { + const violation = findDataOnlyTransportViolation(globals); + if (violation) { return { result: undefined, error: - "IsolatedVMExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution", + violation.kind === "function" && !dataOnly + ? "IsolatedVMExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution" + : dataOnlyViolationError(violation), stats: emptyStats(0, this.memoryMB), }; } @@ -150,10 +164,7 @@ export class IsolatedVMExecutor implements Executor { code: string, input: Record, ): Promise { - const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); - if (rejection) return rejection; - - return await this.execute(code, input); + return await this.executeGuarded(code, input, true); } } @@ -193,10 +204,6 @@ function captureStats(isolate: { isDisposed: boolean; cpuTime: bigint; wallTime: }; } -function hasHostFunctions(globals: Record): boolean { - return findFunctionPath(globals) !== null; -} - function validateExecutionResult(result: unknown, maxResultBytes: number): void { if (result === "__cmUndef" || result === undefined) return; if (typeof result !== "string") { diff --git a/packages/codemode/src/executor/llrt-native.ts b/packages/codemode/src/executor/llrt-native.ts index d7c66dd..daac713 100644 --- a/packages/codemode/src/executor/llrt-native.ts +++ b/packages/codemode/src/executor/llrt-native.ts @@ -11,7 +11,11 @@ import type { ExecuteStats, SandboxOptions, } from "../types.js"; -import { rejectDataOnlyFunctions } from "./data-only.js"; +import { + dataOnlyViolationError, + findDataOnlyTransportViolation, + rejectDataOnlyTransport, +} from "./data-only.js"; /** * Experimental in-process LLRT executor backed by `@robinbraemer/llrt`. @@ -41,8 +45,26 @@ export class LlrtNativeExecutor implements CapabilityExecutor { async execute( code: string, globals: Record, + ): Promise { + return await this.executeGuarded(code, globals, true); + } + + private async executeGuarded( + code: string, + globals: Record, + allowFunctions: boolean, ): Promise { const start = Date.now(); + const violation = findDataOnlyTransportViolation(globals, "input", { + allowFunctions, + }); + if (violation) { + return { + result: undefined, + error: dataOnlyViolationError(violation), + stats: emptyStats(Date.now() - start, this.memoryMB), + }; + } try { const { LlrtRuntime } = await import("@robinbraemer/llrt"); @@ -99,10 +121,7 @@ export class LlrtNativeExecutor implements CapabilityExecutor { code: string, input: Record, ): Promise { - const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); - if (rejection) return rejection; - - return await this.execute(code, input); + return await this.executeGuarded(code, input, false); } async executeWithCapabilities( @@ -110,7 +129,7 @@ export class LlrtNativeExecutor implements CapabilityExecutor { input: Record, capabilities: CapabilityManifest, ): Promise { - const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); + const rejection = rejectDataOnlyTransport(input, emptyStats(0, this.memoryMB)); if (rejection) return rejection; const collision = capabilityNamespaceCollision(input, capabilities); if (collision) { diff --git a/packages/codemode/src/executor/llrt-process.ts b/packages/codemode/src/executor/llrt-process.ts index ffbcad5..1e5cd2b 100644 --- a/packages/codemode/src/executor/llrt-process.ts +++ b/packages/codemode/src/executor/llrt-process.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; -import { rejectDataOnlyFunctions } from "./data-only.js"; +import { rejectDataOnlyTransport } from "./data-only.js"; export interface LlrtProcessExecutorOptions extends SandboxOptions { binaryPath?: string; @@ -75,7 +75,7 @@ export class LlrtProcessExecutor implements Executor { code: string, input: Record, ): Promise { - const rejection = rejectDataOnlyFunctions(input, captureStats(Date.now())); + const rejection = rejectDataOnlyTransport(input, captureStats(Date.now())); if (rejection) return rejection; return await this.executeProcess(code, input); diff --git a/packages/codemode/src/executor/quickjs.ts b/packages/codemode/src/executor/quickjs.ts index 8c57e9f..5430d83 100644 --- a/packages/codemode/src/executor/quickjs.ts +++ b/packages/codemode/src/executor/quickjs.ts @@ -1,6 +1,9 @@ import { DEFAULT_MAX_RESULT_BYTES } from "../limits.js"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; -import { findFunctionPath, rejectDataOnlyFunctions } from "./data-only.js"; +import { + dataOnlyViolationError, + findDataOnlyTransportViolation, +} from "./data-only.js"; const UTF8_BYTE_LENGTH_SOURCE = `function(value) { let bytes = 0; @@ -104,13 +107,24 @@ export class QuickJSExecutor implements Executor { async execute( code: string, globals: Record, + ): Promise { + return await this.executeGuarded(code, globals, false); + } + + private async executeGuarded( + code: string, + globals: Record, + dataOnly: boolean, ): Promise { const start = Date.now(); - if (hasHostFunctions(globals)) { + const violation = findDataOnlyTransportViolation(globals); + if (violation) { return { result: undefined, error: - "QuickJSExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution", + violation.kind === "function" && !dataOnly + ? "QuickJSExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution" + : dataOnlyViolationError(violation), stats: emptyStats(start, this.memoryMB), }; } @@ -262,10 +276,7 @@ export class QuickJSExecutor implements Executor { code: string, input: Record, ): Promise { - const rejection = rejectDataOnlyFunctions(input, emptyStats(Date.now(), this.memoryMB)); - if (rejection) return rejection; - - return await this.execute(code, input); + return await this.executeGuarded(code, input, true); } } @@ -504,10 +515,6 @@ function emptyStats(startMs: number, memoryMB: number): ExecuteStats { }; } -function hasHostFunctions(globals: Record): boolean { - return findFunctionPath(globals) !== null; -} - /** * Dispose a handle only if it is *owned* (not one of the context's static * singletons like `context.undefined` / `context.null` / `context.true` / diff --git a/packages/codemode/src/spec.ts b/packages/codemode/src/spec.ts index eb3858b..e0d9ae0 100644 --- a/packages/codemode/src/spec.ts +++ b/packages/codemode/src/spec.ts @@ -7,27 +7,32 @@ const DEFAULT_MAX_REF_DEPTH = 50; /** Keys that must never be traversed or copied during $ref resolution. */ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); -/** - * Recursively resolve all `$ref` pointers in an OpenAPI spec inline. - * Circular references are replaced with `{ $circular: ref }`. - * - * The `seen` set tracks the current ancestor chain only (not globally), - * so the same $ref used in sibling positions resolves correctly. - * A memoization cache avoids re-resolving the same $ref multiple times. - * - * @param maxDepth - Maximum $ref resolution depth (default: 50) - */ -export function resolveRefs( +interface ResolutionResult { + value: unknown; + contextDependent: boolean; +} + +function resolveRefsWithContext( obj: unknown, root: Record, - seen = new Set(), - maxDepth = DEFAULT_MAX_REF_DEPTH, - _cache = new Map(), -): unknown { - if (obj === null || obj === undefined) return obj; - if (typeof obj !== "object") return obj; - if (Array.isArray(obj)) - return obj.map((item) => resolveRefs(item, root, seen, maxDepth, _cache)); + seen: Set, + maxDepth: number, + cache: Map, +): ResolutionResult { + if (obj === null || obj === undefined) { + return { value: obj, contextDependent: false }; + } + if (typeof obj !== "object") return { value: obj, contextDependent: false }; + + if (Array.isArray(obj)) { + const items = obj.map((item) => + resolveRefsWithContext(item, root, seen, maxDepth, cache), + ); + return { + value: items.map(({ value }) => value), + contextDependent: items.some(({ contextDependent }) => contextDependent), + }; + } const record = obj as Record; @@ -35,20 +40,30 @@ export function resolveRefs( const ref = record.$ref; // Circular: this $ref is already in the current ancestor chain - if (seen.has(ref)) return { $circular: ref }; + if (seen.has(ref)) { + return { value: { $circular: ref }, contextDependent: true }; + } // Depth limit if (seen.size >= maxDepth) { - return { $circular: ref, $reason: "max depth exceeded" }; + return { + value: { $circular: ref, $reason: "max depth exceeded" }, + contextDependent: true, + }; } - // Memoization: return cached result if available - if (_cache.has(ref)) return _cache.get(ref); + // A finite expansion is reusable only with the same remaining depth budget. + const cacheKey = JSON.stringify([maxDepth - seen.size, ref]); + if (cache.has(cacheKey)) { + return { value: cache.get(cacheKey), contextDependent: false }; + } const parts = ref.replace("#/", "").split("/"); let resolved: unknown = root; for (const part of parts) { - if (DANGEROUS_KEYS.has(part)) return { $ref: ref, $error: "unsafe ref path" }; + if (DANGEROUS_KEYS.has(part)) { + return { value: { $ref: ref, $error: "unsafe ref path" }, contextDependent: false }; + } resolved = (resolved as Record)?.[part]; } @@ -56,17 +71,41 @@ export function resolveRefs( const branchSeen = new Set(seen); branchSeen.add(ref); - const result = resolveRefs(resolved, root, branchSeen, maxDepth, _cache); - _cache.set(ref, result); + const result = resolveRefsWithContext(resolved, root, branchSeen, maxDepth, cache); + if (!result.contextDependent) cache.set(cacheKey, result.value); return result; } const result: Record = {}; + let contextDependent = false; for (const [key, value] of Object.entries(record)) { if (DANGEROUS_KEYS.has(key)) continue; - result[key] = resolveRefs(value, root, seen, maxDepth, _cache); + const resolved = resolveRefsWithContext(value, root, seen, maxDepth, cache); + result[key] = resolved.value; + contextDependent ||= resolved.contextDependent; } - return result; + return { value: result, contextDependent }; +} + +/** + * Recursively resolve resolvable `$ref` pointers in an OpenAPI spec inline. + * Circular references and refs beyond the depth limit are replaced with + * markers describing why expansion stopped. + * + * The `seen` set tracks the current ancestor chain only (not globally), + * so the same $ref used in sibling positions resolves correctly. + * A memoization cache avoids re-resolving context-independent $refs. + * + * @param maxDepth - Maximum $ref resolution depth (default: 50) + */ +export function resolveRefs( + obj: unknown, + root: Record, + seen = new Set(), + maxDepth = DEFAULT_MAX_REF_DEPTH, + _cache = new Map(), +): unknown { + return resolveRefsWithContext(obj, root, seen, maxDepth, _cache).value; } interface OperationObject { @@ -103,9 +142,10 @@ export function extractServerBasePath(spec: OpenAPISpec): string { /** * Process an OpenAPI spec into a simplified format for the search tool. - * Resolves all $refs inline and extracts only the fields needed for search. + * Resolves resolvable $refs inline and extracts only the fields needed for search. * Prepends the server base path to all path keys so they're directly usable. - * Only paths are returned — info and components are omitted since refs are resolved inline. + * Only paths are returned — info and components are omitted because the search + * view contains the fields extracted from operations and their resolved refs. * * @param maxRefDepth - Maximum $ref resolution depth (default: 50) */ @@ -119,6 +159,7 @@ export function processSpec( >; const basePath = extractServerBasePath(spec); const paths: Record> = {}; + const refCache = new Map(); for (const [path, pathItem] of Object.entries(rawPaths)) { if (!pathItem) continue; @@ -137,25 +178,28 @@ export function processSpec( spec as Record, undefined, maxRefDepth, + refCache, ), requestBody: resolveRefs( op.requestBody, spec as Record, undefined, maxRefDepth, + refCache, ), responses: resolveRefs( op.responses, spec as Record, undefined, maxRefDepth, + refCache, ), }; } } } - // Only paths — info and components are omitted since all $refs are resolved inline. + // Only paths — info and components are omitted from the search view. return { paths }; } diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts index cd4a2f3..7ea4144 100644 --- a/packages/codemode/src/tools.ts +++ b/packages/codemode/src/tools.ts @@ -30,7 +30,7 @@ export function createSearchToolDefinition( const parts: string[] = []; parts.push( - `Search the API specification to discover available endpoints. All $refs are pre-resolved inline.`, + `Search the API specification to discover available endpoints. Resolvable $refs are expanded inline; circular and max-depth refs remain marked.`, ); if (context?.tags && context.tags.length > 0) { @@ -80,7 +80,7 @@ Examples: ${discoverExample} -// Get endpoint with requestBody schema (refs are resolved) +// Get endpoint with requestBody schema (resolvable refs are expanded) async () => { const op = spec.paths['/example']?.post; return { summary: op?.summary, requestBody: op?.requestBody }; diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index e03be25..565f4a8 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -74,6 +74,45 @@ class TestExecutor implements Executor { } } +type SearchSchema = { + type?: string; + properties?: Record; +}; + +function responseSchema(input: Record, path: string): SearchSchema { + const spec = input.spec as { + paths: Record; + }>; + }; + }>; + }; + return spec.paths[path]!.get.responses["200"]!.content["application/json"]!.schema; +} + +class MutatingSearchExecutor extends TestExecutor { + observations: Array<{ schemasAreShared: boolean; nameType: string | undefined }> = []; + + override async executeData( + _code: string, + input: Record, + ): Promise { + this.dataCalls += 1; + const petsSchema = responseSchema(input, "/pets"); + const featuredPetsSchema = responseSchema(input, "/featured-pets"); + + this.observations.push({ + schemasAreShared: petsSchema === featuredPetsSchema, + nameType: petsSchema.properties?.name?.type, + }); + petsSchema.properties!.name!.type = "mutated"; + + return { result: null }; + } +} + const testSpec = { openapi: "3.0.0", info: { title: "Test API", version: "1.0.0" }, @@ -271,6 +310,70 @@ describe("CodeMode", () => { expect(executor.calls).toBe(0); }); + it("isolates cached specs from custom data executor mutations", async () => { + const executor = new MutatingSearchExecutor(); + const cm = new CodeMode({ + spec: { + components: { + schemas: { + Pet: { type: "object", properties: { name: { type: "string" } } }, + }, + }, + paths: { + "/pets": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } }, + }, + }, + }, + "/featured-pets": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } }, + }, + }, + }, + }, + }, + request: testHandler, + executor, + }); + + await cm.search("async () => null"); + await cm.search("async () => null"); + + expect(executor.observations).toEqual([ + { schemasAreShared: true, nameType: "string" }, + { schemasAreShared: true, nameType: "string" }, + ]); + }); + + it("reports data-only spec violations before cloning the search input", async () => { + const cm = new CodeMode({ + spec: { + paths: { + "/invalid": { + get: { + responses: { + "200": { description: () => "not data-only" }, + }, + }, + }, + }, + }, + request: testHandler, + executor: new TestExecutor(), + }); + + const result = await cm.search("async () => 1"); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toBe( + "Error: data-only execution does not accept function values at input.spec.paths./invalid.get.responses.200.description", + ); + }); + it("supports spec as async getter", async () => { const cm = new CodeMode({ spec: async () => testSpec, @@ -284,6 +387,63 @@ describe("CodeMode", () => { const paths = JSON.parse(result.content[0]!.text); expect(paths).toContain("/v1/clusters"); }); + + it("shares an in-flight async spec provider across concurrent searches", async () => { + let resolveSpec: (spec: typeof testSpec) => void; + const pendingSpec = new Promise((resolve) => { + resolveSpec = resolve; + }); + let providerCalls = 0; + const executor = new TestExecutor(); + const cm = new CodeMode({ + spec: async () => { + providerCalls += 1; + return await pendingSpec; + }, + request: testHandler, + executor, + }); + + const firstSearch = cm.search("async () => Object.keys(spec.paths)"); + const secondSearch = cm.search("async () => Object.keys(spec.paths)"); + + await Promise.resolve(); + expect(providerCalls).toBe(1); + + resolveSpec!(testSpec); + const [firstResult, secondResult] = await Promise.all([ + firstSearch, + secondSearch, + ]); + + expect(firstResult.isError).toBeUndefined(); + expect(secondResult.isError).toBeUndefined(); + expect(executor.dataCalls).toBe(2); + }); + + it("retries the async spec provider after a failed search", async () => { + let providerCalls = 0; + const executor = new TestExecutor(); + const cm = new CodeMode({ + spec: async () => { + providerCalls += 1; + if (providerCalls === 1) throw new Error("spec unavailable"); + return testSpec; + }, + request: testHandler, + executor, + }); + + await expect(cm.search("async () => Object.keys(spec.paths)")).rejects.toThrow( + "spec unavailable", + ); + + const result = await cm.search("async () => Object.keys(spec.paths)"); + + expect(result.isError).toBeUndefined(); + expect(providerCalls).toBe(2); + expect(executor.dataCalls).toBe(1); + }); }); describe("execute()", () => { diff --git a/packages/codemode/test/data-only.test.ts b/packages/codemode/test/data-only.test.ts index 5106a6e..3eb168b 100644 --- a/packages/codemode/test/data-only.test.ts +++ b/packages/codemode/test/data-only.test.ts @@ -1,8 +1,30 @@ import { describe, expect, it } from "vitest"; -import { findFunctionPath } from "../src/executor/data-only.js"; +import { + findDataOnlyViolation, + rejectDataOnlyFunctions, + rejectDataOnlyTransport, +} from "../src/executor/data-only.js"; +import { emptyExecuteStats } from "../src/types.js"; +import { + createCyclicTransportInput, + createCustomToJSONTransportInput, + createDepth18TransportFanOut, + createDepth18TransportNodeFanOut, +} from "./transport-fixtures.js"; describe("data-only input guard", () => { - it("rejects accessor properties without invoking them", () => { + it("reports real function values with their path", () => { + const rejection = rejectDataOnlyFunctions( + { callback: () => "blocked" }, + emptyExecuteStats(), + ); + + expect(rejection?.error).toBe( + "data-only execution does not accept function values at input.callback", + ); + }); + + it("reports accessor properties without invoking them", () => { const input = {}; Object.defineProperty(input, "danger", { enumerable: true, @@ -11,7 +33,23 @@ describe("data-only input guard", () => { }, }); - expect(findFunctionPath(input)).toContain("accessor property is not data-only"); + const rejection = rejectDataOnlyFunctions(input, emptyExecuteStats()); + + expect(rejection?.error).toBe( + "data-only execution does not accept accessor properties at input.danger", + ); + expect(rejection?.error).not.toContain("function values"); + }); + + it("reports object graphs that exceed the guard limit with their path", () => { + const input = { entries: Array.from({ length: 100_001 }, () => null) }; + + const rejection = rejectDataOnlyFunctions(input, emptyExecuteStats()); + + expect(rejection?.error).toBe( + "data-only execution input graph exceeds 100000 nodes at input.entries[99998]", + ); + expect(rejection?.error).not.toContain("function values"); }); it("checks only present entries in sparse arrays", () => { @@ -19,6 +57,93 @@ describe("data-only input guard", () => { input.length = 1_000_000; input[999_999] = () => "blocked"; - expect(findFunctionPath(input)).toBe("input[999999]"); + expect(findDataOnlyViolation(input)?.path).toBe("input[999999]"); + }); + + it("rejects alias fan-out that exceeds the expanded transport budget", () => { + const rejection = rejectDataOnlyTransport( + createDepth18TransportNodeFanOut(), + emptyExecuteStats(), + ); + + expect(rejection?.error).toMatch( + /^data-only execution expanded transport graph exceeds 500000 nodes at input\.spec/, + ); + expect(rejection?.error).not.toContain("function values"); + }); + + it("rejects encoded alias fan-out before the occurrence budget", () => { + const rejection = rejectDataOnlyTransport( + createDepth18TransportFanOut(), + emptyExecuteStats(), + ); + + expect(rejection?.error).toMatch( + /^data-only execution encoded input exceeds 10485760 bytes at input\.spec/, + ); + expect(rejection?.error).not.toContain("function values"); + }); + + it("rejects true cycles before transport marshalling", () => { + const rejection = rejectDataOnlyTransport( + createCyclicTransportInput(), + emptyExecuteStats(), + ); + + expect(rejection?.error).toBe( + "data-only execution does not accept cyclic object graphs at input.value.self", + ); + }); + + it("rejects custom JSON serialization before invoking it", () => { + const rejection = rejectDataOnlyTransport( + createCustomToJSONTransportInput(), + emptyExecuteStats(), + ); + + expect(rejection?.error).toBe( + "data-only execution does not accept custom toJSON serialization at input.value.toJSON", + ); + }); + + it("accepts native Date JSON serialization", () => { + const rejection = rejectDataOnlyTransport( + { value: new Date("2026-07-24T00:00:00.000Z") }, + emptyExecuteStats(), + ); + + expect(rejection).toBeNull(); + }); + + it("rejects bigint values consistently across JSON transports", () => { + const rejection = rejectDataOnlyTransport( + { value: 1n }, + emptyExecuteStats(), + ); + + expect(rejection?.error).toBe( + "data-only execution does not accept bigint values at input.value", + ); + }); + + it("reuses transport metadata across shared alias occurrences", () => { + let keyScans = 0; + const shared = new Proxy( + { type: "string" }, + { + ownKeys(target) { + keyScans += 1; + return Reflect.ownKeys(target); + }, + }, + ); + + const rejection = rejectDataOnlyTransport( + { aliases: [shared, shared] }, + emptyExecuteStats(), + ); + + expect(rejection).toBeNull(); + expect(keyScans).toBe(2); }); }); diff --git a/packages/codemode/test/executor-contract.ts b/packages/codemode/test/executor-contract.ts index 2102ea9..61c007e 100644 --- a/packages/codemode/test/executor-contract.ts +++ b/packages/codemode/test/executor-contract.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import type { CapabilityExecutor, Executor, SandboxOptions } from "../src/types.js"; import { CodeMode } from "../src/codemode.js"; +import { + createCyclicTransportInput, + createCustomToJSONTransportInput, + createDepth18TransportFanOut, +} from "./transport-fixtures.js"; /** * Factory that constructs an executor for a given set of sandbox options. @@ -110,6 +115,79 @@ export function executorContract( expect(result.error).toContain("input.api.request"); }); + it("rejects alias fan-out before transport marshalling", async () => { + const executor = factory(); + const result = await executor.executeData( + `async () => 1`, + createDepth18TransportFanOut(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toMatch( + /^data-only execution encoded input exceeds 10485760 bytes at input\.spec/, + ); + }); + + it("rejects true cycles before transport marshalling", async () => { + const executor = factory(); + const result = await executor.executeData( + `async () => 1`, + createCyclicTransportInput(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept cyclic object graphs at input.value.self", + ); + }); + + it("rejects custom JSON serialization before invoking it", async () => { + const executor = factory(); + const result = await executor.executeData( + `async () => 1`, + createCustomToJSONTransportInput(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept custom toJSON serialization at input.value.toJSON", + ); + }); + + it("runs one full preflight for rejected data-only input", async () => { + let structuralScans = 0; + const value = new Proxy(createCustomToJSONTransportInput().value!, { + ownKeys(target) { + structuralScans += 1; + return Reflect.ownKeys(target); + }, + }); + const executor = factory(); + + const result = await executor.executeData( + `async () => 1`, + { value }, + ); + + expect(result.error).toBe( + "data-only execution does not accept custom toJSON serialization at input.value.toJSON", + ); + expect(structuralScans).toBe(1); + }); + + it("rejects bigint values consistently across transports", async () => { + const executor = factory(); + const result = await executor.executeData( + `async () => 1`, + { value: 1n }, + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept bigint values at input.value", + ); + }); + if (supportsHostFunctions) { it("injects async host functions in a namespace", async () => { const executor = factory(); @@ -146,6 +224,35 @@ export function executorContract( ); expect(result.error).toContain("does not support host functions"); }); + + it("classifies accessor and graph-limit globals accurately", async () => { + const executor = factory(); + const accessorInput: Record = {}; + Object.defineProperty(accessorInput, "danger", { + enumerable: true, + get() { + throw new Error("getter should not run"); + }, + }); + + const accessorResult = await executor.execute( + `async () => 1`, + accessorInput, + ); + + expect(accessorResult.error).toBe( + "data-only execution does not accept accessor properties at input.danger", + ); + + const graphResult = await executor.execute( + `async () => 1`, + { entries: Array.from({ length: 100_001 }, () => null) }, + ); + + expect(graphResult.error).toBe( + "data-only execution input graph exceeds 100000 nodes at input.entries[99998]", + ); + }); } it("console.log is a no-op (does not crash)", async () => { diff --git a/packages/codemode/test/llrt-process-executor.test.ts b/packages/codemode/test/llrt-process-executor.test.ts index 30e582e..17fd992 100644 --- a/packages/codemode/test/llrt-process-executor.test.ts +++ b/packages/codemode/test/llrt-process-executor.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { LlrtProcessExecutor } from "../src/executor/llrt-process.js"; +import { + createCyclicTransportInput, + createCustomToJSONTransportInput, + createDepth18TransportFanOut, +} from "./transport-fixtures.js"; async function createFakeLlrtBinary(): Promise { const dir = await mkdtemp(join(tmpdir(), "codemode-llrt-")); @@ -69,6 +74,62 @@ describe("LlrtProcessExecutor", () => { expect(result.result).toBe("API"); }); + it("rejects alias fan-out before transport marshalling", async () => { + const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary() }); + + const result = await executor.executeData( + `async () => 1`, + createDepth18TransportFanOut(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toMatch( + /^data-only execution encoded input exceeds 10485760 bytes at input\.spec/, + ); + }); + + it("rejects true cycles before transport marshalling", async () => { + const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary() }); + + const result = await executor.executeData( + `async () => 1`, + createCyclicTransportInput(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept cyclic object graphs at input.value.self", + ); + }); + + it("rejects custom JSON serialization before invoking it", async () => { + const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary() }); + + const result = await executor.executeData( + `async () => 1`, + createCustomToJSONTransportInput(), + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept custom toJSON serialization at input.value.toJSON", + ); + }); + + it("rejects bigint values consistently across transports", async () => { + const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary() }); + + const result = await executor.executeData( + `async () => 1`, + { value: 1n }, + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toBe( + "data-only execution does not accept bigint values at input.value", + ); + }); + it("enforces wall-clock timeout for a stuck process", async () => { const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary(), diff --git a/packages/codemode/test/spec.test.ts b/packages/codemode/test/spec.test.ts index 111701c..9d5d4f4 100644 --- a/packages/codemode/test/spec.test.ts +++ b/packages/codemode/test/spec.test.ts @@ -1,5 +1,26 @@ import { describe, expect, it } from "vitest"; +import { rejectDataOnlyTransport } from "../src/executor/data-only.js"; import { resolveRefs, processSpec, extractTags, extractServerBasePath } from "../src/spec.js"; +import { emptyExecuteStats } from "../src/types.js"; + +interface ResolvedSchema { + type?: string; + $ref?: string; + $circular?: string; + $reason?: string; + properties?: Record; +} + +interface ProcessedOperation { + responses?: Record; + }>; +} + +function responseSchema(processed: Record, path: string): ResolvedSchema { + const paths = processed.paths as Record; + return paths[path]!.get!.responses!["200"]!.content!["application/json"]!.schema!; +} describe("resolveRefs", () => { it("resolves simple $ref", () => { @@ -243,6 +264,163 @@ describe("processSpec", () => { expect(test.parameters).toBeUndefined(); expect(test.description).toBeUndefined(); }); + + it("shares acyclic component expansions across operations", () => { + const spec = { + components: { + schemas: { + Pet: { type: "object", properties: { name: { type: "string" } } }, + }, + }, + paths: { + "/pets": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } }, + }, + }, + }, + "/featured-pet": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } }, + }, + }, + }, + }, + }; + + const processed = processSpec(spec); + const petsSchema = responseSchema(processed, "/pets"); + const featuredPetSchema = responseSchema(processed, "/featured-pet"); + + expect(petsSchema.properties.name.type).toBe("string"); + expect(featuredPetSchema.properties.name.type).toBe("string"); + expect(petsSchema.$ref).toBeUndefined(); + expect(featuredPetSchema.$ref).toBeUndefined(); + expect(featuredPetSchema).toBe(petsSchema); + }); + + it("resolves mutually recursive component roots in each endpoint context", () => { + const spec = { + components: { + schemas: { + Parent: { + type: "object", + properties: { + parentName: { type: "string" }, + child: { $ref: "#/components/schemas/Child" }, + }, + }, + Child: { + type: "object", + properties: { + childAge: { type: "integer" }, + parent: { $ref: "#/components/schemas/Parent" }, + }, + }, + }, + }, + paths: { + "/parent": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Parent" } } } }, + }, + }, + }, + "/child": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Child" } } } }, + }, + }, + }, + }, + }; + + const processed = processSpec(spec); + const parentSchema = responseSchema(processed, "/parent"); + const childSchema = responseSchema(processed, "/child"); + + expect(parentSchema.properties.parentName.type).toBe("string"); + expect(parentSchema.properties.child.properties.childAge.type).toBe("integer"); + expect(parentSchema.properties.child.properties.parent).toEqual({ $circular: "#/components/schemas/Parent" }); + + expect(childSchema.properties.childAge.type).toBe("integer"); + expect(childSchema.properties.parent.properties.parentName.type).toBe("string"); + expect(childSchema.properties.parent.properties.child).toEqual({ $circular: "#/components/schemas/Child" }); + }); + + it("keeps max-depth sentinels when a shallower endpoint has cached a ref", () => { + const spec = { + components: { + schemas: { + A: { type: "object", properties: { b: { $ref: "#/components/schemas/B" } } }, + B: { type: "string" }, + C: { type: "object", properties: { a: { $ref: "#/components/schemas/A" } } }, + }, + }, + paths: { + "/a": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/A" } } } }, + }, + }, + }, + "/c": { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/C" } } } }, + }, + }, + }, + }, + }; + + const processed = processSpec(spec, 2); + const aSchema = responseSchema(processed, "/a"); + const cSchema = responseSchema(processed, "/c"); + + expect(aSchema.properties.b.type).toBe("string"); + expect(cSchema.properties.a.properties.b).toEqual({ + $circular: "#/components/schemas/B", + $reason: "max depth exceeded", + }); + }); + + it("keeps the 4.37 MB production-scale alias graph within transport budgets", () => { + const properties = Object.fromEntries( + Array.from({ length: 500 }, (_, index) => [`field${index}`, { type: "string" }]), + ); + const paths = Object.fromEntries( + Array.from({ length: 300 }, (_, index) => [ + `/resources/${index}`, + { + get: { + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Resource" } } } }, + }, + }, + }, + ]), + ); + const spec = { + components: { + schemas: { + Resource: { type: "object", properties }, + }, + }, + paths, + }; + + const processed = processSpec(spec); + const encodedBytes = Buffer.byteLength(JSON.stringify({ spec: processed })); + + expect(encodedBytes).toBe(4_354_110); + expect(rejectDataOnlyTransport({ spec: processed }, emptyExecuteStats())).toBeNull(); + }); }); describe("extractTags", () => { diff --git a/packages/codemode/test/transport-fixtures.ts b/packages/codemode/test/transport-fixtures.ts new file mode 100644 index 0000000..d77b786 --- /dev/null +++ b/packages/codemode/test/transport-fixtures.ts @@ -0,0 +1,38 @@ +export function createDepth18TransportFanOut(): Record { + let schema: Record = { + type: "string", + description: "x".repeat(35), + }; + + for (let depth = 0; depth < 18; depth += 1) { + schema = { left: schema, right: schema }; + } + + return { spec: schema }; +} + +export function createDepth18TransportNodeFanOut(): Record { + let schema: Record = { type: "string" }; + + for (let depth = 0; depth < 18; depth += 1) { + schema = { left: schema, right: schema }; + } + + return { spec: schema }; +} + +export function createCyclicTransportInput(): Record { + const value: Record = {}; + value.self = value; + return { value }; +} + +export function createCustomToJSONTransportInput(): Record { + class CustomJSONValue { + toJSON(): never { + throw new Error("custom toJSON should not run"); + } + } + + return { value: new CustomJSONValue() }; +}