diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 6a6fc3a..c971a6e 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/codemode", - "version": "0.3.1", + "version": "0.3.2", "description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "url": "https://github.com/cnap-tech/codemode.git" }, "peerDependencies": { - "@robinbraemer/llrt": "^0.1.1", + "@robinbraemer/llrt": "^0.1.2", "isolated-vm": "6", "quickjs-emscripten": ">=0.31" }, diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 34cd555..9a58a18 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,6 +1,7 @@ import { createExecutor } from "./executor/auto.js"; import { createRequestBridge, + type RequestBridgeContext, type RequestBridgeOptions, type SandboxRequestOptions, } from "./request-bridge.js"; @@ -104,6 +105,7 @@ export class CodeMode { this.bridgeBaseUrl = options.baseUrl ?? "http://localhost"; this.bridgeOptions = { maxRequests: options.maxRequests, + maxRequestBytes: options.maxRequestBytes, maxResponseBytes: options.maxResponseBytes, allowedHeaders: options.allowedHeaders, exposedResponseHeaders: options.exposedResponseHeaders, @@ -174,7 +176,9 @@ export class CodeMode { this.bridgeHandler, this.bridgeBaseUrl, this.bridgeOptions, ); const client = { - request: (...args: unknown[]) => bridge(args[0] as SandboxRequestOptions), + request(this: RequestBridgeContext, options: SandboxRequestOptions) { + return bridge(options, this); + }, }; const result = await executor.execute(code, { diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index be40e71..4958ad9 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -25,7 +25,12 @@ export { createExecutor } from "./executor/auto.js"; // Request bridge (for advanced usage / custom request handling) export { createRequestBridge } from "./request-bridge.js"; -export type { SandboxRequestOptions, SandboxResponse, RequestBridgeFn } from "./request-bridge.js"; +export type { + RequestBridgeContext, + RequestBridgeFn, + SandboxRequestOptions, + SandboxResponse, +} from "./request-bridge.js"; // Spec processing export { resolveRefs, processSpec, extractTags, extractServerBasePath } from "./spec.js"; diff --git a/packages/codemode/src/request-bridge.ts b/packages/codemode/src/request-bridge.ts index 03c3c48..2bec1b7 100644 --- a/packages/codemode/src/request-bridge.ts +++ b/packages/codemode/src/request-bridge.ts @@ -27,6 +27,8 @@ export interface SandboxResponse { export interface RequestBridgeOptions { /** Maximum number of requests per bridge instance. Default: 50. */ maxRequests?: number; + /** Maximum request body size in bytes. Default: 1MB. */ + maxRequestBytes?: number; /** Maximum response body size in bytes. Default: 10MB. */ maxResponseBytes?: number; /** Allowed headers whitelist. When undefined, uses default blocklist. */ @@ -35,6 +37,10 @@ export interface RequestBridgeOptions { exposedResponseHeaders?: string[]; } +export interface RequestBridgeContext { + signal?: AbortSignal; +} + const ALLOWED_METHODS = new Set([ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", ]); @@ -63,8 +69,45 @@ const BLOCKED_HEADER_PATTERNS = [ ]; const DEFAULT_MAX_REQUESTS = 50; +const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; // 1MB const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB +function requestAbortedError(): Error { + return new Error("Request aborted"); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw requestAbortedError(); + } +} + +function utf8ByteLength(text: string): number { + return Buffer.byteLength(text, "utf8"); +} + +async function abortable( + operation: Promise, + signal: AbortSignal | undefined, +): Promise { + if (!signal) return await operation; + throwIfAborted(signal); + + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(requestAbortedError()); + signal.addEventListener("abort", onAbort, { once: true }); + }); + + try { + return await Promise.race([operation, aborted]); + } finally { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + } +} + /** * Read a response body as text, aborting early if it exceeds maxBytes. * Streams the body in chunks to avoid buffering the entire response @@ -73,11 +116,13 @@ const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB async function readResponseWithLimit( response: Response, maxBytes: number, + signal?: AbortSignal, ): Promise { + throwIfAborted(signal); const reader = response.body?.getReader(); if (!reader) { // No body stream — fall back to .text() (e.g., empty responses) - const text = await response.text(); + const text = await abortable(response.text(), signal); if (text.length > maxBytes) { throw new Error( `Response too large: ${text.length} bytes exceeds limit of ${maxBytes} bytes`, @@ -88,20 +133,28 @@ async function readResponseWithLimit( const chunks: Uint8Array[] = []; let totalBytes = 0; + let shouldCancel = false; try { // Streaming read — must be sequential for (;;) { - const { done, value } = await reader.read(); // oxlint-disable-line no-await-in-loop + const { done, value } = await abortable(reader.read(), signal); // oxlint-disable-line no-await-in-loop if (done) break; totalBytes += value.byteLength; if (totalBytes > maxBytes) { + shouldCancel = true; throw new Error( `Response too large: exceeded limit of ${maxBytes} bytes`, ); } chunks.push(value); } + } catch (error) { + shouldCancel = true; + throw error; } finally { + if (shouldCancel) { + await reader.cancel().catch(() => {}); + } reader.releaseLock(); } @@ -195,7 +248,10 @@ function filterResponseHeaders( * Bridges sandbox API calls to the host request handler (Hono app.request, fetch, etc.). */ /** Bridge function with an exposed request count. */ -export type RequestBridgeFn = ((options: SandboxRequestOptions) => Promise) & { +export type RequestBridgeFn = (( + options: SandboxRequestOptions, + context?: RequestBridgeContext, +) => Promise) & { /** Number of requests made through this bridge instance. */ readonly requestCount: number; }; @@ -206,6 +262,7 @@ export function createRequestBridge( options: RequestBridgeOptions = {}, ): RequestBridgeFn { const maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS; + const maxRequestBytes = options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES; const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; const allowedHeaders = options.allowedHeaders ? new Set(options.allowedHeaders.map((h) => h.toLowerCase())) @@ -216,8 +273,13 @@ export function createRequestBridge( let requestCount = 0; - const bridge = async (opts: SandboxRequestOptions): Promise => { + const bridge = async ( + opts: SandboxRequestOptions, + context?: RequestBridgeContext, + ): Promise => { + const signal = context?.signal; const { method, path, query, body, headers } = opts; + throwIfAborted(signal); // Validate request count if (++requestCount > maxRequests) { @@ -252,23 +314,35 @@ export function createRequestBridge( const init: RequestInit = { method: upperMethod, headers: { ...filteredHeaders }, + signal, }; if (body !== undefined && body !== null) { - init.body = JSON.stringify(body); + const bodyJson = JSON.stringify(body); + const bodyBytes = utf8ByteLength(bodyJson); + if (bodyBytes > maxRequestBytes) { + throw new Error( + `Request body too large: ${bodyBytes} bytes exceeds limit of ${maxRequestBytes} bytes`, + ); + } + init.body = bodyJson; (init.headers as Record)["content-type"] = (init.headers as Record)["content-type"] ?? "application/json"; } // Call the host handler - const response = await handler(url.toString(), init); + const response = await abortable( + Promise.resolve(handler(url.toString(), init)), + signal, + ); + throwIfAborted(signal); const responseHeaders = filterResponseHeaders(response.headers, exposedResponseHeaders); // Read response body with streaming size limit to avoid host OOM. // Abort as soon as accumulated bytes exceed the limit. const contentType = response.headers.get("content-type") ?? ""; - const text = await readResponseWithLimit(response, maxResponseBytes); + const text = await readResponseWithLimit(response, maxResponseBytes, signal); let responseBody: unknown; if (contentType.includes("application/json")) { diff --git a/packages/codemode/src/types.ts b/packages/codemode/src/types.ts index 361c3ab..9537cd0 100644 --- a/packages/codemode/src/types.ts +++ b/packages/codemode/src/types.ts @@ -164,6 +164,12 @@ export interface CodeModeOptions { */ maxResponseBytes?: number; + /** + * Maximum request body size in bytes. + * Default: 1MB (1_048_576). + */ + maxRequestBytes?: number; + /** * Allowed headers whitelist. When set, only these headers are forwarded. * Credential, routing override, forwarding, and hop-by-hop headers are diff --git a/packages/codemode/test/llrt-native-executor.test.ts b/packages/codemode/test/llrt-native-executor.test.ts index ed429c8..8bd49e7 100644 --- a/packages/codemode/test/llrt-native-executor.test.ts +++ b/packages/codemode/test/llrt-native-executor.test.ts @@ -5,6 +5,7 @@ import { describeWithLlrtNativeBinding as describe, llrtNativeBindingAvailable, } from "./llrt-native-test-helper.js"; +import type { LlrtHostCallContext } from "@robinbraemer/llrt"; if (llrtNativeBindingAvailable) { executorContract( @@ -65,4 +66,58 @@ describe("LlrtNativeExecutor", () => { expect(result.error).toBeUndefined(); expect(result.result).toEqual({ title: "Petstore", path: "/v1/pets" }); }); + + it("does not expose host call context as a guest argument", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + + const result = await executor.execute( + `async () => countArgs("a", "b")`, + { countArgs: (...args: unknown[]) => args.length }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toBe(2); + }); + + it("aborts in-flight host functions when execution times out", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 20 }); + let sawAbortSignal = false; + let resolveAborted: (() => void) | undefined; + const aborted = new Promise((resolve) => { + resolveAborted = resolve; + }); + + const result = await executor.execute( + `async () => { + await api.request({ path: "/slow" }); + }`, + { + api: { + request: async function ( + this: LlrtHostCallContext, + _request: { path: string }, + ) { + if (!this.signal) { + throw new Error("missing abort signal"); + } + sawAbortSignal = true; + await new Promise((resolve) => { + this.signal.addEventListener("abort", resolve, { once: true }); + }); + resolveAborted?.(); + return { status: 499, body: { aborted: true } }; + }, + }, + }, + ); + + expect(result.error).toContain("Wall-clock timeout exceeded"); + await Promise.race([ + aborted, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("host function was not aborted")), 100); + }), + ]); + expect(sawAbortSignal).toBe(true); + }); }); diff --git a/packages/codemode/test/package-publication.test.ts b/packages/codemode/test/package-publication.test.ts index ce25418..85c91a8 100644 --- a/packages/codemode/test/package-publication.test.ts +++ b/packages/codemode/test/package-publication.test.ts @@ -10,7 +10,7 @@ const publishWorkflowPath = join(root, ".github/workflows/publish.yml"); describe("codemode package publication", () => { it("publishes the LLRT executor release with a compatible optional peer range", () => { expect(codemodePackageJson.version).toMatch(/^(?!0\.2\.0$)\d+\.\d+\.\d+(?:[-+].*)?$/); - expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.1"); + expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.2"); expect(codemodePackageJson.devDependencies["@robinbraemer/llrt"]).toBe("workspace:*"); }); diff --git a/packages/codemode/test/request-bridge.test.ts b/packages/codemode/test/request-bridge.test.ts index 1702045..55c45ae 100644 --- a/packages/codemode/test/request-bridge.test.ts +++ b/packages/codemode/test/request-bridge.test.ts @@ -122,6 +122,20 @@ describe("request limits", () => { bridge({ method: "GET", path: "/req-51" }), ).rejects.toThrow("Request limit exceeded"); }); + + it("rejects request bodies exceeding maxRequestBytes", async () => { + const bridge = createRequestBridge(echoHandler, "http://localhost", { + maxRequestBytes: 64, + }); + + await expect( + bridge({ + method: "POST", + path: "/too-large", + body: { payload: "x".repeat(128) }, + }), + ).rejects.toThrow("Request body too large"); + }); }); describe("header filtering", () => { diff --git a/packages/llrt/native/Cargo.lock b/packages/llrt/native/Cargo.lock index ae0c789..3d462f1 100644 --- a/packages/llrt/native/Cargo.lock +++ b/packages/llrt/native/Cargo.lock @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "llrt_node" -version = "0.1.1" +version = "0.1.2" dependencies = [ "llrt_core", "llrt_json", diff --git a/packages/llrt/native/Cargo.toml b/packages/llrt/native/Cargo.toml index 8e75a98..a15e60c 100644 --- a/packages/llrt/native/Cargo.toml +++ b/packages/llrt/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llrt_node" -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "MIT" diff --git a/packages/llrt/npm/darwin-arm64/package.json b/packages/llrt/npm/darwin-arm64/package.json index d1ba9c8..7a8965b 100644 --- a/packages/llrt/npm/darwin-arm64/package.json +++ b/packages/llrt/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-arm64", - "version": "0.1.1", + "version": "0.1.2", "cpu": [ "arm64" ], diff --git a/packages/llrt/npm/darwin-x64/package.json b/packages/llrt/npm/darwin-x64/package.json index 7b656c4..0f086d9 100644 --- a/packages/llrt/npm/darwin-x64/package.json +++ b/packages/llrt/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-x64", - "version": "0.1.1", + "version": "0.1.2", "cpu": [ "x64" ], diff --git a/packages/llrt/npm/linux-arm64-gnu/package.json b/packages/llrt/npm/linux-arm64-gnu/package.json index 96bde61..d762e8a 100644 --- a/packages/llrt/npm/linux-arm64-gnu/package.json +++ b/packages/llrt/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-arm64-gnu", - "version": "0.1.1", + "version": "0.1.2", "cpu": [ "arm64" ], diff --git a/packages/llrt/npm/linux-x64-gnu/package.json b/packages/llrt/npm/linux-x64-gnu/package.json index 63d2e34..4017988 100644 --- a/packages/llrt/npm/linux-x64-gnu/package.json +++ b/packages/llrt/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-x64-gnu", - "version": "0.1.1", + "version": "0.1.2", "cpu": [ "x64" ], diff --git a/packages/llrt/package.json b/packages/llrt/package.json index 15afb63..ae4d388 100644 --- a/packages/llrt/package.json +++ b/packages/llrt/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt", - "version": "0.1.1", + "version": "0.1.2", "description": "TypeScript-friendly Node bindings for AWS LLRT.", "type": "module", "main": "./dist/index.js", diff --git a/packages/llrt/src/index.ts b/packages/llrt/src/index.ts index 25d35a1..89c84bc 100644 --- a/packages/llrt/src/index.ts +++ b/packages/llrt/src/index.ts @@ -15,6 +15,7 @@ export type { LlrtCallResult, LlrtExecutionErrorCode, LlrtExecutionErrorInfo, + LlrtHostCallContext, LlrtResult, LlrtRuntimeOptions, LlrtStats, diff --git a/packages/llrt/src/runtime.ts b/packages/llrt/src/runtime.ts index 34dd52f..af864e9 100644 --- a/packages/llrt/src/runtime.ts +++ b/packages/llrt/src/runtime.ts @@ -3,6 +3,7 @@ import { loadNativeBinding } from "./native.js"; import type { LlrtCallOptions, LlrtExecutionErrorInfo, + LlrtHostCallContext, LlrtHostFunction, LlrtResult, LlrtRuntimeOptions, @@ -63,20 +64,27 @@ export class LlrtRuntime { try { const binding = loadNativeBinding(); + const abortController = new AbortController(); const hostDispatcher = options.functions - ? createHostDispatcher(options.functions) + ? createHostDispatcher(options.functions, abortController.signal) : undefined; - const result = await binding.callJson( - hostDispatcher ? wrapSourceForHostFunctions(source) : source, - inputJson.value, - { - memoryMb: options.memoryMB ?? this.options.memoryMB, - wallTimeMs: options.wallTimeMs ?? this.options.wallTimeMs, - cpuTimeMs: options.cpuTimeMs ?? this.options.cpuTimeMs, - maxStackBytes: options.maxStackBytes ?? this.options.maxStackBytes, - }, - hostDispatcher, - ); + const result = await (async () => { + try { + return await binding.callJson( + hostDispatcher ? wrapSourceForHostFunctions(source) : source, + inputJson.value, + { + memoryMb: options.memoryMB ?? this.options.memoryMB, + wallTimeMs: options.wallTimeMs ?? this.options.wallTimeMs, + cpuTimeMs: options.cpuTimeMs ?? this.options.cpuTimeMs, + maxStackBytes: options.maxStackBytes ?? this.options.maxStackBytes, + }, + hostDispatcher, + ); + } finally { + abortController.abort(); + } + })(); if (!result.ok) { return { @@ -135,6 +143,7 @@ export class LlrtRuntime { function createHostDispatcher( functions: Record, + signal: AbortSignal, ): (payloadJson: string) => Promise { return async (payloadJson) => { const { name, argsJson } = JSON.parse(payloadJson) as { @@ -147,7 +156,8 @@ function createHostDispatcher( } const args = JSON.parse(argsJson) as unknown[]; - const result = await hostFunction(...args); + const context: LlrtHostCallContext = { signal }; + const result = await hostFunction.apply(context, args); const resultJson = JSON.stringify(result); if (resultJson === undefined) { return "null"; diff --git a/packages/llrt/src/types.ts b/packages/llrt/src/types.ts index a171a28..a75cf13 100644 --- a/packages/llrt/src/types.ts +++ b/packages/llrt/src/types.ts @@ -13,7 +13,14 @@ export interface LlrtCallOptions { functions?: Record; } -export type LlrtHostFunction = (...args: unknown[]) => unknown | Promise; +export interface LlrtHostCallContext { + signal: AbortSignal; +} + +export type LlrtHostFunction = ( + this: LlrtHostCallContext, + ...args: unknown[] +) => unknown | Promise; export interface LlrtStats { wallTimeMs: number;