History

Use QuickJS as the default executor runtime

Keep the Deno and SES runtimes available while moving default execution to a packaged QuickJS sandbox.

Rhys Sullivan committed 8 months ago

Commit eb8d8101 parent f1c988d10 files · +709 −16Browse files

Unified · Split

10 files
  1. ARCHITECTURE.md +5 −5
  2. README.md +3 −2
  3. apps/executor/src/distribution/artifact.ts +18 −1
  4. bun.lock +31 −0
  5. packages/control-plane/package.json +1 −0
  6. packages/control-plane/src/runtime/workspace-execution-environment.ts +2 −8
  7. packages/runtime-quickjs/package.json +24 −0
  8. packages/runtime-quickjs/src/index.test.ts +200 −0
  9. packages/runtime-quickjs/src/index.ts +410 −0
  10. packages/runtime-quickjs/tsconfig.json +15 −0
ARCHITECTURE.md+5−5
@@ -38,10 +38,10 @@ If `README.md` answers "what is this product and how do I use it?", this file an · 37 unmodified lines
3838 +-- live execution manager
3939 v
4040 +---------+---------+
41− | SES sandboxed |
41+ | QuickJS sandboxed |
4242 | executor runtime |
43− | runtime-ses |
44− | subprocess worker |
43+ | runtime-quickjs |
44+ | default executor |
4545 +--------------------+
4646 ```
4747
@@ -103,7 +103,7 @@ It contains the runtime layer, persistence integration, and the business logic f · 55 unmodified lines
103103
104104 If you want to understand the behavior of the product, this is the most important package.
105105
106−### `packages/runtime-ses`: code execution runtime
106+### `packages/runtime-quickjs`: default code execution runtime
107107
108108 This package provides the TypeScript execution environment used by the local product.
109109
@@ -115,7 +115,7 @@ At a high level it receives: · 5 unmodified lines
115115
116116 and runs user-authored code against that environment.
117117
118−The current runtime executes code in a dedicated SES child process so tool calls stay proxied through the control plane.
118+The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
119119
120120 ### Adapter packages
121121
README.md+3−2
@@ -228,7 +228,7 @@ At a high level, every execution follows the same loop: · 227 unmodified lines
228228
229229 1. `executor` resolves the current local installation and workspace.
230230 2. It builds a tool catalog from built-in tools plus all connected workspace sources.
231−3. It runs your TypeScript inside the SES sandbox runtime.
231+3. It runs your TypeScript inside the QuickJS sandbox runtime by default.
232232 4. Tool calls are dispatched through `executor` rather than directly from your code.
233233 5. If a tool needs interaction, the run pauses and records a pending interaction.
234234 6. Once the interaction is resolved, the execution continues and eventually completes or fails.
@@ -319,7 +319,8 @@ If you are exploring the repo, these are the directories that matter most: · 84 unmodified lines
319319 - `apps/web`: local React web UI
320320 - `packages/server`: local HTTP server that serves API, MCP, and UI
321321 - `packages/control-plane`: source management, secrets, persistence, execution, and inspection
322−- `packages/runtime-ses`: SES sandbox runtime for TypeScript execution
322+- `packages/runtime-quickjs`: default QuickJS sandbox runtime for TypeScript execution
323+- `packages/runtime-ses`: optional SES sandbox runtime for TypeScript execution
323324 - `packages/executor-mcp`: MCP bridge for `execute` and `resume`
324325 - `packages/codemode-*`: core tool abstractions plus MCP and OpenAPI adapters
325326
apps/executor/src/distribution/artifact.ts+18−1
@@ -86,6 +86,21 @@ const resolvePGliteDistDir = (): string => { · 85 unmodified lines
8686 return distDir;
8787 };
8888
89+const resolveQuickJsWasmPath = (): string => {
90+ const requireFromQuickJsRuntime = createRequire(
91+ join(repoRoot, "packages/runtime-quickjs/package.json"),
92+ );
93+ const wasmPath = requireFromQuickJsRuntime.resolve(
94+ "@jitl/quickjs-wasmfile-release-sync/wasm",
95+ );
96+
97+ if (!existsSync(wasmPath)) {
98+ throw new Error(`Unable to locate QuickJS wasm asset at ${wasmPath}`);
99+ }
100+
101+ return wasmPath;
102+};
103+
89104
90105 const createPackageJson = (input: {
91106 packageName: string;
@@ -176,7 +191,7 @@ const createPostinstallSource = () => [ · 84 unmodified lines
176191 "const denoExecutable = resolveDenoExecutable();",
177192 "",
178193 "if (!isDenoAvailable(denoExecutable)) {",
179− ' console.warn(`Deno not found, if you want to use deno for sandboxing install it from ${denoInstallUrl} otherwise JS will execute in SES`);',
194+ ' console.warn(`Deno not found, if you want to use deno for sandboxing install it from ${denoInstallUrl} otherwise JS will execute in QuickJS`);',
180195 "}",
181196 "",
182197 ].join("\n");
@@ -196,6 +211,7 @@ export const buildDistributionPackage = async ( · 13 unmodified lines
196211 const pgliteDistDir = resolvePGliteDistDir();
197212 const pgliteDataPath = join(pgliteDistDir, "pglite.data");
198213 const pgliteWasmPath = join(pgliteDistDir, "pglite.wasm");
214+ const quickJsWasmPath = resolveQuickJsWasmPath();
199215 const webDistDir = join(repoRoot, "apps/web/dist");
200216 const readmePath = join(repoRoot, "README.md");
201217 const packageName = options.packageName ?? defaults.name;
@@ -235,6 +251,7 @@ export const buildDistributionPackage = async ( · 33 unmodified lines
235251 });
236252 await cp(pgliteDataPath, join(binDir, "pglite.data"));
237253 await cp(pgliteWasmPath, join(binDir, "pglite.wasm"));
254+ await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
238255 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
239256 await cp(
240257 join(repoRoot, "packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),
bun.lock+31−0
@@ -167,6 +167,7 @@ · 166 unmodified lines
167167 "@executor/codemode-mcp": "workspace:*",
168168 "@executor/codemode-openapi": "workspace:*",
169169 "@executor/runtime-deno-subprocess": "workspace:*",
170+ "@executor/runtime-quickjs": "workspace:*",
170171 "@executor/runtime-ses": "workspace:*",
171172 "drizzle-orm": "1.0.0-beta.15-859cf75",
172173 "effect": "catalog:",
@@ -257,6 +258,20 @@ · 84 unmodified lines
257258 "vitest": "catalog:",
258259 },
259260 },
261+ "packages/runtime-quickjs": {
262+ "name": "@executor/runtime-quickjs",
263+ "dependencies": {
264+ "@executor/codemode-core": "workspace:*",
265+ "effect": "catalog:",
266+ "quickjs-emscripten": "^0.31.0",
267+ },
268+ "devDependencies": {
269+ "@effect/vitest": "catalog:",
270+ "@types/node": "catalog:",
271+ "bun-types": "catalog:",
272+ "vitest": "catalog:",
273+ },
274+ },
260275 "packages/runtime-ses": {
261276 "name": "@executor/runtime-ses",
262277 "dependencies": {
@@ -509,6 +524,8 @@ · 246 unmodified lines
509524
510525 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],
511526
527+ "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/runtime-quickjs"],
528+
512529 "@executor/runtime-ses": ["@executor/runtime-ses@workspace:packages/runtime-ses"],
513530
514531 "@executor/server": ["@executor/server@workspace:packages/server"],
@@ -519,6 +536,16 @@ · 4 unmodified lines
519536
520537 "@hono/vite-dev-server": ["@hono/vite-dev-server@0.25.0", "", { "dependencies": { "@hono/node-server": "^1.14.2", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-4j5rs7yUTOObU2/yCEHeWbf3C8m95SD9lhdUUE6CJ462Emp9vS94yiza2dsA8+ly5pdaIQW0cd4nhNVcwYL5tA=="],
521538
539+ "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.31.0", "", {}, "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw=="],
540+
541+ "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.31.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w=="],
542+
543+ "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.31.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw=="],
544+
545+ "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.31.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg=="],
546+
547+ "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.31.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw=="],
548+
522549 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
523550
524551 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -1415,6 +1442,10 @@ · 890 unmodified lines
14151442
14161443 "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
14171444
1445+ "quickjs-emscripten": ["quickjs-emscripten@0.31.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "quickjs-emscripten-core": "0.31.0" } }, "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ=="],
1446+
1447+ "quickjs-emscripten-core": ["quickjs-emscripten-core@0.31.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ=="],
1448+
14181449 "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
14191450
14201451 "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
packages/control-plane/package.json+1−0
@@ -28,6 +28,7 @@ · 27 unmodified lines
2828 "@executor/codemode-core": "workspace:*",
2929 "@executor/codemode-google-discovery": "workspace:*",
3030 "@executor/codemode-mcp": "workspace:*",
31+ "@executor/runtime-quickjs": "workspace:*",
3132 "@executor/runtime-ses": "workspace:*",
3233 "@executor/codemode-openapi": "workspace:*",
3334 "@executor/runtime-deno-subprocess": "workspace:*",
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−8
@@ -9,11 +9,7 @@ import { · 8 unmodified lines
99 type ToolPath,
1010 type ToolSchemaBundle,
1111 } from "@executor/codemode-core";
12−import {
13− isDenoAvailable,
14− makeDenoSubprocessExecutor,
15−} from "@executor/runtime-deno-subprocess";
16−import { makeSesExecutor } from "@executor/runtime-ses";
12+import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
1713 import {
1814 SqlControlPlaneRowsService,
1915 type SqlControlPlaneRows,
@@ -775,9 +771,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 755 unmodified lines
775771 onElicitation,
776772 });
777773
778− const executor = isDenoAvailable()
779− ? makeDenoSubprocessExecutor()
780− : makeSesExecutor();
774+ const executor = makeQuickJsExecutor();
781775
782776 return {
783777 executor,
packages/runtime-quickjs/package.json+24−0
@@ -0,0 +1,24 @@
1+{
2+ "name": "@executor/runtime-quickjs",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./src/index.ts"
7+ },
8+ "scripts": {
9+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10+ "test": "vitest run",
11+ "test:watch": "vitest"
12+ },
13+ "dependencies": {
14+ "@executor/codemode-core": "workspace:*",
15+ "effect": "catalog:",
16+ "quickjs-emscripten": "^0.31.0"
17+ },
18+ "devDependencies": {
19+ "@effect/vitest": "catalog:",
20+ "@types/node": "catalog:",
21+ "bun-types": "catalog:",
22+ "vitest": "catalog:"
23+ }
24+}
packages/runtime-quickjs/src/index.test.ts+200−0
@@ -0,0 +1,200 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+
5+import {
6+ allowAllToolInteractions,
7+ makeToolInvokerFromTools,
8+ toExecutorTool,
9+} from "@executor/codemode-core";
10+
11+import { makeQuickJsExecutor } from "./index";
12+
13+const numberPairInputSchema = Schema.standardSchemaV1(
14+ Schema.Struct({
15+ a: Schema.Number,
16+ b: Schema.Number,
17+ }),
18+);
19+
20+const messageInputSchema = Schema.standardSchemaV1(
21+ Schema.Struct({
22+ message: Schema.String,
23+ }),
24+);
25+
26+const tools = {
27+ "math.add": {
28+ description: "Add two numbers",
29+ inputSchema: numberPairInputSchema,
30+ execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
31+ },
32+ "notifications.send": toExecutorTool({
33+ tool: {
34+ description: "Send a message",
35+ inputSchema: messageInputSchema,
36+ execute: ({ message }: { message: string }) => ({
37+ delivered: true,
38+ message,
39+ }),
40+ },
41+ metadata: {
42+ interaction: "required",
43+ },
44+ }),
45+};
46+
47+describe("runtime-quickjs", () => {
48+ it.effect("executes simple code and returns result", () =>
49+ Effect.gen(function* () {
50+ const executor = makeQuickJsExecutor();
51+ const toolInvoker = makeToolInvokerFromTools({ tools });
52+
53+ const output = yield* executor.execute(
54+ "return 1 + 2;",
55+ toolInvoker,
56+ );
57+
58+ expect(output.result).toBe(3);
59+ expect(output.error).toBeUndefined();
60+ }),
61+ );
62+
63+ it.effect("executes code with tool calls", () =>
64+ Effect.gen(function* () {
65+ const executor = makeQuickJsExecutor();
66+ const toolInvoker = makeToolInvokerFromTools({
67+ tools,
68+ onToolInteraction: allowAllToolInteractions,
69+ });
70+
71+ const output = yield* executor.execute(
72+ [
73+ "const math = await tools.math.add({ a: 19, b: 23 });",
74+ "await tools.notifications.send({ message: `sum is ${math.sum}` });",
75+ "return math;",
76+ ].join("\n"),
77+ toolInvoker,
78+ );
79+
80+ expect(output.result).toEqual({ sum: 42 });
81+ expect(output.error).toBeUndefined();
82+ }),
83+ );
84+
85+ it.effect("captures console output in logs", () =>
86+ Effect.gen(function* () {
87+ const executor = makeQuickJsExecutor();
88+ const toolInvoker = makeToolInvokerFromTools({ tools });
89+
90+ const output = yield* executor.execute(
91+ [
92+ 'console.log("hello from quickjs");',
93+ 'console.warn("a warning");',
94+ 'console.error("an error");',
95+ "return 42;",
96+ ].join("\n"),
97+ toolInvoker,
98+ );
99+
100+ expect(output.result).toBe(42);
101+ expect(output.logs).toContain("[log] hello from quickjs");
102+ expect(output.logs).toContain("[warn] a warning");
103+ expect(output.logs).toContain("[error] an error");
104+ }),
105+ );
106+
107+ it.effect("reports execution errors without crashing", () =>
108+ Effect.gen(function* () {
109+ const executor = makeQuickJsExecutor();
110+ const toolInvoker = makeToolInvokerFromTools({ tools });
111+
112+ const output = yield* executor.execute(
113+ 'throw new Error("boom");',
114+ toolInvoker,
115+ );
116+
117+ expect(output.result).toBeNull();
118+ expect(output.error).toContain("boom");
119+ }),
120+ );
121+
122+ it.effect("handles tool call errors gracefully", () =>
123+ Effect.gen(function* () {
124+ const failingTools = {
125+ "broken.thing": {
126+ description: "Always fails",
127+ inputSchema: Schema.standardSchemaV1(Schema.Struct({})),
128+ execute: () => {
129+ throw new Error("tool is broken");
130+ },
131+ },
132+ };
133+
134+ const executor = makeQuickJsExecutor();
135+ const toolInvoker = makeToolInvokerFromTools({
136+ tools: failingTools,
137+ });
138+
139+ const output = yield* executor.execute(
140+ "return await tools.broken.thing({});",
141+ toolInvoker,
142+ );
143+
144+ expect(output.result).toBeNull();
145+ expect(output.error).toContain("tool is broken");
146+ }),
147+ );
148+
149+ it.effect("respects timeout", () =>
150+ Effect.gen(function* () {
151+ const executor = makeQuickJsExecutor({
152+ timeoutMs: 250,
153+ });
154+ const toolInvoker = makeToolInvokerFromTools({ tools });
155+
156+ const output = yield* executor.execute(
157+ "while (true) {}",
158+ toolInvoker,
159+ );
160+
161+ expect(output.result).toBeNull();
162+ expect(output.error).toContain("timed out");
163+ }),
164+ );
165+
166+ it.effect("network access is denied by default", () =>
167+ Effect.gen(function* () {
168+ const executor = makeQuickJsExecutor();
169+ const toolInvoker = makeToolInvokerFromTools({ tools });
170+
171+ const output = yield* executor.execute(
172+ 'await fetch("https://example.com"); return 1;',
173+ toolInvoker,
174+ );
175+
176+ expect(output.result).toBeNull();
177+ expect(output.error).toContain("fetch is disabled in QuickJS executor");
178+ }),
179+ );
180+
181+ it.effect("multiple sequential tool calls work correctly", () =>
182+ Effect.gen(function* () {
183+ const executor = makeQuickJsExecutor();
184+ const toolInvoker = makeToolInvokerFromTools({ tools });
185+
186+ const output = yield* executor.execute(
187+ [
188+ "const r1 = await tools.math.add({ a: 1, b: 2 });",
189+ "const r2 = await tools.math.add({ a: r1.sum, b: 10 });",
190+ "const r3 = await tools.math.add({ a: r2.sum, b: 100 });",
191+ "return r3;",
192+ ].join("\n"),
193+ toolInvoker,
194+ );
195+
196+ expect(output.result).toEqual({ sum: 113 });
197+ expect(output.error).toBeUndefined();
198+ }),
199+ );
200+});
packages/runtime-quickjs/src/index.ts+410−0
@@ -0,0 +1,410 @@
1+import type {
2+ CodeExecutor,
3+ ExecuteResult,
4+ ToolInvoker,
5+} from "@executor/codemode-core";
6+import * as Effect from "effect/Effect";
7+import {
8+ getQuickJS,
9+ shouldInterruptAfterDeadline,
10+ type QuickJSContext,
11+ type QuickJSDeferredPromise,
12+ type QuickJSHandle,
13+ type QuickJSRuntime,
14+} from "quickjs-emscripten";
15+
16+export type QuickJsExecutorOptions = {
17+ timeoutMs?: number;
18+ memoryLimitBytes?: number;
19+ maxStackSizeBytes?: number;
20+};
21+
22+const DEFAULT_TIMEOUT_MS = 5 * 60_000;
23+const EXECUTION_FILENAME = "executor-quickjs-runtime.js";
24+
25+const toError = (cause: unknown): Error =>
26+ cause instanceof Error ? cause : new Error(String(cause));
27+
28+const toErrorMessage = (cause: unknown): string => {
29+ if (typeof cause === "object" && cause !== null) {
30+ const stack = "stack" in cause && typeof cause.stack === "string"
31+ ? cause.stack
32+ : undefined;
33+ const message = "message" in cause && typeof cause.message === "string"
34+ ? cause.message
35+ : undefined;
36+
37+ if (stack) {
38+ return stack;
39+ }
40+
41+ if (message) {
42+ return message;
43+ }
44+ }
45+
46+ const error = toError(cause);
47+ return error.stack ?? error.message;
48+};
49+
50+const serializeJson = (value: unknown, label: string): string | undefined => {
51+ if (typeof value === "undefined") {
52+ return undefined;
53+ }
54+
55+ try {
56+ return JSON.stringify(value);
57+ } catch (cause) {
58+ throw new Error(
59+ `${label} is not JSON serializable: ${toError(cause).message}`,
60+ );
61+ }
62+};
63+
64+const looksLikeInterruptedError = (message: string): boolean =>
65+ /\binterrupted\b/i.test(message);
66+
67+const timeoutMessage = (timeoutMs: number): string =>
68+ `QuickJS execution timed out after ${timeoutMs}ms`;
69+
70+const normalizeExecutionError = (
71+ cause: unknown,
72+ deadlineMs: number,
73+ timeoutMs: number,
74+): string => {
75+ const message = toErrorMessage(cause);
76+ return Date.now() >= deadlineMs && looksLikeInterruptedError(message)
77+ ? timeoutMessage(timeoutMs)
78+ : message;
79+};
80+
81+const buildExecutionSource = (code: string): string => {
82+ const trimmed = code.trim();
83+ const looksLikeArrowFunction =
84+ (trimmed.startsWith("async") || trimmed.startsWith("("))
85+ && trimmed.includes("=>");
86+
87+ const body = looksLikeArrowFunction
88+ ? [
89+ `const __fn = (${trimmed});`,
90+ "if (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');",
91+ "return await __fn();",
92+ ].join("\n")
93+ : code;
94+
95+ return [
96+ '"use strict";',
97+ "const __formatLogArg = (value) => {",
98+ " if (typeof value === 'string') return value;",
99+ " try {",
100+ " return JSON.stringify(value);",
101+ " } catch {",
102+ " return String(value);",
103+ " }",
104+ "};",
105+ "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",
106+ "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",
107+ " get(_target, prop) {",
108+ " if (prop === 'then' || typeof prop === 'symbol') {",
109+ " return undefined;",
110+ " }",
111+ " return __makeToolsProxy([...path, String(prop)]);",
112+ " },",
113+ " apply(_target, _thisArg, args) {",
114+ " const toolPath = path.join('.');",
115+ " if (!toolPath) {",
116+ " throw new Error('Tool path missing in invocation');",
117+ " }",
118+ " return Promise.resolve(__executor_invokeTool(toolPath, args[0])).then((raw) => raw === undefined ? undefined : JSON.parse(raw));",
119+ " },",
120+ "});",
121+ "const tools = __makeToolsProxy();",
122+ "const console = {",
123+ " log: (...args) => __executor_log('log', __formatLogLine(args)),",
124+ " warn: (...args) => __executor_log('warn', __formatLogLine(args)),",
125+ " error: (...args) => __executor_log('error', __formatLogLine(args)),",
126+ " info: (...args) => __executor_log('info', __formatLogLine(args)),",
127+ " debug: (...args) => __executor_log('debug', __formatLogLine(args)),",
128+ "};",
129+ "const fetch = (..._args) => {",
130+ " throw new Error('fetch is disabled in QuickJS executor');",
131+ "};",
132+ "(async () => {",
133+ body,
134+ "})()",
135+ ].join("\n");
136+};
137+
138+const readPropDump = (
139+ context: QuickJSContext,
140+ handle: QuickJSHandle,
141+ key: string,
142+): unknown => {
143+ const prop = context.getProp(handle, key);
144+ try {
145+ return context.dump(prop);
146+ } finally {
147+ prop.dispose();
148+ }
149+};
150+
151+const readResultState = (
152+ context: QuickJSContext,
153+ handle: QuickJSHandle,
154+): {
155+ settled: boolean;
156+ value: unknown;
157+ error: unknown;
158+} => ({
159+ settled: readPropDump(context, handle, "settled") === true,
160+ value: readPropDump(context, handle, "v"),
161+ error: readPropDump(context, handle, "e"),
162+});
163+
164+const createLogBridge = (
165+ context: QuickJSContext,
166+ logs: string[],
167+): QuickJSHandle =>
168+ context.newFunction("__executor_log", (levelHandle, lineHandle) => {
169+ const level = context.getString(levelHandle);
170+ const line = context.getString(lineHandle);
171+ logs.push(`[${level}] ${line}`);
172+ return context.undefined;
173+ });
174+
175+const createToolBridge = (
176+ context: QuickJSContext,
177+ toolInvoker: ToolInvoker,
178+ pendingDeferreds: Set<QuickJSDeferredPromise>,
179+): QuickJSHandle =>
180+ context.newFunction("__executor_invokeTool", (pathHandle, argsHandle) => {
181+ const path = context.getString(pathHandle);
182+ const args =
183+ argsHandle === undefined || context.typeof(argsHandle) === "undefined"
184+ ? undefined
185+ : context.dump(argsHandle);
186+ const deferred = context.newPromise();
187+ pendingDeferreds.add(deferred);
188+ deferred.settled.finally(() => {
189+ pendingDeferreds.delete(deferred);
190+ });
191+
192+ void Effect.runPromise(toolInvoker.invoke({ path, args })).then(
193+ (value) => {
194+ if (!deferred.alive) {
195+ return;
196+ }
197+
198+ const serialized = serializeJson(value, `Tool result for ${path}`);
199+ if (typeof serialized === "undefined") {
200+ deferred.resolve();
201+ return;
202+ }
203+
204+ const valueHandle = context.newString(serialized);
205+ deferred.resolve(valueHandle);
206+ valueHandle.dispose();
207+ },
208+ (cause) => {
209+ if (!deferred.alive) {
210+ return;
211+ }
212+
213+ const errorHandle = context.newError(toErrorMessage(cause));
214+ deferred.reject(errorHandle);
215+ errorHandle.dispose();
216+ },
217+ );
218+
219+ return deferred.handle;
220+ });
221+
222+const drainJobs = (
223+ context: QuickJSContext,
224+ runtime: QuickJSRuntime,
225+ deadlineMs: number,
226+ timeoutMs: number,
227+): void => {
228+ while (runtime.hasPendingJob()) {
229+ if (Date.now() >= deadlineMs) {
230+ throw new Error(timeoutMessage(timeoutMs));
231+ }
232+
233+ const pending = runtime.executePendingJobs();
234+ if (pending.error) {
235+ const error = context.dump(pending.error);
236+ pending.error.dispose();
237+ throw toError(error);
238+ }
239+ }
240+};
241+
242+const waitForDeferreds = async (
243+ pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,
244+ deadlineMs: number,
245+ timeoutMs: number,
246+): Promise<void> => {
247+ const remainingMs = deadlineMs - Date.now();
248+ if (remainingMs <= 0) {
249+ throw new Error(timeoutMessage(timeoutMs));
250+ }
251+
252+ let timer: ReturnType<typeof setTimeout> | undefined;
253+ try {
254+ await Promise.race([
255+ Promise.race([...pendingDeferreds].map((deferred) => deferred.settled)),
256+ new Promise<never>((_, reject) => {
257+ timer = setTimeout(() => reject(new Error(timeoutMessage(timeoutMs))), remainingMs);
258+ }),
259+ ]);
260+ } finally {
261+ if (timer !== undefined) {
262+ clearTimeout(timer);
263+ }
264+ }
265+};
266+
267+const drainAsync = async (
268+ context: QuickJSContext,
269+ runtime: QuickJSRuntime,
270+ pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,
271+ deadlineMs: number,
272+ timeoutMs: number,
273+): Promise<void> => {
274+ drainJobs(context, runtime, deadlineMs, timeoutMs);
275+
276+ while (pendingDeferreds.size > 0) {
277+ await waitForDeferreds(pendingDeferreds, deadlineMs, timeoutMs);
278+ drainJobs(context, runtime, deadlineMs, timeoutMs);
279+ }
280+
281+ drainJobs(context, runtime, deadlineMs, timeoutMs);
282+};
283+
284+const evaluateInQuickJs = async (
285+ options: QuickJsExecutorOptions,
286+ code: string,
287+ toolInvoker: ToolInvoker,
288+): Promise<ExecuteResult> => {
289+ const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
290+ const deadlineMs = Date.now() + timeoutMs;
291+ const logs: string[] = [];
292+ const pendingDeferreds = new Set<QuickJSDeferredPromise>();
293+ const QuickJS = await getQuickJS();
294+ const runtime = QuickJS.newRuntime();
295+
296+ try {
297+ if (options.memoryLimitBytes !== undefined) {
298+ runtime.setMemoryLimit(options.memoryLimitBytes);
299+ }
300+
301+ if (options.maxStackSizeBytes !== undefined) {
302+ runtime.setMaxStackSize(options.maxStackSizeBytes);
303+ }
304+
305+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));
306+
307+ const context = runtime.newContext();
308+ try {
309+ const logBridge = createLogBridge(context, logs);
310+ context.setProp(context.global, "__executor_log", logBridge);
311+ logBridge.dispose();
312+
313+ const toolBridge = createToolBridge(context, toolInvoker, pendingDeferreds);
314+ context.setProp(context.global, "__executor_invokeTool", toolBridge);
315+ toolBridge.dispose();
316+
317+ const evaluated = context.evalCode(
318+ buildExecutionSource(code),
319+ EXECUTION_FILENAME,
320+ );
321+ if (evaluated.error) {
322+ const error = context.dump(evaluated.error);
323+ evaluated.error.dispose();
324+ return {
325+ result: null,
326+ error: normalizeExecutionError(error, deadlineMs, timeoutMs),
327+ logs,
328+ } satisfies ExecuteResult;
329+ }
330+
331+ context.setProp(context.global, "__executor_result", evaluated.value);
332+ evaluated.value.dispose();
333+
334+ const stateResult = context.evalCode(
335+ "(function(p){ var s = { v: void 0, e: void 0, settled: false }; var formatError = function(e){ if (e && typeof e === 'object') { var message = typeof e.message === 'string' ? e.message : ''; var stack = typeof e.stack === 'string' ? e.stack : ''; if (message && stack) { return stack.indexOf(message) === -1 ? message + '\\n' + stack : stack; } if (message) return message; if (stack) return stack; } return String(e); }; p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = formatError(e); s.settled = true; }); return s; })(__executor_result)",
336+ );
337+ if (stateResult.error) {
338+ const error = context.dump(stateResult.error);
339+ stateResult.error.dispose();
340+ return {
341+ result: null,
342+ error: normalizeExecutionError(error, deadlineMs, timeoutMs),
343+ logs,
344+ } satisfies ExecuteResult;
345+ }
346+
347+ const stateHandle = stateResult.value;
348+ try {
349+ await drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs);
350+ const state = readResultState(context, stateHandle);
351+ if (!state.settled) {
352+ return {
353+ result: null,
354+ error: timeoutMessage(timeoutMs),
355+ logs,
356+ } satisfies ExecuteResult;
357+ }
358+
359+ if (typeof state.error !== "undefined") {
360+ return {
361+ result: null,
362+ error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),
363+ logs,
364+ } satisfies ExecuteResult;
365+ }
366+
367+ return {
368+ result: state.value,
369+ logs,
370+ } satisfies ExecuteResult;
371+ } finally {
372+ stateHandle.dispose();
373+ }
374+ } finally {
375+ for (const deferred of pendingDeferreds) {
376+ if (deferred.alive) {
377+ deferred.dispose();
378+ }
379+ }
380+
381+ pendingDeferreds.clear();
382+ context.dispose();
383+ }
384+ } catch (cause) {
385+ return {
386+ result: null,
387+ error: normalizeExecutionError(cause, deadlineMs, timeoutMs),
388+ logs,
389+ } satisfies ExecuteResult;
390+ } finally {
391+ runtime.dispose();
392+ }
393+};
394+
395+const runInQuickJs = (
396+ options: QuickJsExecutorOptions,
397+ code: string,
398+ toolInvoker: ToolInvoker,
399+): Effect.Effect<ExecuteResult, Error> =>
400+ Effect.tryPromise({
401+ try: () => evaluateInQuickJs(options, code, toolInvoker),
402+ catch: toError,
403+ });
404+
405+export const makeQuickJsExecutor = (
406+ options: QuickJsExecutorOptions = {},
407+): CodeExecutor => ({
408+ execute: (code: string, toolInvoker: ToolInvoker) =>
409+ runInQuickJs(options, code, toolInvoker),
410+});
packages/runtime-quickjs/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "src/**/*.ts"
14+ ]
15+}