Showing the changes to packages/kernel/runtime-secure-exec/src (1 of 6 files). Show all
Unified · Split
| @@ -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 | }); | ||