M4 implementation plan — WASM execution (scalar-only)
Task breakdown for milestone M4 of sandbox-package-design.md: declared WASM modules become callable from guest code through generated facades, on workers, instance per call, under layered budgets.
Grounding: the static half already shipped in M0.
validateWasmModule/parseWasm in
packages/node-sdk/src/sandbox-pack-discovery.ts enforce the whole
binary contract — magic/version, zero imports, every memory unshared
with a maximum at or below both the manifest’s memoryPagesMax and the
4096-page host ceiling, declared exports present and function-kind,
arity ≤ 8, ≤ 1 result, i32/f32/f64 value types only — and the
resolution schema carries WASM bytes through the catalog. What does
not exist is everything at run time: the M1 loader rejects kind:
"wasm" resolutions with an M4 error.
Task 1 — Validation gaps and manifest mapping
Close the deltas between the shipped validator and the design text:
- Export-name identifier rule: a manifest export must be a valid,
non-reserved JS identifier, and a binary export that is not
(
"foo-bar") must use the explicit{ "wasm": "foo-bar", "as": "fooBar" }mapping (the schema already allows the object form — validate theasname). - Skip reasons name the offending export and rule (“uses i64”, “is a memory, not a function”) rather than the generic unsupported- signature message, per “named in the skip reason, not silently dropped”.
- Per-call timeout and budget fields a manifest may lower (never raise): schema additions with the defaults as ceilings.
Task 2 — The WASM host: workers, pool, instance per call
New module in packages/agents (beside js-sandbox.ts, which owns the
guest side and already runs on Node and browser):
- Compile once per process from the resolution’s bytes;
WebAssembly.Moduleis structured-cloneable, so the compiled module crosses to workers without recompiling. - Calls run on workers — Node
worker_threads, browser Web Worker — from a process-wide pool of 4. A hard per-call timeout (5 s default) terminates and replaces the worker. - Instance per call, stateless: each call instantiates fresh from the cached module inside the worker, runs, discards. Mutable globals and linear memory do not survive between calls; the docs say so. This is what makes terminate-and-replace coherent — a killed worker destroys nothing an invocation owns.
- Argument conversion is validated host-side before dispatch, never
left to WebAssembly coercion:
i32requires a finite integer in int32 range (out-of-range rejects, no wrapping);f32/f64accept any JS number includingNaNand infinities,f32rounding by WebAssembly’s rules. Return values mirror the same rules; a void export resolvesundefined.
Task 3 — Budgets
Layered, defaults fixed by the design, a manifest may lower:
| Bound | Default |
|---|---|
| Process-wide worker pool | 4 |
| Per-invocation call concurrency | 2 |
| Calls per invocation | 256 |
| Aggregate WASM wall clock per invocation | 30 s |
| Per-call timeout | 5 s |
Exhaustion errors name the budget and the module. No byte caps — those belong to the future byte ABI.
Task 4 — Generated facades and the per-run dispatcher
- For a WASM entry, the catalog resolution carries a generated ESM
facade: named async exports matching the manifest’s
exports, each calling a per-run dispatcher. Authored JS in the same pack importing a sibling WASM specifier resolves to the same facade. The facade source and generator version join the module-graph digest (the digest schema already anticipates this). - The dispatcher is the boundary, not the hiding: it serves only WASM modules declared for the run, validates module identity, export allowlist, and argument count/types on every call. The static analyzer and the M1 loader both deny direct imports of the private bridge module, and the dispatcher binding is removed before the user IIFE starts — but a module that finds the binding anyway gains nothing beyond the run’s declared WASM surface.
- The M1 loader’s “WASM is M4” rejection is replaced by facade mounting.
Task 5 — Reference module and toolchain doc
One scalar reference module built from a documented toolchain (Rust
no_std → wasm32-unknown-unknown, or WAT via wabt — whichever the
doc pins), checked in as bytes with its source and build instructions.
It is the fixture for every execution test and the template third
parties copy. The byte-oriented reference module from M-1 informs the
future byte ABI and ships as documentation only.
Task 6 — Contract tests, Node and browser
- Happy path: facade import, scalar round trips for all three types,
void export,
as-mapped name. - Conversion rejections:
i64-shaped values, out-of-rangei32, non-numbers. - Statelessness: a mutable-global counter reads fresh every call.
- Budgets: per-call timeout kills and replaces the worker (an infinite loop export), call-count and wall-clock exhaustion, concurrency cap.
- Boundary: undeclared module id at the dispatcher, direct bridge import denied, facade of a pack not declared for the run denied.
- The same fixtures run on Node (vitest) and browser (the M2 shared fixture harness).
Sequencing
Task 1 is independent and lands first. Task 2 → 3 → 4 build on each other; Task 5 supplies the fixture Task 2 needs on day one (build it first). Task 6 grows with each. Depends on M1 (loader, facade mounting); browser-side tests depend on M2’s harness. Independent of M3.
Exit criteria
- The reference module’s export is callable from a Code node via its facade on server, CLI, and browser; the call runs on a worker, instance per call.
- Every budget bound is observable in a test, including timeout-terminate-replace.
- An adversarial guest cannot reach the dispatcher beyond the run’s declared WASM surface.
npm run checkgreen on Node; the shared fixture suite green in the browser.