| @@ -0,0 +1,480 @@ |
| | 1 | import type { |
| | 2 | CodeExecutor, |
| | 3 | ExecuteResult, |
| | 4 | SandboxToolInvoker, |
| | 5 | } from "@executor/codemode-core"; |
| | 6 | import * as Cause from "effect/Cause"; |
| | 7 | import * as Data from "effect/Data"; |
| | 8 | import * as Duration from "effect/Duration"; |
| | 9 | import * as Effect from "effect/Effect"; |
| | 10 | import * as Runtime from "effect/Runtime"; |
| | 11 | import { |
| | 12 | allowAll, |
| | 13 | createInMemoryFileSystem, |
| | 14 | createKernel, |
| | 15 | createNodeRuntime, |
| | 16 | } from "secure-exec"; |
| | 17 | |
| | 18 | export type SecureExecExecutorOptions = { |
| | 19 | timeoutMs?: number; |
| | 20 | memoryLimitMb?: number; |
| | 21 | }; |
| | 22 | |
| | 23 | const formatUnknownMessage = (cause: unknown): string => { |
| | 24 | if (cause instanceof Error) { |
| | 25 | const message = cause.message.trim(); |
| | 26 | return message.length > 0 ? message : cause.name; |
| | 27 | } |
| | 28 | |
| | 29 | if (typeof cause === "string") { |
| | 30 | return cause; |
| | 31 | } |
| | 32 | |
| | 33 | if ( |
| | 34 | typeof cause === "object" && |
| | 35 | cause !== null && |
| | 36 | "message" in cause && |
| | 37 | typeof cause.message === "string" |
| | 38 | ) { |
| | 39 | const message = cause.message.trim(); |
| | 40 | if (message.length > 0) return message; |
| | 41 | } |
| | 42 | |
| | 43 | if (typeof cause === "object" && cause !== null) { |
| | 44 | try { |
| | 45 | return JSON.stringify(cause); |
| | 46 | } catch { |
| | 47 | return String(cause); |
| | 48 | } |
| | 49 | } |
| | 50 | |
| | 51 | return String(cause); |
| | 52 | }; |
| | 53 | |
| | 54 | const formatCauseMessage = (cause: Cause.Cause<unknown>): string => |
| | 55 | formatUnknownMessage(Cause.squash(cause)); |
| | 56 | |
| | 57 | class SecureExecExecutionError extends Data.TaggedError( |
| | 58 | "SecureExecExecutionError", |
| | 59 | )<{ |
| | 60 | readonly operation: string; |
| | 61 | readonly cause: unknown; |
| | 62 | }> { |
| | 63 | override get message() { |
| | 64 | return `${this.operation}: ${formatUnknownMessage(this.cause)}`; |
| | 65 | } |
| | 66 | } |
| | 67 | |
| | 68 | class SecureExecTimeoutError extends Data.TaggedError("SecureExecTimeoutError")<{ |
| | 69 | readonly timeoutMs: number; |
| | 70 | }> {} |
| | 71 | |
| | 72 | const DEFAULT_TIMEOUT_MS = 5 * 60_000; |
| | 73 | const DEFAULT_MEMORY_LIMIT_MB = 128; |
| | 74 | const TEXT_ENCODER = new TextEncoder(); |
| | 75 | |
| | 76 | const serializeJson = (value: unknown): string | undefined => { |
| | 77 | if (typeof value === "undefined") return undefined; |
| | 78 | try { |
| | 79 | return JSON.stringify(value); |
| | 80 | } catch (cause) { |
| | 81 | throw new Error( |
| | 82 | `Value is not JSON serializable: ${cause instanceof Error ? cause.message : String(cause)}`, |
| | 83 | ); |
| | 84 | } |
| | 85 | }; |
| | 86 | |
| | 87 | const IPC_RESULT_PREFIX = "@@executor-result@@"; |
| | 88 | |
| | 89 | const timeoutMessage = (timeoutMs: number): string => |
| | 90 | `Secure-exec execution timed out after ${timeoutMs}ms`; |
| | 91 | |
| | 92 | type ToolSuccessEnvelope = { |
| | 93 | readonly ok: true; |
| | 94 | readonly value?: string; |
| | 95 | }; |
| | 96 | |
| | 97 | type ToolErrorEnvelope = { |
| | 98 | readonly ok: false; |
| | 99 | readonly error: string; |
| | 100 | }; |
| | 101 | |
| | 102 | type ToolEnvelope = ToolSuccessEnvelope | ToolErrorEnvelope; |
| | 103 | |
| | 104 | type RuntimePromiseRunner = <A, E>( |
| | 105 | effect: Effect.Effect<A, E, never>, |
| | 106 | ) => Promise<A>; |
| | 107 | |
| | 108 | type SecureExecKernel = ReturnType<typeof createKernel>; |
| | 109 | |
| | 110 | type KernelProcess = { |
| | 111 | readonly exitCode: number | null; |
| | 112 | wait(): Promise<number>; |
| | 113 | kill(signal?: number): void; |
| | 114 | }; |
| | 115 | |
| | 116 | type ProcessOutcome = |
| | 117 | | { |
| | 118 | readonly _tag: "Exited"; |
| | 119 | readonly exitCode: number; |
| | 120 | } |
| | 121 | | { |
| | 122 | readonly _tag: "TimedOut"; |
| | 123 | }; |
| | 124 | |
| | 125 | type ProcessOutput = { |
| | 126 | readonly stdout: string; |
| | 127 | readonly stderr: string; |
| | 128 | readonly outcome: ProcessOutcome; |
| | 129 | }; |
| | 130 | |
| | 131 | const wrapSync = <A>( |
| | 132 | operation: string, |
| | 133 | fn: () => A, |
| | 134 | ): Effect.Effect<A, SecureExecExecutionError> => |
| | 135 | Effect.try({ |
| | 136 | try: fn, |
| | 137 | catch: (cause) => new SecureExecExecutionError({ operation, cause }), |
| | 138 | }).pipe(Effect.withSpan(`secure_exec.${operation}`)); |
| | 139 | |
| | 140 | const wrap = <A>( |
| | 141 | operation: string, |
| | 142 | fn: () => Promise<A>, |
| | 143 | ): Effect.Effect<A, SecureExecExecutionError> => |
| | 144 | Effect.tryPromise({ |
| | 145 | try: fn, |
| | 146 | catch: (cause) => new SecureExecExecutionError({ operation, cause }), |
| | 147 | }).pipe(Effect.withSpan(`secure_exec.${operation}`)); |
| | 148 | |
| | 149 | const use = <T, A>( |
| | 150 | operation: string, |
| | 151 | client: T, |
| | 152 | fn: (client: T) => Promise<A>, |
| | 153 | ): Effect.Effect<A, SecureExecExecutionError> => |
| | 154 | wrap(operation, () => fn(client)); |
| | 155 | |
| | 156 | const useSync = <T, A>( |
| | 157 | operation: string, |
| | 158 | client: T, |
| | 159 | fn: (client: T) => A, |
| | 160 | ): Effect.Effect<A, SecureExecExecutionError> => |
| | 161 | wrapSync(operation, () => fn(client)); |
| | 162 | |
| | 163 | const encodeToolEnvelope = (envelope: ToolEnvelope): string => { |
| | 164 | try { |
| | 165 | return JSON.stringify(envelope); |
| | 166 | } catch (cause) { |
| | 167 | return JSON.stringify({ |
| | 168 | ok: false, |
| | 169 | error: `Tool envelope serialization failed: ${formatUnknownMessage(cause)}`, |
| | 170 | } satisfies ToolErrorEnvelope); |
| | 171 | } |
| | 172 | }; |
| | 173 | |
| | 174 | const parseToolArgs = ( |
| | 175 | argsJson: unknown, |
| | 176 | ): Effect.Effect<unknown, SecureExecExecutionError> => |
| | 177 | typeof argsJson === "string" |
| | 178 | ? wrapSync("tool.parse_args", () => JSON.parse(argsJson)) |
| | 179 | : Effect.void; |
| | 180 | |
| | 181 | const invokeToolBinding = ( |
| | 182 | toolInvoker: SandboxToolInvoker, |
| | 183 | path: unknown, |
| | 184 | argsJson: unknown, |
| | 185 | ): Effect.Effect<string, never> => |
| | 186 | Effect.gen(function* () { |
| | 187 | const toolPath = String(path); |
| | 188 | const args = yield* parseToolArgs(argsJson); |
| | 189 | |
| | 190 | const envelope = yield* toolInvoker.invoke({ path: toolPath, args }).pipe( |
| | 191 | Effect.flatMap((value) => |
| | 192 | wrapSync("tool.serialize_result", () => ({ |
| | 193 | ok: true as const, |
| | 194 | value: serializeJson(value), |
| | 195 | })), |
| | 196 | ), |
| | 197 | Effect.catchAllCause((cause) => |
| | 198 | Effect.succeed<ToolErrorEnvelope>({ |
| | 199 | ok: false, |
| | 200 | error: formatCauseMessage(cause), |
| | 201 | }), |
| | 202 | ), |
| | 203 | ); |
| | 204 | |
| | 205 | return encodeToolEnvelope(envelope); |
| | 206 | }).pipe( |
| | 207 | Effect.catchAllCause((cause) => |
| | 208 | Effect.succeed( |
| | 209 | encodeToolEnvelope({ |
| | 210 | ok: false, |
| | 211 | error: formatCauseMessage(cause), |
| | 212 | }), |
| | 213 | ), |
| | 214 | ), |
| | 215 | ); |
| | 216 | |
| | 217 | const buildExecutionSource = (code: string): string => { |
| | 218 | const trimmed = code.trim(); |
| | 219 | const looksLikeArrowFunction = |
| | 220 | (trimmed.startsWith("async") || trimmed.startsWith("(")) && |
| | 221 | trimmed.includes("=>"); |
| | 222 | |
| | 223 | const body = looksLikeArrowFunction |
| | 224 | ? [ |
| | 225 | `const __fn = (${trimmed});`, |
| | 226 | "if (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');", |
| | 227 | "return await __fn();", |
| | 228 | ].join("\n") |
| | 229 | : code; |
| | 230 | |
| | 231 | // Tool invocation uses async bindings via SecureExec.bindings.__invokeTool. |
| | 232 | // Console methods use SecureExec.bindings.__log for structured log capture. |
| | 233 | return ` |
| | 234 | "use strict"; |
| | 235 | |
| | 236 | const __invokeTool = SecureExec.bindings.invokeTool; |
| | 237 | const __log = SecureExec.bindings.emitLog; |
| | 238 | |
| | 239 | const __formatLogArg = (value) => { |
| | 240 | if (typeof value === 'string') return value; |
| | 241 | try { return JSON.stringify(value); } catch { return String(value); } |
| | 242 | }; |
| | 243 | const __formatLogLine = (args) => args.map(__formatLogArg).join(' '); |
| | 244 | |
| | 245 | const __makeToolsProxy = (path = []) => new Proxy(() => undefined, { |
| | 246 | get(_target, prop) { |
| | 247 | if (prop === 'then' || typeof prop === 'symbol') return undefined; |
| | 248 | return __makeToolsProxy([...path, String(prop)]); |
| | 249 | }, |
| | 250 | apply(_target, _thisArg, args) { |
| | 251 | const toolPath = path.join('.'); |
| | 252 | if (!toolPath) throw new Error('Tool path missing in invocation'); |
| | 253 | const argsJson = args[0] !== undefined ? JSON.stringify(args[0]) : undefined; |
| | 254 | return Promise.resolve(__invokeTool(toolPath, argsJson)) |
| | 255 | .then((raw) => { |
| | 256 | const envelope = JSON.parse(raw); |
| | 257 | if (!envelope.ok) throw new Error(envelope.error || 'Tool invocation failed'); |
| | 258 | return envelope.value === undefined ? undefined : JSON.parse(envelope.value); |
| | 259 | }); |
| | 260 | }, |
| | 261 | }); |
| | 262 | const tools = __makeToolsProxy(); |
| | 263 | |
| | 264 | const console = { |
| | 265 | log: (...args) => __log('log', __formatLogLine(args)), |
| | 266 | warn: (...args) => __log('warn', __formatLogLine(args)), |
| | 267 | error: (...args) => __log('error', __formatLogLine(args)), |
| | 268 | info: (...args) => __log('info', __formatLogLine(args)), |
| | 269 | debug: (...args) => __log('debug', __formatLogLine(args)), |
| | 270 | }; |
| | 271 | |
| | 272 | (async () => { |
| | 273 | ${body} |
| | 274 | })().then( |
| | 275 | (v) => { |
| | 276 | if (v !== undefined) process.stdout.write(${JSON.stringify(IPC_RESULT_PREFIX)} + JSON.stringify(v) + '\\n'); |
| | 277 | }, |
| | 278 | (e) => { |
| | 279 | const msg = e && typeof e === 'object' ? (e.stack || e.message || String(e)) : String(e); |
| | 280 | process.stderr.write(msg + '\\n'); |
| | 281 | process.exitCode = 1; |
| | 282 | } |
| | 283 | ); |
| | 284 | `.trim(); |
| | 285 | }; |
| | 286 | |
| | 287 | const acquireScopedKernel = ( |
| | 288 | source: string, |
| | 289 | memoryLimitMb: number, |
| | 290 | toolInvoker: SandboxToolInvoker, |
| | 291 | logs: string[], |
| | 292 | runPromise: RuntimePromiseRunner, |
| | 293 | ) => |
| | 294 | Effect.gen(function* () { |
| | 295 | const vfs = createInMemoryFileSystem(); |
| | 296 | yield* use("vfs.write_entry", vfs, (fs) => |
| | 297 | fs.writeFile("/entry.mjs", TEXT_ENCODER.encode(source)), |
| | 298 | ); |
| | 299 | |
| | 300 | const kernel = createKernel({ |
| | 301 | filesystem: vfs, |
| | 302 | permissions: allowAll, |
| | 303 | }); |
| | 304 | |
| | 305 | yield* Effect.addFinalizer(() => |
| | 306 | use("kernel.dispose", kernel, (k) => k.dispose()).pipe(Effect.orDie), |
| | 307 | ); |
| | 308 | |
| | 309 | yield* use("kernel.mount_runtime", kernel, (k) => |
| | 310 | k.mount( |
| | 311 | createNodeRuntime({ |
| | 312 | memoryLimit: memoryLimitMb, |
| | 313 | permissions: allowAll, |
| | 314 | bindings: { |
| | 315 | invokeTool: (path: unknown, argsJson: unknown) => |
| | 316 | runPromise(invokeToolBinding(toolInvoker, path, argsJson)), |
| | 317 | emitLog: (level: unknown, line: unknown) => { |
| | 318 | logs.push(`[${String(level)}] ${String(line)}`); |
| | 319 | }, |
| | 320 | }, |
| | 321 | }), |
| | 322 | ), |
| | 323 | ); |
| | 324 | |
| | 325 | return kernel; |
| | 326 | }); |
| | 327 | |
| | 328 | const waitForProcess = ( |
| | 329 | proc: KernelProcess, |
| | 330 | timeoutMs: number, |
| | 331 | ): Effect.Effect<ProcessOutcome, SecureExecExecutionError> => |
| | 332 | use("process.wait", proc, (p) => p.wait()).pipe( |
| | 333 | Effect.map( |
| | 334 | (exitCode): ProcessOutcome => ({ |
| | 335 | _tag: "Exited", |
| | 336 | exitCode, |
| | 337 | }), |
| | 338 | ), |
| | 339 | Effect.timeoutFail({ |
| | 340 | duration: Duration.millis(timeoutMs), |
| | 341 | onTimeout: () => new SecureExecTimeoutError({ timeoutMs }), |
| | 342 | }), |
| | 343 | Effect.catchTag("SecureExecTimeoutError", () => |
| | 344 | useSync("process.kill_on_timeout", proc, (p) => { |
| | 345 | p.kill(9); |
| | 346 | return { _tag: "TimedOut" as const }; |
| | 347 | }), |
| | 348 | ), |
| | 349 | ); |
| | 350 | |
| | 351 | const runEntryProcess = ( |
| | 352 | kernel: SecureExecKernel, |
| | 353 | timeoutMs: number, |
| | 354 | ): Effect.Effect<ProcessOutput, SecureExecExecutionError> => |
| | 355 | Effect.gen(function* () { |
| | 356 | let stdout = ""; |
| | 357 | let stderr = ""; |
| | 358 | const stdoutDecoder = new TextDecoder(); |
| | 359 | const stderrDecoder = new TextDecoder(); |
| | 360 | |
| | 361 | const proc = yield* useSync("process.spawn", kernel, (k) => |
| | 362 | k.spawn("node", ["/entry.mjs"], { |
| | 363 | onStdout: (data: Uint8Array) => { |
| | 364 | stdout += stdoutDecoder.decode(data, { stream: true }); |
| | 365 | }, |
| | 366 | onStderr: (data: Uint8Array) => { |
| | 367 | stderr += stderrDecoder.decode(data, { stream: true }); |
| | 368 | }, |
| | 369 | }), |
| | 370 | ); |
| | 371 | |
| | 372 | const outcome = yield* waitForProcess(proc, timeoutMs).pipe( |
| | 373 | Effect.ensuring( |
| | 374 | useSync("process.ensure_killed", proc, (p) => { |
| | 375 | if (p.exitCode === null) { |
| | 376 | p.kill(9); |
| | 377 | } |
| | 378 | }).pipe(Effect.orDie), |
| | 379 | ), |
| | 380 | ); |
| | 381 | |
| | 382 | stdout += stdoutDecoder.decode(); |
| | 383 | stderr += stderrDecoder.decode(); |
| | 384 | |
| | 385 | return { |
| | 386 | stdout, |
| | 387 | stderr, |
| | 388 | outcome, |
| | 389 | }; |
| | 390 | }); |
| | 391 | |
| | 392 | const parseResultFromStdout = (stdout: string): unknown => { |
| | 393 | for (const line of stdout.split("\n")) { |
| | 394 | if (!line.startsWith(IPC_RESULT_PREFIX)) continue; |
| | 395 | const payload = line.slice(IPC_RESULT_PREFIX.length); |
| | 396 | try { |
| | 397 | return JSON.parse(payload); |
| | 398 | } catch { |
| | 399 | return payload; |
| | 400 | } |
| | 401 | } |
| | 402 | return null; |
| | 403 | }; |
| | 404 | |
| | 405 | const evaluateInSecureExec = ( |
| | 406 | options: SecureExecExecutorOptions, |
| | 407 | code: string, |
| | 408 | toolInvoker: SandboxToolInvoker, |
| | 409 | ): Effect.Effect<ExecuteResult, never> => { |
| | 410 | const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); |
| | 411 | const memoryLimitMb = options.memoryLimitMb ?? DEFAULT_MEMORY_LIMIT_MB; |
| | 412 | const logs: string[] = []; |
| | 413 | |
| | 414 | return Effect.gen(function* () { |
| | 415 | const runtime = yield* Effect.runtime<never>(); |
| | 416 | const runPromise: RuntimePromiseRunner = Runtime.runPromise(runtime); |
| | 417 | const source = buildExecutionSource(code); |
| | 418 | |
| | 419 | const processOutput = yield* Effect.scoped( |
| | 420 | Effect.gen(function* () { |
| | 421 | const kernel = yield* acquireScopedKernel( |
| | 422 | source, |
| | 423 | memoryLimitMb, |
| | 424 | toolInvoker, |
| | 425 | logs, |
| | 426 | runPromise, |
| | 427 | ); |
| | 428 | return yield* runEntryProcess(kernel, timeoutMs); |
| | 429 | }), |
| | 430 | ); |
| | 431 | |
| | 432 | if (processOutput.outcome._tag === "TimedOut") { |
| | 433 | return { |
| | 434 | result: null, |
| | 435 | error: timeoutMessage(timeoutMs), |
| | 436 | logs, |
| | 437 | } satisfies ExecuteResult; |
| | 438 | } |
| | 439 | |
| | 440 | if (processOutput.outcome.exitCode !== 0) { |
| | 441 | const errorOutput = |
| | 442 | processOutput.stderr.trim() || processOutput.stdout.trim(); |
| | 443 | |
| | 444 | return { |
| | 445 | result: null, |
| | 446 | error: |
| | 447 | errorOutput || |
| | 448 | `Process exited with code ${processOutput.outcome.exitCode}`, |
| | 449 | logs, |
| | 450 | } satisfies ExecuteResult; |
| | 451 | } |
| | 452 | |
| | 453 | return { |
| | 454 | result: parseResultFromStdout(processOutput.stdout), |
| | 455 | logs, |
| | 456 | } satisfies ExecuteResult; |
| | 457 | }).pipe( |
| | 458 | Effect.catchTag("SecureExecExecutionError", (error) => |
| | 459 | Effect.succeed<ExecuteResult>({ |
| | 460 | result: null, |
| | 461 | error: error.message, |
| | 462 | logs, |
| | 463 | }), |
| | 464 | ), |
| | 465 | Effect.catchAllCause((cause) => |
| | 466 | Effect.succeed<ExecuteResult>({ |
| | 467 | result: null, |
| | 468 | error: formatCauseMessage(cause), |
| | 469 | logs, |
| | 470 | }), |
| | 471 | ), |
| | 472 | ); |
| | 473 | }; |
| | 474 | |
| | 475 | export const makeSecureExecExecutor = ( |
| | 476 | options: SecureExecExecutorOptions = {}, |
| | 477 | ): CodeExecutor => ({ |
| | 478 | execute: (code: string, toolInvoker: SandboxToolInvoker) => |
| | 479 | evaluateInSecureExec(options, code, toolInvoker), |
| | 480 | }); |