History

deno runtime

Rhys Sullivan committed 7 months ago

Commit 842c2e11 parent 1a6a9f88 files · +1003 −0Browse files

Unified · Split

8 files
  1. bun.lock +16 −0
  2. packages/kernel/runtime-deno-subprocess/package.json +24 −0
  3. packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +205 −0
  4. packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts +165 −0
  5. packages/kernel/runtime-deno-subprocess/src/index.test.ts +214 −0
  6. packages/kernel/runtime-deno-subprocess/src/index.ts +349 −0
  7. packages/kernel/runtime-deno-subprocess/tsconfig.json +22 −0
  8. packages/kernel/runtime-deno-subprocess/vitest.config.ts +8 −0
bun.lock+16−0
@@ -234,6 +234,20 @@ · 233 unmodified lines
234 "vitest": "catalog:",234 "vitest": "catalog:",
235 },235 },
236 },236 },
237 "packages/kernel/runtime-deno-subprocess": {
238 "name": "@executor/runtime-deno-subprocess",
239 "version": "0.0.0",
240 "dependencies": {
241 "@executor/codemode-core": "workspace:*",
242 "effect": "catalog:",
243 },
244 "devDependencies": {
245 "@effect/vitest": "catalog:",
246 "@types/node": "catalog:",
247 "bun-types": "catalog:",
248 "vitest": "catalog:",
249 },
250 },
237 "packages/kernel/runtime-quickjs": {251 "packages/kernel/runtime-quickjs": {
238 "name": "@executor/runtime-quickjs",252 "name": "@executor/runtime-quickjs",
239 "version": "1.4.0-beta.0",253 "version": "1.4.0-beta.0",
@@ -710,6 +724,8 @@ · 470 unmodified lines
710 724
711 "@executor/react": ["@executor/react@workspace:packages/clients/react"],725 "@executor/react": ["@executor/react@workspace:packages/clients/react"],
712 726
727 "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
728
713 "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/kernel/runtime-quickjs"],729 "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/kernel/runtime-quickjs"],
714 730
715 "@executor/sdk": ["@executor/sdk@workspace:packages/core/sdk"],731 "@executor/sdk": ["@executor/sdk@workspace:packages/core/sdk"],
packages/kernel/runtime-deno-subprocess/package.json+24−0
@@ -0,0 +1,24 @@
1{
2 "name": "@executor/runtime-deno-subprocess",
3 "private": true,
4 "type": "module",
5 "version": "0.0.0",
6 "exports": {
7 ".": "./src/index.ts"
8 },
9 "scripts": {
10 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
11 "test": "vitest run",
12 "test:watch": "vitest"
13 },
14 "dependencies": {
15 "@executor/codemode-core": "workspace:*",
16 "effect": "catalog:"
17 },
18 "devDependencies": {
19 "@effect/vitest": "catalog:",
20 "@types/node": "catalog:",
21 "bun-types": "catalog:",
22 "vitest": "catalog:"
23 }
24}
packages/kernel/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+205−0
@@ -0,0 +1,205 @@
1// @ts-nocheck
2// This script runs inside the Deno subprocess.
3// It communicates with the host process via line-delimited JSON over stdin/stdout.
4// All IPC messages are prefixed with @@executor-ipc@@ to distinguish from user output.
5
6const encoder = new TextEncoder();
7const IPC_PREFIX = "@@executor-ipc@@";
8
9const pendingToolCalls = new Map();
10let started = false;
11
12/** @type {string[]} */
13const logs = [];
14
15const writeIpcMessage = (message) => {
16 const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
17 Deno.stdout.writeSync(encoder.encode(payload));
18};
19
20const toErrorMessage = (error) => {
21 if (error instanceof Error) {
22 return error.stack ?? error.message;
23 }
24
25 return String(error);
26};
27
28const createToolCaller = (toolPath) => (args) =>
29 new Promise((resolve, reject) => {
30 const requestId = crypto.randomUUID();
31 pendingToolCalls.set(requestId, { resolve, reject });
32
33 writeIpcMessage({
34 type: "tool_call",
35 requestId,
36 toolPath,
37 args: args === undefined ? {} : args,
38 });
39 });
40
41const createToolsProxy = (path = []) => {
42 const callable = () => undefined;
43
44 return new Proxy(callable, {
45 get(_target, prop) {
46 if (prop === "then") return undefined;
47 if (typeof prop !== "string") return undefined;
48 return createToolsProxy([...path, prop]);
49 },
50 apply(_target, _thisArg, args) {
51 const toolPath = path.join(".");
52 if (!toolPath) {
53 throw new Error("Tool path missing in invocation");
54 }
55
56 return createToolCaller(toolPath)(args.length > 0 ? args[0] : undefined);
57 },
58 });
59};
60
61const formatLogArg = (value) => {
62 if (typeof value === "string") {
63 return value;
64 }
65
66 try {
67 return JSON.stringify(value);
68 } catch {
69 return String(value);
70 }
71};
72
73const formatLogLine = (args) => args.map(formatLogArg).join(" ");
74
75const sandboxConsole = {
76 log: (...args) => {
77 logs.push(`[log] ${formatLogLine(args)}`);
78 },
79 warn: (...args) => {
80 logs.push(`[warn] ${formatLogLine(args)}`);
81 },
82 error: (...args) => {
83 logs.push(`[error] ${formatLogLine(args)}`);
84 },
85 info: (...args) => {
86 logs.push(`[info] ${formatLogLine(args)}`);
87 },
88 debug: (...args) => {
89 logs.push(`[debug] ${formatLogLine(args)}`);
90 },
91};
92
93const runUserCode = async (code) => {
94 const tools = createToolsProxy();
95
96 const execute = new Function(
97 "tools",
98 "console",
99 `"use strict"; return (async () => {\n${code}\n})();`,
100 );
101
102 return await execute(tools, sandboxConsole);
103};
104
105const handleStart = (message) => {
106 if (started) {
107 writeIpcMessage({
108 type: "failed",
109 error: "start message already received",
110 logs,
111 });
112 return;
113 }
114
115 started = true;
116
117 runUserCode(message.code)
118 .then((result) => {
119 writeIpcMessage({
120 type: "completed",
121 result,
122 logs,
123 });
124 })
125 .catch((error) => {
126 writeIpcMessage({
127 type: "failed",
128 error: toErrorMessage(error),
129 logs,
130 });
131 });
132};
133
134const handleToolResult = (message) => {
135 const pending = pendingToolCalls.get(message.requestId);
136 if (!pending) {
137 return;
138 }
139
140 pendingToolCalls.delete(message.requestId);
141
142 if (message.ok) {
143 pending.resolve(message.value);
144 return;
145 }
146
147 pending.reject(new Error(message.error));
148};
149
150const handleHostMessage = (message) => {
151 if (!message || typeof message !== "object") {
152 return;
153 }
154
155 if (message.type === "start") {
156 handleStart(message);
157 return;
158 }
159
160 if (message.type === "tool_result") {
161 handleToolResult(message);
162 }
163};
164
165const decodeLines = async () => {
166 const reader = Deno.stdin.readable.getReader();
167 const decoder = new TextDecoder();
168 let buffer = "";
169
170 while (true) {
171 const { value, done } = await reader.read();
172 if (done) {
173 break;
174 }
175
176 buffer += decoder.decode(value, { stream: true });
177
178 while (true) {
179 const lineEnd = buffer.indexOf("\n");
180 if (lineEnd === -1) {
181 break;
182 }
183
184 const line = buffer.slice(0, lineEnd).trim();
185 buffer = buffer.slice(lineEnd + 1);
186
187 if (line.length === 0) {
188 continue;
189 }
190
191 try {
192 const message = JSON.parse(line);
193 handleHostMessage(message);
194 } catch (error) {
195 writeIpcMessage({
196 type: "failed",
197 error: `invalid host message: ${toErrorMessage(error)}`,
198 logs,
199 });
200 }
201 }
202 }
203};
204
205await decodeLines();
packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts+165−0
@@ -0,0 +1,165 @@
1import { spawn } from "node:child_process";
2
3export type DenoPermissions = {
4 /** Allow network access. Pass `true` for all, or an array of allowed hosts. */
5 allowNet?: boolean | string[];
6 /** Allow file read access. Pass `true` for all, or an array of allowed paths. */
7 allowRead?: boolean | string[];
8 /** Allow file write access. Pass `true` for all, or an array of allowed paths. */
9 allowWrite?: boolean | string[];
10 /** Allow environment variable access. Pass `true` for all, or an array of allowed vars. */
11 allowEnv?: boolean | string[];
12 /** Allow running subprocesses. Pass `true` for all, or an array of allowed commands. */
13 allowRun?: boolean | string[];
14 /** Allow FFI (foreign function interface). */
15 allowFfi?: boolean | string[];
16};
17
18export type SpawnDenoWorkerProcessInput = {
19 executable: string;
20 scriptPath: string;
21 permissions?: DenoPermissions;
22};
23
24export type DenoWorkerProcessCallbacks = {
25 onStdoutLine: (line: string) => void;
26 onStderr: (chunk: string) => void;
27 onError: (error: Error) => void;
28 onExit: (code: number | null, signal: NodeJS.Signals | null) => void;
29};
30
31export type DenoWorkerProcess = {
32 stdin: NodeJS.WritableStream;
33 dispose: () => void;
34};
35
36const normalizeError = (cause: unknown): Error =>
37 cause instanceof Error ? cause : new Error(String(cause));
38
39const buildPermissionArgs = (permissions?: DenoPermissions): string[] => {
40 if (!permissions) {
41 return [
42 "--deny-net",
43 "--deny-read",
44 "--deny-write",
45 "--deny-env",
46 "--deny-run",
47 "--deny-ffi",
48 ];
49 }
50
51 const args: string[] = [];
52
53 const addPermission = (
54 flag: string,
55 value: boolean | string[] | undefined,
56 ) => {
57 if (value === true) {
58 args.push(`--allow-${flag}`);
59 } else if (Array.isArray(value) && value.length > 0) {
60 args.push(`--allow-${flag}=${value.join(",")}`);
61 } else {
62 args.push(`--deny-${flag}`);
63 }
64 };
65
66 addPermission("net", permissions.allowNet);
67 addPermission("read", permissions.allowRead);
68 addPermission("write", permissions.allowWrite);
69 addPermission("env", permissions.allowEnv);
70 addPermission("run", permissions.allowRun);
71 addPermission("ffi", permissions.allowFfi);
72
73 return args;
74};
75
76export const spawnDenoWorkerProcess = (
77 input: SpawnDenoWorkerProcessInput,
78 callbacks: DenoWorkerProcessCallbacks,
79): DenoWorkerProcess => {
80 const permissionArgs = buildPermissionArgs(input.permissions);
81
82 const child = spawn(
83 input.executable,
84 [
85 "run",
86 "--quiet",
87 "--no-prompt",
88 "--no-check",
89 ...permissionArgs,
90 input.scriptPath,
91 ],
92 {
93 stdio: ["pipe", "pipe", "pipe"],
94 },
95 );
96
97 if (!child.stdin || !child.stdout || !child.stderr) {
98 throw new Error(
99 "Failed to create piped stdio for Deno worker subprocess",
100 );
101 }
102
103 child.stdout.setEncoding("utf8");
104 child.stderr.setEncoding("utf8");
105
106 let stdoutBuffer = "";
107
108 const onStdoutData = (chunk: string) => {
109 stdoutBuffer += chunk;
110
111 while (true) {
112 const newlineIndex = stdoutBuffer.indexOf("\n");
113 if (newlineIndex === -1) {
114 break;
115 }
116
117 const line = stdoutBuffer.slice(0, newlineIndex);
118 stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
119 callbacks.onStdoutLine(line);
120 }
121 };
122
123 const onStderrData = (chunk: string) => {
124 callbacks.onStderr(chunk);
125 };
126
127 const onError = (cause: unknown) => {
128 callbacks.onError(normalizeError(cause));
129 };
130
131 const onExit = (
132 code: number | null,
133 signal: NodeJS.Signals | null,
134 ) => {
135 callbacks.onExit(code, signal);
136 };
137
138 child.stdout.on("data", onStdoutData);
139 child.stderr.on("data", onStderrData);
140 child.on("error", onError);
141 child.on("exit", onExit);
142
143 let disposed = false;
144
145 const dispose = () => {
146 if (disposed) {
147 return;
148 }
149
150 disposed = true;
151 child.stdout!.removeListener("data", onStdoutData);
152 child.stderr!.removeListener("data", onStderrData);
153 child.removeListener("error", onError);
154 child.removeListener("exit", onExit);
155
156 if (!child.killed) {
157 child.kill("SIGKILL");
158 }
159 };
160
161 return {
162 stdin: child.stdin,
163 dispose,
164 };
165};
packages/kernel/runtime-deno-subprocess/src/index.test.ts+214−0
@@ -0,0 +1,214 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4
5import type { SandboxToolInvoker } from "@executor/codemode-core";
6import { isDenoAvailable, makeDenoSubprocessExecutor } from "./index";
7
8class UnknownToolError extends Data.TaggedError("UnknownToolError")<{
9 readonly path: string;
10}> {}
11
12const makeTestInvoker = (
13 handlers: Record<string, (args: unknown) => unknown>,
14): SandboxToolInvoker => ({
15 invoke: ({ path, args }) => {
16 const handler = handlers[path];
17 if (!handler) {
18 return Effect.fail(new UnknownToolError({ path }));
19 }
20 return Effect.try(() => handler(args));
21 },
22});
23
24it("reports unavailable Deno executables", () => {
25 expect(isDenoAvailable("__executor_missing_deno__")).toBe(false);
26});
27
28it.effect("returns an actionable error when Deno is missing", () =>
29 Effect.gen(function* () {
30 const executor = makeDenoSubprocessExecutor({
31 denoExecutable: "__executor_missing_deno__",
32 });
33 const toolInvoker = makeTestInvoker({});
34
35 const output = yield* executor.execute("return 1 + 2;", toolInvoker);
36
37 expect(output.result).toBeNull();
38 expect(output.error).toContain("Install Deno or set DENO_BIN");
39 }),
40);
41
42const skipUnlessDeno = isDenoAvailable() ? describe : describe.skip;
43
44skipUnlessDeno("runtime-deno-subprocess", () => {
45 it.effect("executes simple code and returns result", () =>
46 Effect.gen(function* () {
47 const executor = makeDenoSubprocessExecutor();
48 const toolInvoker = makeTestInvoker({});
49
50 const output = yield* executor.execute("return 1 + 2;", toolInvoker);
51
52 expect(output.result).toBe(3);
53 expect(output.error).toBeUndefined();
54 }),
55 );
56
57 it.effect("executes code with tool calls", () =>
58 Effect.gen(function* () {
59 const executor = makeDenoSubprocessExecutor();
60 const toolInvoker = makeTestInvoker({
61 "math.add": (args) => {
62 const { a, b } = args as { a: number; b: number };
63 return { sum: a + b };
64 },
65 });
66
67 const output = yield* executor.execute(
68 [
69 "const math = await tools.math.add({ a: 19, b: 23 });",
70 "return math;",
71 ].join("\n"),
72 toolInvoker,
73 );
74
75 expect(output.result).toEqual({ sum: 42 });
76 expect(output.error).toBeUndefined();
77 }),
78 );
79
80 it.effect("captures console.log output in logs", () =>
81 Effect.gen(function* () {
82 const executor = makeDenoSubprocessExecutor();
83 const toolInvoker = makeTestInvoker({});
84
85 const output = yield* executor.execute(
86 [
87 'console.log("hello from sandbox");',
88 'console.warn("a warning");',
89 'console.error("an error");',
90 "return 42;",
91 ].join("\n"),
92 toolInvoker,
93 );
94
95 expect(output.result).toBe(42);
96 expect(output.logs).toContain("[log] hello from sandbox");
97 expect(output.logs).toContain("[warn] a warning");
98 expect(output.logs).toContain("[error] an error");
99 }),
100 );
101
102 it.effect("reports execution errors without crashing", () =>
103 Effect.gen(function* () {
104 const executor = makeDenoSubprocessExecutor();
105 const toolInvoker = makeTestInvoker({});
106
107 const output = yield* executor.execute(
108 'throw new Error("boom");',
109 toolInvoker,
110 );
111
112 expect(output.result).toBeNull();
113 expect(output.error).toContain("boom");
114 }),
115 );
116
117 it.effect("handles tool call errors gracefully", () =>
118 Effect.gen(function* () {
119 const executor = makeDenoSubprocessExecutor();
120 const toolInvoker = makeTestInvoker({
121 "broken.thing": () => {
122 throw new Error("tool is broken");
123 },
124 });
125
126 const output = yield* executor.execute(
127 "return await tools.broken.thing({});",
128 toolInvoker,
129 );
130
131 expect(output.result).toBeNull();
132 expect(output.error).toContain("tool is broken");
133 }),
134 );
135
136 it.effect("respects timeout", () =>
137 Effect.gen(function* () {
138 const executor = makeDenoSubprocessExecutor({
139 timeoutMs: 500,
140 });
141 const toolInvoker = makeTestInvoker({});
142
143 const output = yield* executor.execute(
144 "await new Promise(() => {}); return 1;",
145 toolInvoker,
146 );
147
148 expect(output.result).toBeNull();
149 expect(output.error).toContain("timed out");
150 }),
151 );
152
153 it.effect("network access is denied by default", () =>
154 Effect.gen(function* () {
155 const executor = makeDenoSubprocessExecutor();
156 const toolInvoker = makeTestInvoker({});
157
158 const output = yield* executor.execute(
159 'await fetch("https://example.com"); return 1;',
160 toolInvoker,
161 );
162
163 expect(output.result).toBeNull();
164 expect(output.error).toBeDefined();
165 }),
166 );
167
168 it.effect("network access can be allowed via permissions", () =>
169 Effect.gen(function* () {
170 const executor = makeDenoSubprocessExecutor({
171 permissions: {
172 allowNet: true,
173 },
174 });
175 const toolInvoker = makeTestInvoker({});
176
177 const output = yield* executor.execute(
178 [
179 'const res = await fetch("https://example.com");',
180 "return res.status;",
181 ].join("\n"),
182 toolInvoker,
183 );
184
185 expect(output.result).toBe(200);
186 expect(output.error).toBeUndefined();
187 }),
188 );
189
190 it.effect("multiple sequential tool calls work correctly", () =>
191 Effect.gen(function* () {
192 const executor = makeDenoSubprocessExecutor();
193 const toolInvoker = makeTestInvoker({
194 "math.add": (args) => {
195 const { a, b } = args as { a: number; b: number };
196 return { sum: a + b };
197 },
198 });
199
200 const output = yield* executor.execute(
201 [
202 "const r1 = await tools.math.add({ a: 1, b: 2 });",
203 "const r2 = await tools.math.add({ a: r1.sum, b: 10 });",
204 "const r3 = await tools.math.add({ a: r2.sum, b: 100 });",
205 "return r3;",
206 ].join("\n"),
207 toolInvoker,
208 );
209
210 expect(output.result).toEqual({ sum: 113 });
211 expect(output.error).toBeUndefined();
212 }),
213 );
214});
packages/kernel/runtime-deno-subprocess/src/index.ts+349−0
@@ -0,0 +1,349 @@
1import { spawnSync } from "node:child_process";
2import { fileURLToPath } from "node:url";
3
4import type {
5 CodeExecutor,
6 ExecuteResult,
7 SandboxToolInvoker,
8} from "@executor/codemode-core";
9import * as Cause from "effect/Cause";
10import * as Data from "effect/Data";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Queue from "effect/Queue";
15import * as Runtime from "effect/Runtime";
16import * as Schema from "effect/Schema";
17
18import {
19 type DenoPermissions,
20 spawnDenoWorkerProcess,
21} from "./deno-worker-process";
22
23export type { DenoPermissions };
24
25export type DenoSubprocessExecutorOptions = {
26 denoExecutable?: string;
27 timeoutMs?: number;
28 permissions?: DenoPermissions;
29};
30
31const IPC_PREFIX = "@@executor-ipc@@";
32const DEFAULT_TIMEOUT_MS = 5 * 60_000;
33
34// ---------------------------------------------------------------------------
35// Errors
36// ---------------------------------------------------------------------------
37
38class DenoSpawnError extends Data.TaggedError("DenoSpawnError")<{
39 readonly executable: string;
40 readonly reason: unknown;
41}> {
42 override get message() {
43 const code =
44 typeof this.reason === "object" &&
45 this.reason !== null &&
46 "code" in this.reason
47 ? String((this.reason as { code?: unknown }).code)
48 : null;
49
50 return code === "ENOENT"
51 ? `Failed to spawn Deno subprocess: Deno executable "${this.executable}" was not found. Install Deno or set DENO_BIN.`
52 : `Failed to spawn Deno subprocess: ${this.reason instanceof Error ? this.reason.message : String(this.reason)}`;
53 }
54}
55
56// ---------------------------------------------------------------------------
57// IPC schemas
58// ---------------------------------------------------------------------------
59
60const WorkerToolCallMessage = Schema.Struct({
61 type: Schema.Literal("tool_call"),
62 requestId: Schema.String,
63 toolPath: Schema.String,
64 args: Schema.Unknown,
65});
66
67const WorkerCompletedMessage = Schema.Struct({
68 type: Schema.Literal("completed"),
69 result: Schema.Unknown,
70 logs: Schema.optional(Schema.Array(Schema.String)),
71});
72
73const WorkerFailedMessage = Schema.Struct({
74 type: Schema.Literal("failed"),
75 error: Schema.String,
76 logs: Schema.optional(Schema.Array(Schema.String)),
77});
78
79const WorkerMessage = Schema.Union(
80 WorkerToolCallMessage,
81 WorkerCompletedMessage,
82 WorkerFailedMessage,
83);
84
85type WorkerToHostMessage = typeof WorkerMessage.Type;
86
87// ---------------------------------------------------------------------------
88// Deno binary resolution
89// ---------------------------------------------------------------------------
90
91const defaultDenoExecutable = (): string => {
92 const configured = process.env.DENO_BIN?.trim();
93 if (configured) return configured;
94
95 const home = process.env.HOME?.trim();
96 if (home) {
97 const installedPath = `${home}/.deno/bin/deno`;
98 const result = spawnSync(installedPath, ["--version"], {
99 stdio: "ignore",
100 timeout: 5000,
101 });
102 if (result.error === undefined && result.status === 0) return installedPath;
103 }
104
105 return "deno";
106};
107
108// ---------------------------------------------------------------------------
109// Worker script resolution
110// ---------------------------------------------------------------------------
111
112const resolveWorkerScriptPath = (): string => {
113 const moduleUrl = String(import.meta.url);
114 if (moduleUrl.startsWith("/")) return moduleUrl;
115
116 try {
117 const workerUrl = new URL("./deno-subprocess-worker.mjs", moduleUrl);
118 if (workerUrl.protocol === "file:") return fileURLToPath(workerUrl);
119 return workerUrl.pathname.length > 0
120 ? workerUrl.pathname
121 : workerUrl.toString();
122 } catch {
123 return moduleUrl;
124 }
125};
126
127let cachedWorkerScriptPath: string | undefined;
128const workerScriptPath = (): string =>
129 (cachedWorkerScriptPath ??= resolveWorkerScriptPath());
130
131// ---------------------------------------------------------------------------
132// Helpers
133// ---------------------------------------------------------------------------
134
135type HostToWorkerMessage =
136 | { type: "start"; code: string }
137 | {
138 type: "tool_result";
139 requestId: string;
140 ok: boolean;
141 value?: unknown;
142 error?: string;
143 };
144
145const causeMessage = (cause: Cause.Cause<unknown>): string => {
146 const squashed = Cause.squash(cause);
147 if (squashed instanceof Error) {
148 return squashed.cause instanceof Error
149 ? squashed.cause.message
150 : squashed.message;
151 }
152 return String(squashed);
153};
154
155const writeMessage = (
156 stdin: NodeJS.WritableStream,
157 message: HostToWorkerMessage,
158): void => {
159 stdin.write(`${JSON.stringify(message)}\n`);
160};
161
162// ---------------------------------------------------------------------------
163// Core execution
164// ---------------------------------------------------------------------------
165
166const executeInDeno = (
167 code: string,
168 toolInvoker: SandboxToolInvoker,
169 options: DenoSubprocessExecutorOptions,
170): Effect.Effect<ExecuteResult, never> => {
171 const denoExecutable = options.denoExecutable ?? defaultDenoExecutable();
172 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
173
174 return Effect.gen(function* () {
175 const rt = yield* Effect.runtime<never>();
176 const runSync = Runtime.runSync(rt);
177
178 // Queue bridges Node callbacks → Effect fibers
179 const messages = yield* Queue.unbounded<WorkerToHostMessage>();
180
181 // Terminal result — resolved once by completed/failed/error/exit
182 const result = yield* Deferred.make<ExecuteResult>();
183
184 const completeWith = (value: ExecuteResult): Effect.Effect<boolean> =>
185 Deferred.complete(result, Effect.succeed(value));
186
187 // -----------------------------------------------------------------------
188 // Spawn subprocess — callbacks only do simple synchronous pushes
189 // -----------------------------------------------------------------------
190
191 const worker = yield* Effect.try({
192 try: () =>
193 spawnDenoWorkerProcess(
194 {
195 executable: denoExecutable,
196 scriptPath: workerScriptPath(),
197 permissions: options.permissions,
198 },
199 {
200 onStdoutLine: (rawLine) => {
201 const line = rawLine.trim();
202 if (!line.startsWith(IPC_PREFIX)) return;
203
204 const decoded = Schema.decodeUnknownOption(WorkerMessage)(
205 JSON.parse(line.slice(IPC_PREFIX.length)),
206 );
207 if (decoded._tag === "Some") {
208 runSync(Queue.offer(messages, decoded.value));
209 }
210 },
211 onStderr: () => {},
212 onError: (cause) => {
213 runSync(
214 completeWith({
215 result: null,
216 error: new DenoSpawnError({
217 executable: denoExecutable,
218 reason: cause,
219 }).message,
220 }),
221 );
222 },
223 onExit: (exitCode, signal) => {
224 runSync(
225 completeWith({
226 result: null,
227 error: `Deno subprocess exited unexpectedly (code=${String(exitCode)} signal=${String(signal)})`,
228 }),
229 );
230 },
231 },
232 ),
233 catch: (cause) =>
234 new DenoSpawnError({ executable: denoExecutable, reason: cause }),
235 });
236
237 // Send code to the subprocess
238 writeMessage(worker.stdin, { type: "start", code });
239
240 // Set up timeout — kills process and completes the deferred
241 const timer = setTimeout(() => {
242 worker.dispose();
243 runSync(
244 completeWith({
245 result: null,
246 error: `Deno subprocess execution timed out after ${timeoutMs}ms`,
247 }),
248 );
249 }, timeoutMs);
250
251 // -----------------------------------------------------------------------
252 // Message processing fiber — tool calls happen here, inside Effect
253 // -----------------------------------------------------------------------
254
255 const processFiber = yield* Effect.fork(
256 Effect.gen(function* () {
257 while (true) {
258 const msg = yield* Queue.take(messages);
259
260 switch (msg.type) {
261 case "tool_call": {
262 const toolResult = yield* toolInvoker
263 .invoke({ path: msg.toolPath, args: msg.args })
264 .pipe(
265 Effect.map(
266 (value): HostToWorkerMessage => ({
267 type: "tool_result",
268 requestId: msg.requestId,
269 ok: true,
270 value,
271 }),
272 ),
273 Effect.catchAllCause((cause) =>
274 Effect.succeed<HostToWorkerMessage>({
275 type: "tool_result",
276 requestId: msg.requestId,
277 ok: false,
278 error: causeMessage(cause),
279 }),
280 ),
281 );
282
283 writeMessage(worker.stdin, toolResult);
284 break;
285 }
286
287 case "completed": {
288 yield* completeWith({
289 result: msg.result,
290 logs: msg.logs as string[] | undefined,
291 });
292 return;
293 }
294
295 case "failed": {
296 yield* completeWith({
297 result: null,
298 error: msg.error,
299 logs: msg.logs as string[] | undefined,
300 });
301 return;
302 }
303 }
304 }
305 }),
306 );
307
308 // -----------------------------------------------------------------------
309 // Await result with timeout, then clean up
310 // -----------------------------------------------------------------------
311
312 const output = yield* Deferred.await(result).pipe(
313 Effect.ensuring(
314 Effect.gen(function* () {
315 clearTimeout(timer);
316 yield* Fiber.interrupt(processFiber);
317 worker.dispose();
318 }),
319 ),
320 );
321
322 return output;
323 }).pipe(
324 Effect.catchTag("DenoSpawnError", (e) =>
325 Effect.succeed<ExecuteResult>({ result: null, error: e.message }),
326 ),
327 );
328};
329
330// ---------------------------------------------------------------------------
331// Public API
332// ---------------------------------------------------------------------------
333
334export const isDenoAvailable = (
335 executable: string = defaultDenoExecutable(),
336): boolean => {
337 const result = spawnSync(executable, ["--version"], {
338 stdio: "ignore",
339 timeout: 5000,
340 });
341 return result.error === undefined && result.status === 0;
342};
343
344export const makeDenoSubprocessExecutor = (
345 options: DenoSubprocessExecutorOptions = {},
346): CodeExecutor => ({
347 execute: (code: string, toolInvoker: SandboxToolInvoker) =>
348 executeInDeno(code, toolInvoker, options),
349});
packages/kernel/runtime-deno-subprocess/tsconfig.json+22−0
@@ -0,0 +1,22 @@
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 "noUnusedLocals": true,
12 "noImplicitOverride": true,
13 "plugins": [
14 {
15 "name": "@effect/language-service"
16 }
17 ]
18 },
19 "include": [
20 "src/**/*.ts"
21 ]
22}
packages/kernel/runtime-deno-subprocess/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 testTimeout: 30_000,
7 },
8});