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
234234 "vitest": "catalog:",
235235 },
236236 },
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+ },
237251 "packages/kernel/runtime-quickjs": {
238252 "name": "@executor/runtime-quickjs",
239253 "version": "1.4.0-beta.0",
@@ -710,6 +724,8 @@ · 470 unmodified lines
710724
711725 "@executor/react": ["@executor/react@workspace:packages/clients/react"],
712726
727+ "@executor/runtime-deno-subprocess": ["@executor/runtime-deno-subprocess@workspace:packages/kernel/runtime-deno-subprocess"],
728+
713729 "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/kernel/runtime-quickjs"],
714730
715731 "@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+
6+const encoder = new TextEncoder();
7+const IPC_PREFIX = "@@executor-ipc@@";
8+
9+const pendingToolCalls = new Map();
10+let started = false;
11+
12+/** @type {string[]} */
13+const logs = [];
14+
15+const writeIpcMessage = (message) => {
16+ const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
17+ Deno.stdout.writeSync(encoder.encode(payload));
18+};
19+
20+const toErrorMessage = (error) => {
21+ if (error instanceof Error) {
22+ return error.stack ?? error.message;
23+ }
24+
25+ return String(error);
26+};
27+
28+const 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+
41+const 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+
61+const 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+
73+const formatLogLine = (args) => args.map(formatLogArg).join(" ");
74+
75+const 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+
93+const 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+
105+const 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+
134+const 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+
150+const 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+
165+const 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+
205+await decodeLines();
packages/kernel/runtime-deno-subprocess/src/deno-worker-process.ts+165−0
@@ -0,0 +1,165 @@
1+import { spawn } from "node:child_process";
2+
3+export 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+
18+export type SpawnDenoWorkerProcessInput = {
19+ executable: string;
20+ scriptPath: string;
21+ permissions?: DenoPermissions;
22+};
23+
24+export 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+
31+export type DenoWorkerProcess = {
32+ stdin: NodeJS.WritableStream;
33+ dispose: () => void;
34+};
35+
36+const normalizeError = (cause: unknown): Error =>
37+ cause instanceof Error ? cause : new Error(String(cause));
38+
39+const 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+
76+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+
5+import type { SandboxToolInvoker } from "@executor/codemode-core";
6+import { isDenoAvailable, makeDenoSubprocessExecutor } from "./index";
7+
8+class UnknownToolError extends Data.TaggedError("UnknownToolError")<{
9+ readonly path: string;
10+}> {}
11+
12+const 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+
24+it("reports unavailable Deno executables", () => {
25+ expect(isDenoAvailable("__executor_missing_deno__")).toBe(false);
26+});
27+
28+it.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+
42+const skipUnlessDeno = isDenoAvailable() ? describe : describe.skip;
43+
44+skipUnlessDeno("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 @@
1+import { spawnSync } from "node:child_process";
2+import { fileURLToPath } from "node:url";
3+
4+import type {
5+ CodeExecutor,
6+ ExecuteResult,
7+ SandboxToolInvoker,
8+} from "@executor/codemode-core";
9+import * as Cause from "effect/Cause";
10+import * as Data from "effect/Data";
11+import * as Deferred from "effect/Deferred";
12+import * as Effect from "effect/Effect";
13+import * as Fiber from "effect/Fiber";
14+import * as Queue from "effect/Queue";
15+import * as Runtime from "effect/Runtime";
16+import * as Schema from "effect/Schema";
17+
18+import {
19+ type DenoPermissions,
20+ spawnDenoWorkerProcess,
21+} from "./deno-worker-process";
22+
23+export type { DenoPermissions };
24+
25+export type DenoSubprocessExecutorOptions = {
26+ denoExecutable?: string;
27+ timeoutMs?: number;
28+ permissions?: DenoPermissions;
29+};
30+
31+const IPC_PREFIX = "@@executor-ipc@@";
32+const DEFAULT_TIMEOUT_MS = 5 * 60_000;
33+
34+// ---------------------------------------------------------------------------
35+// Errors
36+// ---------------------------------------------------------------------------
37+
38+class 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+
60+const WorkerToolCallMessage = Schema.Struct({
61+ type: Schema.Literal("tool_call"),
62+ requestId: Schema.String,
63+ toolPath: Schema.String,
64+ args: Schema.Unknown,
65+});
66+
67+const WorkerCompletedMessage = Schema.Struct({
68+ type: Schema.Literal("completed"),
69+ result: Schema.Unknown,
70+ logs: Schema.optional(Schema.Array(Schema.String)),
71+});
72+
73+const WorkerFailedMessage = Schema.Struct({
74+ type: Schema.Literal("failed"),
75+ error: Schema.String,
76+ logs: Schema.optional(Schema.Array(Schema.String)),
77+});
78+
79+const WorkerMessage = Schema.Union(
80+ WorkerToolCallMessage,
81+ WorkerCompletedMessage,
82+ WorkerFailedMessage,
83+);
84+
85+type WorkerToHostMessage = typeof WorkerMessage.Type;
86+
87+// ---------------------------------------------------------------------------
88+// Deno binary resolution
89+// ---------------------------------------------------------------------------
90+
91+const 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+
112+const 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+
127+let cachedWorkerScriptPath: string | undefined;
128+const workerScriptPath = (): string =>
129+ (cachedWorkerScriptPath ??= resolveWorkerScriptPath());
130+
131+// ---------------------------------------------------------------------------
132+// Helpers
133+// ---------------------------------------------------------------------------
134+
135+type 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+
145+const 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+
155+const 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+
166+const 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+
334+export 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+
344+export 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 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ testTimeout: 30_000,
7+ },
8+});