209 lines · 9.2 KB · TypeScript
RawDownload
/** * Run a self-contained JS bundle in a QuickJS sandbox and read one string back. * * ## Why this exists next to `makeQuickJsExecutor` * * `CodeExecutor` is the *user execution* contract: it recovers a body from * model-written code, injects `tools`/`console`/`emit`, dispatches tool calls * back to the host, and — on cloud — is wrapped in metering, quota and rate * limiting, because every call through it is something a user asked for and is * billed for. * * A validation render is none of those things. It has no tools (every call must * hang, by design), no logs anyone reads, no result but a verdict, and it is * not a user execution — refusing it for quota, or billing someone for typing, * would both be wrong. It also must not pay `CodeExecutor`'s preprocessing: * `recoverExecutionBody` + `stripTypeScript` spend ~600ms parsing a * megabyte-scale prebuilt bundle that is already plain ES2022 with nothing to * strip. * * So this is a deliberately smaller primitive: no tool bridge, no code * recovery, no transform. A sealed bundle goes in, one string comes out. It * sits BELOW every gating layer by construction, because it never touches the * `Executor`, the engine, or anything that knows what an organization is. * * ## Why QuickJS, on every host * * QuickJS is a WASM interpreter. The code it runs is interpreted by the engine * compiled into the WASM module, never by the host's own JS engine — so * `new Function` inside the sandbox is QuickJS building a QuickJS function, not * V8 compiling a string. That is why this works unchanged on workerd, whose ban * on runtime codegen is a ban on *V8* codegen, and it is the same reason * executor already runs model `execute` code through QuickJS on Cloudflare. * * ## What the sandbox cannot reach * * A fresh runtime and context per call, disposed after. The context starts with * only what QuickJS itself defines: no `process`, no `fetch`, no `require`, no * host object of any kind is ever installed on the global. Memory, stack depth * and wall-clock are all capped, and the interrupt handler preempts a * synchronous infinite loop — which the host process cannot do to itself. */ import * as Data from "effect/Data";import * as Effect from "effect/Effect";import { getQuickJS, type QuickJSContext, type QuickJSWASMModule } from "quickjs-emscripten"; /** Raised when the sandbox could not produce an answer — never when the code *  the sandbox ran merely failed. The caller decides what to do with it; every *  current caller fails open. */export class SealedBundleError extends Data.TaggedError("SealedBundleError")<{  readonly message: string;}> {} export type SealedBundleOptions = {  /** The program. Must be self-contained: no imports, no host globals. */  readonly bundle: string;  /**   * An expression evaluated after the bundle, whose value is awaited and must   * resolve to a string. Kept an expression rather than a function name so the   * caller decides how to pass its own arguments — always by embedding them as   * JSON literals, never by installing them as globals.   */  readonly call: string;  readonly timeoutMs?: number;  readonly memoryLimitBytes?: number;  readonly maxStackSizeBytes?: number;}; const DEFAULT_TIMEOUT_MS = 10_000;// A React server render of the component barrel peaks well under this; the// spike ran comfortably in 32 MiB. Headroom, not a target.const DEFAULT_MEMORY_LIMIT_BYTES = 128 * 1024 * 1024;const DEFAULT_MAX_STACK_SIZE_BYTES = 2 * 1024 * 1024; const BUNDLE_FILENAME = "executor-sealed-bundle.js";const CALL_FILENAME = "executor-sealed-call.js"; /** Set by `setQuickJSModule` in this package's main entry. Read lazily so a *  host that preloads a custom WASM module (Workers must) is honoured. */type ModuleResolver = () => Promise<QuickJSWASMModule>; const errorMessage = (cause: unknown): string =>  cause instanceof Error ? cause.message : String(cause); /** Evaluate one chunk, converting QuickJS's error handle into a thrown Error *  whose message is the sandbox's own. */const evaluate = (context: QuickJSContext, source: string, filename: string): void => {  const result = context.evalCode(source, filename);  if (result.error) {    const dumped: unknown = context.dump(result.error);    result.error.dispose();    // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: QuickJS reports failure as a handle; this is where it becomes a JS error    throw new Error(      typeof dumped === "object" && dumped !== null && "message" in dumped        ? String((dumped as { message: unknown }).message)        : String(dumped),    );  }  result.value.dispose();}; const runSealedBundle = async (  options: SealedBundleOptions,  resolveModule: ModuleResolver,): Promise<string> => {  const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);  const QuickJS = await resolveModule();  const runtime = QuickJS.newRuntime();   try {    runtime.setMemoryLimit(options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES);    runtime.setMaxStackSize(options.maxStackSizeBytes ?? DEFAULT_MAX_STACK_SIZE_BYTES);     // The deadline is absolute and never suspended: unlike `execute`, nothing    // here waits on a host round-trip, so any time spent is time the sandbox    // spent computing. This is what preempts `while (true)` in a component.    const deadline = Date.now() + timeoutMs;    runtime.setInterruptHandler(() => Date.now() >= deadline);     const context = runtime.newContext();    try {      evaluate(context, options.bundle, BUNDLE_FILENAME);       // The call's promise is settled into a plain object the host can read      // without holding a live promise handle across the drain.      evaluate(        context,        `globalThis.__executor_sealed = { settled: false, value: undefined, error: undefined };         Promise.resolve(${options.call}).then(           function (value) { globalThis.__executor_sealed.value = value; globalThis.__executor_sealed.settled = true; },           function (error) {             globalThis.__executor_sealed.error = error && error.message ? String(error.message) : String(error);             globalThis.__executor_sealed.settled = true;           },         );`,        CALL_FILENAME,      );       // Everything in a sealed bundle is synchronous or a microtask — there are      // no host callbacks to await — so draining pending jobs to exhaustion      // settles it. `executePendingJobs` returns once no job remains; the      // interrupt handler stops a job that will not end.      const drained = runtime.executePendingJobs();      if (drained.error) {        const dumped: unknown = context.dump(drained.error);        drained.error.dispose();        // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: same as `evaluate`, for the microtask drain        throw new Error(errorMessage(dumped));      }       const stateHandle = context.getProp(context.global, "__executor_sealed");      try {        const state = context.dump(stateHandle) as {          settled?: boolean;          value?: unknown;          error?: unknown;        };        if (state.settled !== true) {          // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: an unsettled drain is a sandbox failure, reported to the caller          throw new Error(`Sealed bundle did not settle within ${timeoutMs}ms`);        }        if (state.error !== undefined) {          // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: propagate the sandbox's own rejection          throw new Error(String(state.error));        }        if (typeof state.value !== "string") {          // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the contract is one string          throw new Error(`Sealed bundle resolved a ${typeof state.value}, expected a string`);        }        return state.value;      } finally {        stateHandle.dispose();      }    } finally {      context.dispose();    }  } finally {    runtime.dispose();  }}; /** * Run `bundle`, then `call`, in a fresh QuickJS sandbox; resolve its string. * * Fails with `SealedBundleError` for every sandbox-side problem — a bundle that * throws while loading, a call that rejects, a deadline, a memory cap. Callers * that treat validation as advisory should fail open on it. */export const executeSealedBundle = (  options: SealedBundleOptions,  resolveModule: ModuleResolver,): Effect.Effect<string, SealedBundleError> =>  Effect.tryPromise({    try: () => runSealedBundle(options, resolveModule),    catch: (cause) => new SealedBundleError({ message: errorMessage(cause) }),  }).pipe(    Effect.withSpan("executor.sealed_bundle.exec", {      attributes: { "executor.runtime": "quickjs" },    }),  ); /** The default module resolver: the preloaded WASM module when a host set one *  (Workers must), otherwise the package's own. */export const defaultQuickJsModuleResolver =  (preloaded: () => QuickJSWASMModule | null): ModuleResolver =>  () => {    const module = preloaded();    return module ? Promise.resolve(module) : getQuickJS();  };