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
38 +-- live execution manager38 +-- live execution manager
39 v39 v
40 +---------+---------+40 +---------+---------+
41 | SES sandboxed |41 | QuickJS sandboxed |
42 | executor runtime |42 | executor runtime |
43 | runtime-ses |43 | runtime-quickjs |
44 | subprocess worker |44 | default executor |
45 +--------------------+45 +--------------------+
46```46```
47 47
@@ -103,7 +103,7 @@ It contains the runtime layer, persistence integration, and the business logic f · 55 unmodified lines
103 103
104If you want to understand the behavior of the product, this is the most important package.104If you want to understand the behavior of the product, this is the most important package.
105 105
106### `packages/runtime-ses`: code execution runtime106### `packages/runtime-quickjs`: default code execution runtime
107 107
108This package provides the TypeScript execution environment used by the local product.108This package provides the TypeScript execution environment used by the local product.
109 109
@@ -115,7 +115,7 @@ At a high level it receives: · 5 unmodified lines
115 115
116and runs user-authored code against that environment.116and runs user-authored code against that environment.
117 117
118The current runtime executes code in a dedicated SES child process so tool calls stay proxied through the control plane.118The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
119 119
120### Adapter packages120### Adapter packages
121 121
README.md+3−2
@@ -228,7 +228,7 @@ At a high level, every execution follows the same loop: · 227 unmodified lines
228 228
2291. `executor` resolves the current local installation and workspace.2291. `executor` resolves the current local installation and workspace.
2302. It builds a tool catalog from built-in tools plus all connected workspace sources.2302. It builds a tool catalog from built-in tools plus all connected workspace sources.
2313. It runs your TypeScript inside the SES sandbox runtime.2313. It runs your TypeScript inside the QuickJS sandbox runtime by default.
2324. Tool calls are dispatched through `executor` rather than directly from your code.2324. Tool calls are dispatched through `executor` rather than directly from your code.
2335. If a tool needs interaction, the run pauses and records a pending interaction.2335. If a tool needs interaction, the run pauses and records a pending interaction.
2346. Once the interaction is resolved, the execution continues and eventually completes or fails.2346. 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
319- `apps/web`: local React web UI319- `apps/web`: local React web UI
320- `packages/server`: local HTTP server that serves API, MCP, and UI320- `packages/server`: local HTTP server that serves API, MCP, and UI
321- `packages/control-plane`: source management, secrets, persistence, execution, and inspection321- `packages/control-plane`: source management, secrets, persistence, execution, and inspection
322- `packages/runtime-ses`: SES sandbox runtime for TypeScript execution322- `packages/runtime-quickjs`: default QuickJS sandbox runtime for TypeScript execution
323- `packages/runtime-ses`: optional SES sandbox runtime for TypeScript execution
323- `packages/executor-mcp`: MCP bridge for `execute` and `resume`324- `packages/executor-mcp`: MCP bridge for `execute` and `resume`
324- `packages/codemode-*`: core tool abstractions plus MCP and OpenAPI adapters325- `packages/codemode-*`: core tool abstractions plus MCP and OpenAPI adapters
325 326
apps/executor/src/distribution/artifact.ts+18−1
@@ -86,6 +86,21 @@ const resolvePGliteDistDir = (): string => { · 85 unmodified lines
86 return distDir;86 return distDir;
87};87};
88 88
89const 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
89 104
90const createPackageJson = (input: {105const createPackageJson = (input: {
91 packageName: string;106 packageName: string;
@@ -176,7 +191,7 @@ const createPostinstallSource = () => [ · 84 unmodified lines
176 "const denoExecutable = resolveDenoExecutable();",191 "const denoExecutable = resolveDenoExecutable();",
177 "",192 "",
178 "if (!isDenoAvailable(denoExecutable)) {",193 "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`);',
180 "}",195 "}",
181 "",196 "",
182].join("\n");197].join("\n");
@@ -196,6 +211,7 @@ export const buildDistributionPackage = async ( · 13 unmodified lines
196 const pgliteDistDir = resolvePGliteDistDir();211 const pgliteDistDir = resolvePGliteDistDir();
197 const pgliteDataPath = join(pgliteDistDir, "pglite.data");212 const pgliteDataPath = join(pgliteDistDir, "pglite.data");
198 const pgliteWasmPath = join(pgliteDistDir, "pglite.wasm");213 const pgliteWasmPath = join(pgliteDistDir, "pglite.wasm");
214 const quickJsWasmPath = resolveQuickJsWasmPath();
199 const webDistDir = join(repoRoot, "apps/web/dist");215 const webDistDir = join(repoRoot, "apps/web/dist");
200 const readmePath = join(repoRoot, "README.md");216 const readmePath = join(repoRoot, "README.md");
201 const packageName = options.packageName ?? defaults.name;217 const packageName = options.packageName ?? defaults.name;
@@ -235,6 +251,7 @@ export const buildDistributionPackage = async ( · 33 unmodified lines
235 });251 });
236 await cp(pgliteDataPath, join(binDir, "pglite.data"));252 await cp(pgliteDataPath, join(binDir, "pglite.data"));
237 await cp(pgliteWasmPath, join(binDir, "pglite.wasm"));253 await cp(pgliteWasmPath, join(binDir, "pglite.wasm"));
254 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
238 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });255 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
239 await cp(256 await cp(
240 join(repoRoot, "packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),257 join(repoRoot, "packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),
bun.lock+31−0
@@ -167,6 +167,7 @@ · 166 unmodified lines
167 "@executor/codemode-mcp": "workspace:*",167 "@executor/codemode-mcp": "workspace:*",
168 "@executor/codemode-openapi": "workspace:*",168 "@executor/codemode-openapi": "workspace:*",
169 "@executor/runtime-deno-subprocess": "workspace:*",169 "@executor/runtime-deno-subprocess": "workspace:*",
170 "@executor/runtime-quickjs": "workspace:*",
170 "@executor/runtime-ses": "workspace:*",171 "@executor/runtime-ses": "workspace:*",
171 "drizzle-orm": "1.0.0-beta.15-859cf75",172 "drizzle-orm": "1.0.0-beta.15-859cf75",
172 "effect": "catalog:",173 "effect": "catalog:",
@@ -257,6 +258,20 @@ · 84 unmodified lines
257 "vitest": "catalog:",258 "vitest": "catalog:",
258 },259 },
259 },260 },
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 },
260 "packages/runtime-ses": {275 "packages/runtime-ses": {
261 "name": "@executor/runtime-ses",276 "name": "@executor/runtime-ses",
262 "dependencies": {277 "dependencies": {
@@ -509,6 +524,8 @@ · 246 unmodified lines
509 524
510 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],525 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],
511 526
527 "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/runtime-quickjs"],
528
512 "@executor/runtime-ses": ["@executor/runtime-ses@workspace:packages/runtime-ses"],529 "@executor/runtime-ses": ["@executor/runtime-ses@workspace:packages/runtime-ses"],
513 530
514 "@executor/server": ["@executor/server@workspace:packages/server"],531 "@executor/server": ["@executor/server@workspace:packages/server"],
@@ -519,6 +536,16 @@ · 4 unmodified lines
519 536
520 "@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=="],537 "@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=="],
521 538
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
522 "@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=="],549 "@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=="],
523 550
524 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],551 "@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
1415 1442
1416 "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],1443 "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
1417 1444
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
1418 "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],1449 "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
1419 1450
1420 "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=="],1451 "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
28 "@executor/codemode-core": "workspace:*",28 "@executor/codemode-core": "workspace:*",
29 "@executor/codemode-google-discovery": "workspace:*",29 "@executor/codemode-google-discovery": "workspace:*",
30 "@executor/codemode-mcp": "workspace:*",30 "@executor/codemode-mcp": "workspace:*",
31 "@executor/runtime-quickjs": "workspace:*",
31 "@executor/runtime-ses": "workspace:*",32 "@executor/runtime-ses": "workspace:*",
32 "@executor/codemode-openapi": "workspace:*",33 "@executor/codemode-openapi": "workspace:*",
33 "@executor/runtime-deno-subprocess": "workspace:*",34 "@executor/runtime-deno-subprocess": "workspace:*",
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−8
@@ -9,11 +9,7 @@ import { · 8 unmodified lines
9 type ToolPath,9 type ToolPath,
10 type ToolSchemaBundle,10 type ToolSchemaBundle,
11} from "@executor/codemode-core";11} from "@executor/codemode-core";
12import {12import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
13 isDenoAvailable,
14 makeDenoSubprocessExecutor,
15} from "@executor/runtime-deno-subprocess";
16import { makeSesExecutor } from "@executor/runtime-ses";
17import {13import {
18 SqlControlPlaneRowsService,14 SqlControlPlaneRowsService,
19 type SqlControlPlaneRows,15 type SqlControlPlaneRows,
@@ -775,9 +771,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 755 unmodified lines
775 onElicitation,771 onElicitation,
776 });772 });
777 773
778 const executor = isDenoAvailable()774 const executor = makeQuickJsExecutor();
779 ? makeDenoSubprocessExecutor()
780 : makeSesExecutor();
781 775
782 return {776 return {
783 executor,777 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import {
6 allowAllToolInteractions,
7 makeToolInvokerFromTools,
8 toExecutorTool,
9} from "@executor/codemode-core";
10
11import { makeQuickJsExecutor } from "./index";
12
13const numberPairInputSchema = Schema.standardSchemaV1(
14 Schema.Struct({
15 a: Schema.Number,
16 b: Schema.Number,
17 }),
18);
19
20const messageInputSchema = Schema.standardSchemaV1(
21 Schema.Struct({
22 message: Schema.String,
23 }),
24);
25
26const 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
47describe("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 @@
1import type {
2 CodeExecutor,
3 ExecuteResult,
4 ToolInvoker,
5} from "@executor/codemode-core";
6import * as Effect from "effect/Effect";
7import {
8 getQuickJS,
9 shouldInterruptAfterDeadline,
10 type QuickJSContext,
11 type QuickJSDeferredPromise,
12 type QuickJSHandle,
13 type QuickJSRuntime,
14} from "quickjs-emscripten";
15
16export type QuickJsExecutorOptions = {
17 timeoutMs?: number;
18 memoryLimitBytes?: number;
19 maxStackSizeBytes?: number;
20};
21
22const DEFAULT_TIMEOUT_MS = 5 * 60_000;
23const EXECUTION_FILENAME = "executor-quickjs-runtime.js";
24
25const toError = (cause: unknown): Error =>
26 cause instanceof Error ? cause : new Error(String(cause));
27
28const 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
50const 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
64const looksLikeInterruptedError = (message: string): boolean =>
65 /\binterrupted\b/i.test(message);
66
67const timeoutMessage = (timeoutMs: number): string =>
68 `QuickJS execution timed out after ${timeoutMs}ms`;
69
70const 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
81const 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
138const 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
151const 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
164const 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
175const 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
222const 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
242const 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
267const 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
284const 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
395const 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
405export 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}