Unified · Split
| @@ -234,6 +234,20 @@ · 233 unmodified lines | ||
| 234 | 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 | 251 | "packages/kernel/runtime-quickjs": { |
| 238 | 252 | "name": "@executor/runtime-quickjs", |
| 239 | 253 | "version": "1.4.0-beta.0", |
| @@ -710,6 +724,8 @@ · 470 unmodified lines | ||
| 710 | 724 | |
| 711 | 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 | 729 | "@executor/runtime-quickjs": ["@executor/runtime-quickjs@workspace:packages/kernel/runtime-quickjs"], |
| 714 | 730 | |
| 715 | 731 | "@executor/sdk": ["@executor/sdk@workspace:packages/core/sdk"], |
| @@ -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 | +} | |
| @@ -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(); | |
| @@ -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 | +}; | |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +}); | |