573 lines · 20.2 KB · TypeScript
RawDownload
import {  recoverExecutionBody,  stripTypeScript,  type CodeExecutor,  type ExecuteOutputItem,  type ExecuteResult,  type SandboxToolInvoker,} from "@executor-js/codemode-core";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import {  executeSealedBundle as runSealedBundleWith,  type SealedBundleError,  type SealedBundleOptions,} from "./sealed-bundle";import {  getQuickJS,  type QuickJSContext,  type QuickJSDeferredPromise,  type QuickJSHandle,  type QuickJSRuntime,  type QuickJSWASMModule,} from "quickjs-emscripten"; export type QuickJsExecutorOptions = {  timeoutMs?: number;  memoryLimitBytes?: number;  maxStackSizeBytes?: number;}; // Allow pre-loading a QuickJS module (e.g. with custom WASM bytes for compiled binaries)let preloadedModule: QuickJSWASMModule | null = null; export const setQuickJSModule = (mod: QuickJSWASMModule) => {  preloadedModule = mod;}; const resolveQuickJS = (): Promise<QuickJSWASMModule> =>  preloadedModule ? Promise.resolve(preloadedModule) : getQuickJS(); /** * Run a sealed, self-contained bundle in a QuickJS sandbox. * * A sibling of `makeQuickJsExecutor` for validation work rather than user * execution — no tool bridge, no code recovery, no metering above it. See * `sealed-bundle.ts` for why that separation matters. The WASM module is * resolved the same way, so a host that called `setQuickJSModule` (Workers must) * is honoured here too. */export const executeSealedBundle = (  options: SealedBundleOptions,): Effect.Effect<string, SealedBundleError> => runSealedBundleWith(options, resolveQuickJS); export { SealedBundleError, type SealedBundleOptions } from "./sealed-bundle"; class QuickJsExecutionError extends Data.TaggedError("QuickJsExecutionError")<{  readonly message: string;}> {} // Large OpenAPI specs can take longer to parse inside QuickJS, so keep the// default execution budget at five minutes unless a caller opts into less.const DEFAULT_TIMEOUT_MS = 5 * 60_000;const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;const DEFAULT_MAX_STACK_SIZE_BYTES = 1 * 1024 * 1024;const EXECUTION_FILENAME = "executor-quickjs-runtime.js"; const toError = (cause: unknown): Error =>  cause instanceof Error ? cause : new Error(String(cause)); // Defect surfaced to the sandbox when a tool dispatch reaches the// reject path. Two sources reach here://   - `ExecutionToolError` emitted by the execution package — message//     is already either the canonical//     `Internal tool error [<corrId>]` opaque generic (plugin defects//     passed through `makeExecutorToolInvoker`'s catchCause) or an//     intentional public string (validators in engine.ts://     "tools.search expects an object: ...").//   - Anything else — assume infra defect and emit a bare opaque//     generic.// `_tag === "ExecutionToolError"` is the in-band signal that the// message was generated by code we control and is safe to pass// through; we never read `.cause` / `.stack`.const sandboxDefectMessage = (cause: unknown): string => {  if (cause !== null && typeof cause === "object") {    const tagged = cause as { readonly _tag?: unknown; readonly message?: unknown };    // oxlint-disable-next-line executor/no-manual-tag-check, executor/no-unknown-error-message -- boundary: QuickJS reject path receives an already-rendered ExecutionToolError; we pass its in-band public message through and otherwise emit the opaque generic    if (tagged._tag === "ExecutionToolError" && typeof tagged.message === "string") {      return tagged.message;    }  }  return "Internal tool error";}; const toErrorMessage = (cause: unknown): string => {  if (typeof cause === "object" && cause !== null) {    const message =      "message" in cause && typeof cause.message === "string" ? cause.message : undefined;     if (message) {      return message;    }     const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;     if (stack) {      return stack;    }  }   const error = toError(cause);  return error.stack ?? error.message;}; const serializeJson = (value: unknown, label: string): string | undefined => {  if (typeof value === "undefined") {    return undefined;  }   try {    return JSON.stringify(value);  } catch (cause) {    throw new Error(`${label} is not JSON serializable: ${toError(cause).message}`);  }}; const looksLikeInterruptedError = (message: string): boolean => /\binterrupted\b/i.test(message); const timeoutMessage = (timeoutMs: number): string =>  `QuickJS execution timed out after ${timeoutMs}ms`; type DeadlineTracker = {  /** Current absolute deadline, or null while at least one dispatch is in flight. */  readonly deadlineMs: () => number | null;  readonly dispatchStarted: () => void;  readonly dispatchReturned: () => void;}; const makeDeadlineTracker = (timeoutMs: number): DeadlineTracker => {  const start = Date.now();  let inFlight = 0;  let lastReturnedAt = start;   return {    deadlineMs: () => (inFlight > 0 ? null : Math.max(start, lastReturnedAt) + timeoutMs),    dispatchStarted: () => {      inFlight += 1;    },    dispatchReturned: () => {      inFlight -= 1;      lastReturnedAt = Date.now();    },  };}; const shouldInterruptAfterDeadline =  (deadline: DeadlineTracker): (() => boolean) =>  () => {    const deadlineMs = deadline.deadlineMs();    return deadlineMs !== null && Date.now() >= deadlineMs;  }; const normalizeExecutionError = (  cause: unknown,  deadline: DeadlineTracker,  timeoutMs: number,): string => {  const message = toErrorMessage(cause);  const deadlineMs = deadline.deadlineMs();  return deadlineMs !== null && Date.now() >= deadlineMs && looksLikeInterruptedError(message)    ? timeoutMessage(timeoutMs)    : message;}; const buildExecutionSource = (code: string): string => {  // QuickJS evaluates plain JavaScript only; strip any TS type syntax  // first. A parse failure here throws a SyntaxError which the outer  // `Effect.tryPromise` maps to `QuickJsExecutionError` with the  // sucrase-formatted message intact.  const body = stripTypeScript(recoverExecutionBody(code));   return [    '"use strict";',    "const __invokeTool = __executor_invokeTool;",    "const __log = __executor_log;",    "try { delete globalThis.__executor_invokeTool; } catch {}",    "try { delete globalThis.__executor_log; } catch {}",    "const __formatLogArg = (value) => {",    "  if (typeof value === 'string') return value;",    "  try {",    "    return JSON.stringify(value);",    "  } catch {",    "    return String(value);",    "  }",    "};",    "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",    "const __outputs = [];",    "globalThis.__executor_outputs = __outputs;",    "const __formatOutputText = (value) => {",    "  if (typeof value === 'undefined') return 'undefined';",    "  if (value === null) return 'null';",    "  if (typeof value === 'string') return value;",    "  try {",    "    return JSON.stringify(value);",    "  } catch {",    "    return String(value);",    "  }",    "};",    "const __isToolFile = (value) => value && typeof value === 'object' && value._tag === 'ToolFile' && typeof value.mimeType === 'string' && value.encoding === 'base64' && typeof value.data === 'string' && typeof value.byteLength === 'number';",    "const __isMcpTextContentBlock = (value) => value && typeof value === 'object' && value.type === 'text' && typeof value.text === 'string';",    "const __isMcpImageContentBlock = (value) => value && typeof value === 'object' && value.type === 'image' && typeof value.data === 'string' && typeof value.mimeType === 'string';",    "const __isMcpAudioContentBlock = (value) => value && typeof value === 'object' && value.type === 'audio' && typeof value.data === 'string' && typeof value.mimeType === 'string';",    "const __isMcpResourceContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource' && value.resource && typeof value.resource === 'object' && typeof value.resource.uri === 'string' && (typeof value.resource.text === 'string' || typeof value.resource.blob === 'string');",    "const __isMcpResourceLinkContentBlock = (value) => value && typeof value === 'object' && value.type === 'resource_link' && typeof value.uri === 'string' && typeof value.name === 'string';",    "const __isMcpContentBlock = (value) => __isMcpTextContentBlock(value) || __isMcpImageContentBlock(value) || __isMcpAudioContentBlock(value) || __isMcpResourceContentBlock(value) || __isMcpResourceLinkContentBlock(value);",    "const emit = (value) => {",    "  if (__isToolFile(value)) {",    "    __outputs.push({ type: 'file', file: value });",    "    return;",    "  }",    "  if (__isMcpContentBlock(value)) {",    "    __outputs.push({ type: 'content', content: value });",    "    return;",    "  }",    "  __outputs.push({ type: 'content', content: { type: 'text', text: __formatOutputText(value) } });",    "};",    "const __toolsEnumerationError = (path) => new Error(",    "  (path.length === 0 ? 'tools' : 'tools.' + path.join('.')) +",    '    \' is a lazy proxy and cannot be enumerated. Use tools.search({ query: "..." }) to find tools, tools.search({ namespace: "<integration>", query: "" }) to list every tool in an integration, or tools.executor.coreTools.connections.list({}) to list saved connections.\',',    ");",    "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",    "  get(_target, prop) {",    "    if (prop === 'then' || typeof prop === 'symbol') {",    "      return undefined;",    "    }",    "    return __makeToolsProxy([...path, String(prop)]);",    "  },",    "  ownKeys() {",    "    throw __toolsEnumerationError(path);",    "  },",    "  getOwnPropertyDescriptor() {",    "    throw __toolsEnumerationError(path);",    "  },",    "  apply(_target, _thisArg, args) {",    "    const toolPath = path.join('.');",    "    if (!toolPath) {",    "      throw new Error('Tool path missing in invocation');",    "    }",    "    return Promise.resolve(__invokeTool(toolPath, args[0])).then((raw) => raw === undefined ? undefined : JSON.parse(raw));",    "  },",    "});",    "const tools = __makeToolsProxy();",    "const console = {",    "  log: (...args) => __log('log', __formatLogLine(args)),",    "  warn: (...args) => __log('warn', __formatLogLine(args)),",    "  error: (...args) => __log('error', __formatLogLine(args)),",    "  info: (...args) => __log('info', __formatLogLine(args)),",    "  debug: (...args) => __log('debug', __formatLogLine(args)),",    "};",    "const fetch = (..._args) => {",    "  throw new Error('fetch is disabled in QuickJS executor');",    "};",    "(async () => {",    body,    "})()",  ].join("\n");}; const readPropDump = (context: QuickJSContext, handle: QuickJSHandle, key: string): unknown => {  const prop = context.getProp(handle, key);  try {    return context.dump(prop);  } finally {    prop.dispose();  }}; const readOutputItems = (context: QuickJSContext): ExecuteOutputItem[] | undefined => {  const output = readPropDump(context, context.global, "__executor_outputs");  return Array.isArray(output) && output.length > 0 ? (output as ExecuteOutputItem[]) : undefined;}; const readResultState = (  context: QuickJSContext,  handle: QuickJSHandle,): {  settled: boolean;  value: unknown;  error: unknown;} => ({  settled: readPropDump(context, handle, "settled") === true,  value: readPropDump(context, handle, "v"),  error: readPropDump(context, handle, "e"),}); const createLogBridge = (context: QuickJSContext, logs: string[]): QuickJSHandle =>  context.newFunction("__executor_log", (levelHandle, lineHandle) => {    const level = context.getString(levelHandle);    const line = context.getString(lineHandle);    logs.push(`[${level}] ${line}`);    return context.undefined;  }); type RunPromise = <A, E>(effect: Effect.Effect<A, E>) => Promise<A>; const createToolBridge = (  context: QuickJSContext,  toolInvoker: SandboxToolInvoker,  pendingDeferreds: Set<QuickJSDeferredPromise>,  runPromise: RunPromise,  deadline: DeadlineTracker,): QuickJSHandle =>  context.newFunction("__executor_invokeTool", (pathHandle, argsHandle) => {    const path = context.getString(pathHandle);    const args =      argsHandle === undefined || context.typeof(argsHandle) === "undefined"        ? undefined        : context.dump(argsHandle);    const deferred = context.newPromise();    pendingDeferreds.add(deferred);    deferred.settled.finally(() => {      pendingDeferreds.delete(deferred);    });     deadline.dispatchStarted();    void runPromise(toolInvoker.invoke({ path, args })).then(      (value) => {        deadline.dispatchReturned();        if (!deferred.alive) {          return;        }         const serialized = serializeJson(value, `Tool result for ${path}`);        if (typeof serialized === "undefined") {          deferred.resolve();          return;        }         const valueHandle = context.newString(serialized);        deferred.resolve(valueHandle);        valueHandle.dispose();      },      (cause) => {        deadline.dispatchReturned();        if (!deferred.alive) {          return;        }         // The reject path is reserved for true infra defects. The        // upstream tool-invoker has already replaced the message with        // an opaque generic plus a correlation id, but defensively log        // the cause here and emit a stable generic if upstream changes.        const message = sandboxDefectMessage(cause);        try {          console.error("[executor:quickjs] tool dispatch defect", { path, cause });        } catch {          /* ignore logger failures */        }        const errorHandle = context.newError(message);        deferred.reject(errorHandle);        errorHandle.dispose();      },    );     return deferred.handle;  }); const drainJobs = (  context: QuickJSContext,  runtime: QuickJSRuntime,  deadline: DeadlineTracker,  timeoutMs: number,): void => {  while (runtime.hasPendingJob()) {    const deadlineMs = deadline.deadlineMs();    if (deadlineMs !== null && Date.now() >= deadlineMs) {      throw new Error(timeoutMessage(timeoutMs));    }     const pending = runtime.executePendingJobs();    if (pending.error) {      const error = context.dump(pending.error);      pending.error.dispose();      throw toError(error);    }  }}; const waitForDeferreds = async (  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,  deadline: DeadlineTracker,  timeoutMs: number,): Promise<void> => {  const settled = Promise.race([...pendingDeferreds].map((deferred) => deferred.settled));  const deadlineMs = deadline.deadlineMs();  if (deadlineMs === null) {    await settled;    return;  }   const remainingMs = deadlineMs - Date.now();  if (remainingMs <= 0) {    throw new Error(timeoutMessage(timeoutMs));  }   let timer: ReturnType<typeof setTimeout> | undefined;  try {    await Promise.race([      settled,      new Promise<never>((_, reject) => {        timer = setTimeout(() => reject(new Error(timeoutMessage(timeoutMs))), remainingMs);      }),    ]);  } finally {    if (timer !== undefined) {      clearTimeout(timer);    }  }}; const drainAsync = async (  context: QuickJSContext,  runtime: QuickJSRuntime,  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,  deadline: DeadlineTracker,  timeoutMs: number,): Promise<void> => {  drainJobs(context, runtime, deadline, timeoutMs);   while (pendingDeferreds.size > 0) {    await waitForDeferreds(pendingDeferreds, deadline, timeoutMs);    drainJobs(context, runtime, deadline, timeoutMs);  }   drainJobs(context, runtime, deadline, timeoutMs);}; const evaluateInQuickJs = async (  options: QuickJsExecutorOptions,  code: string,  toolInvoker: SandboxToolInvoker,  runPromise: RunPromise,): Promise<ExecuteResult> => {  const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);  const deadline = makeDeadlineTracker(timeoutMs);  const logs: string[] = [];  const pendingDeferreds = new Set<QuickJSDeferredPromise>();  const QuickJS = await resolveQuickJS();  const runtime = QuickJS.newRuntime();   try {    runtime.setMemoryLimit(options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);    runtime.setMaxStackSize(options.maxStackSizeBytes ?? DEFAULT_MAX_STACK_SIZE_BYTES);     runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadline));     const context = runtime.newContext();    try {      const logBridge = createLogBridge(context, logs);      context.setProp(context.global, "__executor_log", logBridge);      logBridge.dispose();       const toolBridge = createToolBridge(        context,        toolInvoker,        pendingDeferreds,        runPromise,        deadline,      );      context.setProp(context.global, "__executor_invokeTool", toolBridge);      toolBridge.dispose();       const evaluated = context.evalCode(buildExecutionSource(code), EXECUTION_FILENAME);      if (evaluated.error) {        const error = context.dump(evaluated.error);        evaluated.error.dispose();        return {          result: null,          error: normalizeExecutionError(error, deadline, timeoutMs),          logs,        } satisfies ExecuteResult;      }       context.setProp(context.global, "__executor_result", evaluated.value);      evaluated.value.dispose();       const stateResult = context.evalCode(        "(function(p){ var s = { v: void 0, e: void 0, settled: false }; var formatError = function(e){ if (e && typeof e === 'object') { var message = typeof e.message === 'string' ? e.message : ''; var stack = typeof e.stack === 'string' ? e.stack : ''; if (message && stack) { return stack.indexOf(message) === -1 ? message + '\\n' + stack : stack; } if (message) return message; if (stack) return stack; } return String(e); }; p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = formatError(e); s.settled = true; }); return s; })(__executor_result)",      );      if (stateResult.error) {        const error = context.dump(stateResult.error);        stateResult.error.dispose();        return {          result: null,          error: normalizeExecutionError(error, deadline, timeoutMs),          logs,        } satisfies ExecuteResult;      }       const stateHandle = stateResult.value;      try {

Lines 1–500 of 573.