M3 implementation plan — npm compilation
Task breakdown for milestone M3 of
sandbox-package-design.md: the config-only
module form. A pack declares {"name": ".", "kind": "js", "npm": "js-yaml"}
and NodeTool produces the guest module from the npm dependency — bundled,
statically scanned, admission-probed, and cached by content.
Grounding, verified against the tree:
- Discovery already parses the
npmfield and records the entry as a skip:sandbox-pack-discovery.tsemitsnpm-module-unsupported(“requires the M3 compiler”) and gives the module idnpm:<name>with an empty dependency list. The catalog excludes those ids from resolution. M3 replaces that skip with a compiled artifact. - The QuickJS engine dependencies live only in
packages/agents, and agents depends on node-sdk — so node-sdk can never import the engine for the admission probe. esbuild is likewise not a node-sdk dependency. The compiler cannot live inside node-sdk without spreading esbuild concerns through it, which the design forbids anyway.
Task 1 — Pay down the M-1 measurement debt
The 1 MB npm-bundle cap is promised “validated in M-1”, and the design says to measure before the number lands anywhere user-visible. Bundle the real candidates with the exact Task 2 options — zod, date-fns, js-yaml, papaparse, fast-xml-parser, diff, fflate, cheerio — record the sizes in this document, and keep or move the cap on evidence. cheerio is the expected casualty (the design already hedges “if the bundle admits it”); a candidate that fails is recorded as out of scope for the bridge-pack milestone, not silently dropped.
Measured candidates
esbuild 0.28.1, conditions ["import","module","default"], mainFields
["module","main"], platform: "neutral", format: "esm", target:
"es2022", no externals, no minification — the exact Task 2 options.
Bundled from the versions installed in this tree.
| candidate | version | bundle | inputs | verdict |
|---|---|---|---|---|
| zod | 4.4.3 | 483.3 KB | 80 | scan error — const F = Function (×2); warns on navigator |
| date-fns | 4.4.0 | 175.6 KB | 305 | admitted — 250 exports through the probe |
| js-yaml | 4.3.0 | 101.4 KB | 2 | admitted — 15 exports through the probe |
| papaparse | 5.5.3 | — | — | bundle failed — imports node:stream |
| fast-xml-parser | 5.7.3 | 142.1 KB | 27 | scan error — window && window.parseInt (×3) |
| diff | 4.0.4 | 35.1 KB | 2 | scan error — setTimeout (×2) |
| fflate | 0.8.3 | 60.8 KB | 2 | admitted — 49 exports; warns on queueMicrotask, setTimeout |
| cheerio | 1.2.0 | — | — | bundle failed — imports 25 Node builtins |
Every rejection is a real one rather than a tooling artifact: zod reaches
the Function constructor for its compiled validators, fast-xml-parser
reads a bare window with no typeof guard, diff schedules with
setTimeout. Each would throw in the guest at the line the scan names.
cheerio is the expected casualty and papaparse joins it — both need the
host bridge path, so both are out of scope for the bridge-pack milestone.
Cap decision: keep 1 MB. The largest bundle measured is zod at 483 KB;
the largest admitted one is date-fns at 176 KB. The cap is about 2× the
worst candidate and 6× the worst realistic one, no candidate fails on size,
and nothing measured argues for moving the number either way. It lives in
NPM_BUNDLE_MAX_BYTES (packages/sandbox-compiler) and is re-checked
against SANDBOX_PACKAGE_LIMITS.npmBundledJsBytes in node-sdk, so an
artifact that grew past it after compilation is still refused at discovery.
Reproduce with nodetool packs compile --json against a pack declaring the
candidate, or drive bundleNpmModule / scanBundle / probeBundle from
@nodetool-ai/sandbox-compiler directly.
Task 2 — The compiler package
New workspace package packages/sandbox-compiler, the dedicated
compiler module. It depends on esbuild, acorn, and the QuickJS variant
directly; node-sdk stays free of all three.
compileNpmModule({ packDir, npmName, options })runs esbuild:bundle: true,format: "esm",platform: "neutral", explicitconditionsandmainFieldspinned as constants (neutral alone does not pin which conditional export wins), no externals, no minification (digests and review beat bytes here).- An import of a Node builtin fails the bundle; the failure becomes a named skip status at discovery (“imports node:fs — needs the host bridge path”), never a generic error.
- Output larger than the cap is a named skip carrying the measured size.
Task 3 — Scope-aware forbidden-global scan
The bundle then passes the authored-module static checks plus a
scope-aware scan (acorn scope analysis, not a regex): free references
to process, Buffer, require, eval, Function, timer globals,
WebAssembly, and DOM names. A hard reference errors; a
feature-detected reference (typeof process !== "undefined") warns —
and the warning is a heads-up, not a compatibility promise. Local
bindings that shadow those names are not hits; that is what
“scope-aware” buys.
Task 4 — QuickJS admission probe
Admission ends by importing the bundle in the real engine, because bundling proves resolution, not compatibility:
- The probe context is capability-free: no
fetch, no workspace or secret bridges, no tools, nothing fromjs-sandbox.ts’s bridge surface. The compiler package instantiates the engine directly with a short deadline, the normal memory limit, and output/log caps. - The probe proves module initialization only — top-level code runs and the exports object materializes. It cannot prove every export works; runtime loading stays authoritative after admission.
- A module that fails the probe is skipped with the probe’s error.
Task 5 — Content-addressed cache
Cache keys are digests, never pack/name@version (linked packs,
transitive updates, lockfile changes, and esbuild upgrades all change
output while the version stays put):
- Key: hash over esbuild’s metafile input list (every input file’s content hash), the esbuild version, the compiler package’s own version, and the normalized build options.
- Value: the bundled source, the metafile summary, the scan report, and the probe verdict — so a warm cache skips the probe too.
- Writes are atomic (temp file + rename); every path component is
sanitized; the cache lives under the existing per-user cache root
from
@nodetool-ai/configpaths.
Task 6 — Catalog and host integration
discoverSandboxPackaccepts compiled-artifact input (an injected lookup, keeping discovery synchronous and engine-free): an npm entry with a cached artifact joins the source graph like an authored file; one without becomes apending-compilestatus instead of today’snpm-module-unsupported.- The graph digest for npm entries incorporates what the design
requires: the bundled source, compiler options, and compiler
version.
computeSandboxModuleGraphDigestalready hashes authored files; the npm branch lands here. - Compilation runs where the process is already async: the server’s
bootstrapNodeRegistrypath and soft reload, the Electron installer after a sandbox pack lands, and an explicitnodetool packs compileCLI command. The CLI’s synchronousbuildFullRegistrynever compiles — it reads the cache, and a miss surfaces as thepending-compilediagnostic naming the command. In-flight runs keep the resolution they started with, as with every catalog swap. packs.sandboxModulesdiagnostics carry the new statuses; the Package Manager shows a pending/failed compile per module with the skip reason.
Task 7 — Fixtures and tests
- Fixture npm packages checked in under the compiler package’s test
directory (tiny, dependency-free, no network): a clean ESM utility,
one importing
node:fs(named skip), one with a hardprocessreference (scan error), one with a feature-detected reference (scan warning, admitted), one whose top-level throw fails the probe, one over the size cap. - Cache tests: identical input hits; changing one source file, the esbuild version, or an option misses; concurrent writes stay atomic.
- An end-to-end test through the catalog: the fixture pack’s npm module resolves for execution with the compiled source and correct digest, and runs in the M1 loader.
Sequencing
Task 2 → 3 → 4 are the compile pipeline, in execution order. Task 5 wraps them; Task 6 wires the result into discovery, hosts, and CLI; Task 1 runs first (its numbers are Task 2 constants); Task 7 grows with each piece. M3 depends on M1’s loader for the end-to-end proof but not on M2 — a compiled npm module is server/CLI-usable while browser delivery ships separately, and delivery of compiled modules rides M2’s route unchanged (they are catalog modules like any other).
Exit criteria
@nodetool-ai/sandbox-yaml-shaped fixture (config-only manifest, no authored code) installs, compiles, probes, and its module imports and runs through the M1 loader on server and CLI.- A builtin-importing, a scan-failing, a probe-failing, and an
oversized candidate each surface as named skips in
packs.sandboxModules, not errors. - Cache invalidates on content, never on version alone.
- The measured candidate table exists in this document with the cap
decision recorded;
npm run checkgreen.