Unified · Split
| @@ -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"], |
| @@ -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 | }); | ||