Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/codemode/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
6 changes: 5 additions & 1 deletion packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createExecutor } from "./executor/auto.js";
import {
createRequestBridge,
type RequestBridgeContext,
type RequestBridgeOptions,
type SandboxRequestOptions,
} from "./request-bridge.js";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
7 changes: 6 additions & 1 deletion packages/codemode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
88 changes: 81 additions & 7 deletions packages/codemode/src/request-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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",
]);
Expand Down Expand Up @@ -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<T>(
operation: Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
if (!signal) return await operation;
throwIfAborted(signal);

let onAbort: (() => void) | undefined;
const aborted = new Promise<T>((_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
Expand All @@ -73,11 +116,13 @@ const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB
async function readResponseWithLimit(
response: Response,
maxBytes: number,
signal?: AbortSignal,
): Promise<string> {
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`,
Expand All @@ -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();
}

Expand Down Expand Up @@ -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<SandboxResponse>) & {
export type RequestBridgeFn = ((
options: SandboxRequestOptions,
context?: RequestBridgeContext,
) => Promise<SandboxResponse>) & {
/** Number of requests made through this bridge instance. */
readonly requestCount: number;
};
Expand All @@ -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()))
Expand All @@ -216,8 +273,13 @@ export function createRequestBridge(

let requestCount = 0;

const bridge = async (opts: SandboxRequestOptions): Promise<SandboxResponse> => {
const bridge = async (
opts: SandboxRequestOptions,
context?: RequestBridgeContext,
): Promise<SandboxResponse> => {
const signal = context?.signal;
const { method, path, query, body, headers } = opts;
throwIfAborted(signal);

// Validate request count
if (++requestCount > maxRequests) {
Expand Down Expand Up @@ -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<string, string>)["content-type"] =
(init.headers as Record<string, string>)["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")) {
Expand Down
6 changes: 6 additions & 0 deletions packages/codemode/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/codemode/test/llrt-native-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
describeWithLlrtNativeBinding as describe,
llrtNativeBindingAvailable,
} from "./llrt-native-test-helper.js";
import type { LlrtHostCallContext } from "@robinbraemer/llrt";

if (llrtNativeBindingAvailable) {
executorContract(
Expand Down Expand Up @@ -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<void>((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<void>((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<never>((_resolve, reject) => {
setTimeout(() => reject(new Error("host function was not aborted")), 100);
}),
]);
expect(sawAbortSignal).toBe(true);
});
});
2 changes: 1 addition & 1 deletion packages/codemode/test/package-publication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:*");
});

Expand Down
14 changes: 14 additions & 0 deletions packages/codemode/test/request-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/llrt/native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/llrt/native/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "llrt_node"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
license = "MIT"

Expand Down
2 changes: 1 addition & 1 deletion packages/llrt/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/llrt-darwin-arm64",
"version": "0.1.1",
"version": "0.1.2",
"cpu": [
"arm64"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/llrt/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/llrt-darwin-x64",
"version": "0.1.1",
"version": "0.1.2",
"cpu": [
"x64"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/llrt/npm/linux-arm64-gnu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/llrt-linux-arm64-gnu",
"version": "0.1.1",
"version": "0.1.2",
"cpu": [
"arm64"
],
Expand Down
Loading
Loading