189 lines · 8.5 KB · TypeScript
RawDownload
/** * Render an artifact once, in a sandbox, to find out whether it renders at all. * * The failure this exists for: a model writes `<ChartTooltipContent />` without * a `<ChartContainer>`, `create-artifact` accepts it, the row saves, and then * the page dies with `useChart must be used within a <ChartContainer />`. The * artifact is broken, the user sees an error box, and the model — which got a * success back — has no idea. Rendering it once at create time moves that * discovery to the only moment it is cheap: the model is still holding the * code, and can fix it and retry before anyone sees it. * * ## Where the code runs, and why that is the whole design * * The code being rendered was written by a model. It is untrusted, exactly as * untrusted as anything `execute` runs — so it runs in the same place: a * QuickJS sandbox from the kernel. It never runs in this process. * * That is not a precaution, it is the only defensible arrangement. An earlier * version of this file compiled the artifact and built its component with * `new Function` right here, in the trusted server, relying on the ~280-name * scope to shadow anything dangerous. Shadowing is not a sandbox: the scope * only binds identifiers the code chooses to mention, and `globalThis.process` * or `Function("return process")()` walk straight past it to the host's * environment — its secrets, its database handle, its network. There is no * version of that idea that is safe, so none of it survives here. * * What crosses the boundary now is a string in and a string out. `code` is * embedded as a JSON literal in the expression the sandbox evaluates, and a * JSON `SmokeRenderResult` comes back. The renderer itself — React, * react-dom/server, the component barrel, the tools proxy, the QueryClient — * is prebuilt into `smoke-harness-bundle.gen.ts` and lives entirely inside the * sandbox. This process holds no React at all. * * ## One path, every host * * QuickJS is a WASM interpreter, so `new Function` inside it is QuickJS * building a QuickJS function — not V8 compiling a string. workerd's ban on * runtime codegen is a ban on V8 codegen, which is why this same path works on * Cloudflare, and why the old "probe `new Function`, skip the check where it * throws" carve-out is gone along with the in-process evaluator. Cloud used to * silently smoke-render nothing; now it runs the real check like everyone else. * * ## What it does and does not catch * * It catches a SYNCHRONOUS FIRST-RENDER THROW: a missing provider, a * ReferenceError, a bad hook call, reading a property of something that is * undefined before any data has arrived. That last one is not a false positive * — `data` genuinely is undefined while a query is pending, so a component that * dereferences it without a guard crashes for real, every time, in front of the * user. Catching it is the point. * * It does NOT catch data-shape errors: code that only throws once a tool * returns a payload of an unexpected shape. Nothing is fetched here, and * inventing a plausible payload would be worse than not trying — it would * reject correct code whenever the guess was wrong. That class stays a runtime * error inside the frame, where the shell's error boundary already reports it. * * ## How it stays faithful to the real renderer * * Everything the component sees comes from the same modules the iframe uses: * `compileJsx`/`evaluateComponent` for the scope, `createToolsProxy` for * `tools`, and the same `TooltipProvider` + `QueryClientProvider` stack the * inner renderer mounts, with the same QueryClient defaults — all of it now * compiled into the harness rather than imported here. Anything that drifts is * a bug in one shared source rather than a divergence between two. * * The one deliberate difference is the transport: every tool call returns a * promise that never settles, so every query stays `isPending` and the * component renders its loading state. That is exactly the state the user sees * first in the real frame, and it is the state a first-render crash happens in. * * ## Boundedness * * The sandbox's interrupt handler preempts the interpreter, so a synchronous * `while (true)` in a component body is now stopped at the deadline. In-process * it could not be — no timeout can interrupt a spinning host thread — and such * an artifact hung the whole request. Getting that for free is a second reason * this belongs in the kernel. * * ## Fail open * * A missing harness bundle, an unavailable sandbox, a deadline: all `ok`. * "Inconclusive" must never read as "broken", because the cost of a false * rejection is a model unable to save correct code. Only a render error from * the artifact ITSELF returns `failed`. */ import { executeSealedBundle } from "@executor-js/runtime-quickjs";import * as Effect from "effect/Effect"; import harnessBundle from "./smoke-harness-bundle.gen";import type { SmokeRenderResult } from "./smoke-render-result"; export type { SmokeRenderResult } from "./smoke-render-result"; /** How long the render may take before it is abandoned as inconclusive. An *  ordinary artifact loads the harness and renders in a few hundred *  milliseconds; this only bounds pathological code. */const RENDER_DEADLINE_MS = 10_000; const OK: SmokeRenderResult = { status: "ok" }; /** * How the sandbox is reached. Indirected through a mutable binding for exactly * one reason: the fail-open contract is the most important behaviour here and * the hardest to provoke honestly — a real missing sandbox cannot be arranged * from a test without breaking the runtime for every other test in the file. * Swapping this seam simulates it at the boundary the host actually depends on. */type SealedBundleRunner = typeof executeSealedBundle; let runSealedBundle: SealedBundleRunner = executeSealedBundle; /** * Replace the sandbox call for the duration of a scope. Test-only, and * `Disposable` so it cannot leak past the `using` that installed it. */export const stubSealedBundle = (runner: SealedBundleRunner): Disposable => {  const previous = runSealedBundle;  runSealedBundle = runner;  return {    [Symbol.dispose]: () => {      runSealedBundle = previous;    },  };}; /** Narrow the sandbox's JSON back to the contract. Anything unrecognized is *  `ok` — see the fail-open note in the header. */const parseResult = (json: string): SmokeRenderResult => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: parsing a value that crossed a sandbox boundary  try {    const parsed: unknown = JSON.parse(json);    if (typeof parsed !== "object" || parsed === null) return OK;    const record = parsed as {      status?: unknown;      message?: unknown;      componentStack?: unknown;      markup?: unknown;    };    if (record.status !== "failed" || typeof record.message !== "string") {      // An `ok` verdict may carry the render's markup. It is still only a      // preview: a value of the wrong type is dropped rather than propagated,      // because "no preview" is always a safe answer and a malformed one is      // not.      return typeof record.markup === "string" && record.markup !== ""        ? { status: "ok", markup: record.markup }        : OK;    }    return {      status: "failed",      message: record.message,      ...(typeof record.componentStack === "string"        ? { componentStack: record.componentStack }        : {}),    };  } catch {    return OK;  }}; /** * Render `code` once in a sandbox. Throws nothing: every outcome is a result. * * A compile error or a missing `App` comes back as `failed` too — the model * gets the same message it would have seen in the frame, at the moment it can * still act on it. */export const smokeRenderArtifact = async (code: string): Promise<SmokeRenderResult> => {  // No harness in this build. Nothing can be concluded, so conclude nothing.  if (harnessBundle === null) return OK;   return Effect.runPromise(    runSealedBundle({      bundle: harnessBundle,      // The artifact's source crosses as a JSON string literal inside the      // expression — the only value this process hands the sandbox, and never      // as a global it could be reached through.      call: `globalThis.__executorSmokeRender(${JSON.stringify(code)})`,      timeoutMs: RENDER_DEADLINE_MS,    }).pipe(      Effect.map(parseResult),      // A sandbox-side failure is not the artifact's fault. Log it and save.      Effect.catch((error) =>        Effect.as(Effect.logWarning("artifact smoke render was unavailable", error), OK),      ),    ),  );};