M6 implementation plan — library packs
Landed, then reworked. The first M6 shipped three packs and kept the
data.*bridges beside them — “an added import path, not a migration”. The repo owner rejected that: “I don’t want to keep thedata.*bridges. I want every module to be imported properly — even the nodetool modules.” The rework makesimportthe only way to reach a library.What changed:
data.*is gone. The namespace, its eleven members, its entries in the sandbox manifest andSANDBOX_GLOBALS, its prelude block, and every prompt and snippet that named it.- Host-facade modules. A new manifest kind,
{"kind": "host", "host": "<id>"}, is the host-JS analog of M4’s WASM path: a generated ESM facade over a per-run dispatcher, with the implementation inpackages/agents/src/host-modules/— the moved bridge code, limits included. A pack declares an id; NodeTool owns the code.- Eight packs, two guest and six host (table below).
- Limits moved, not dropped.
MAX_UNZIP_TOTAL_BYTES(50 MB),DEFAULT_SELECT_HTML_LIMIT/MAX_SELECT_HTML_LIMIT, and the shared 5 MB/10 MB input caps live in the implementations now, each with its test.- zip moved from guest to host. M3 admitted fflate, so the first M6 shipped it in the guest. The 50 MB inflation cap cannot be enforced there — a guest module is the guest, so there is no boundary below it to cap — and the 64 MB heap does not replicate the policy. Host it is.
Disposition
| Pack | Library | Runs | Why |
|---|---|---|---|
@nodetool-ai/sandbox-yaml |
js-yaml | guest | 101 KB bundle, 15 exports through the probe |
@nodetool-ai/sandbox-dates |
date-fns | guest | 176 KB, 250 exports |
@nodetool-ai/sandbox-csv |
papaparse | host | imports node:stream, so the bundle fails |
@nodetool-ai/sandbox-html |
cheerio + turndown | host | 25 Node builtins; turndown wants a DOM |
@nodetool-ai/sandbox-xml |
fast-xml-parser | host | reads a bare window with no typeof guard |
@nodetool-ai/sandbox-xlsx |
exceljs | host | Node streams; never a guest candidate |
@nodetool-ai/sandbox-diff |
diff | host | schedules with setTimeout |
@nodetool-ai/sandbox-zip |
fflate | host | compiler admits it; the inflation cap does not survive the move |
The M3 measurement still decides guest admission. What it no longer decides is
whether a library gets a pack: a library the compiler rejects gets a host
pack instead of a global. The earlier alternative — vendoring an authored
kind: "js" fork of each rejected library — stays rejected, for the same
reason: owning someone else’s code and its licence trail forever is a larger
commitment than one import path is worth. A host module costs nothing like
that, because the code was already in this repo, running host-side, behind the
bridge that just went away.
Trust
kind: "host" is the one manifest kind that names NodeTool’s own code, so it
is the one that had to be closed. Two checks, both by identity and both
repeated:
- Discovery refuses an id that is not in
SANDBOX_HOST_MODULES, and refuses a known id declared by a package that table does not pin it to. - The dispatcher repeats both when it is built — a resolution can arrive from a catalog this process did not build, since the browser fetches one over HTTP — and then validates the module key, the export name, and the argument list on every call.
packages/sandbox-compiler/tests/packs.test.ts drives both refusals with real
crafted manifests; packages/agents/tests/host-modules.test.ts drives the
dispatcher’s.
Migration
Everything that named a data.* member moved to the import form with the
matching packages declaration: the sandbox manifest and its drift tests, the
node-sdk SANDBOX_GLOBALS set, the run_code/js tool prompts, the Code
node’s own description, the editor’s chat sandbox docs, five HTML code
snippets (which now carry a packages list the palette seeds onto the node),
the CodeAct batching example, and the live API script. js-sandbox-data-libs.test.ts
became host-modules.test.ts.
data.parseYaml/toYaml had no host pack of its own — js-yaml already ships
as a guest pack, so YAML is @nodetool-ai/sandbox-yaml and nothing was
duplicated. data.parseXlsx and data.htmlToMarkdown had no pack at all
before; they became @nodetool-ai/sandbox-xlsx and the second export of
@nodetool-ai/sandbox-html. No member survived as a global.
Task breakdown for milestone M6 of sandbox-package-design.md: one config-only pack per migratable bridge library, living in the monorepo, consumed only via install. The original plan is kept below as written; the banner above records where the rework departed from it.
Task 1 — The pack workspace
New directory packages/sandbox-packs/ holding one npm package per
library, each just a package.json (config-only manifest, M3 form) plus
SKILL.md:
| Pack | npm dependency | Note |
|---|---|---|
@nodetool-ai/sandbox-csv |
papaparse | |
@nodetool-ai/sandbox-yaml |
js-yaml | |
@nodetool-ai/sandbox-xml |
fast-xml-parser | |
@nodetool-ai/sandbox-diff |
diff | |
@nodetool-ai/sandbox-zip |
fflate | see Task 3 |
@nodetool-ai/sandbox-html |
cheerio | only if the M3 size |
| measurement admitted it; otherwise dropped here and recorded |
Each manifest is {"name": ".", "kind": "js", "npm": "<lib>"} — the
single-module root form, so usage reads
import Papa from "@nodetool-ai/sandbox-csv". These packages are
not part of the root workspace build graph the way backend
packages are: they ship no compiled output and are consumed only via
the package-manager install flow. Whatever workspace wiring they get
must not let anything import them in-process.
Task 2 — Skills
A SKILL.md per pack: the specifier, the main functions with one
example each written for the sandbox context, and the gotchas that
matter there — input size caps, the 64 MB guest heap, no timers, and
where the hardened data.* bridge remains the better route. The M5
disclosure machinery serves them; nothing new here beyond the files.
Task 3 — The zip-bomb boundary, written down
fflate’s bridge enforces a 50 MB decompression cap
(MAX_UNZIP_TOTAL_BYTES) that the guest heap does not replicate — a
64 MB heap does not bound what a stream inflates through. The
sandbox-zip SKILL.md and the pack description must steer
decompression of untrusted archives to the data.unzip bridge, and
the design doc’s bridge table stays authoritative: exceljs (Node
streams), turndown (DOM), format.* (no Intl), image/canvas
(native), and every capability bridge stay host-side permanently.
Nothing in this milestone touches a bridge limit.
Task 4 — Registry and validation presence
- The registry index entries for the six packs carry the
sandboxModulessummary and a recommended mark. - Presence is never assumed:
nodetool validateon a workflow declaring an uninstalled bridge pack fails with “install" (the M1 unknown-specifier error already names the pack; this adds the install hint for known registry packs). - Prompts advertise only installed packs — already M1’s rule; the test here proves an uninstalled bridge pack never appears in the one-line tier.
Task 5 — End-to-end proof and CI
- An integration test installs
sandbox-yamlthrough the real scripts-disabled install flow, compiles it (M3), and runsimport yaml from "@nodetool-ai/sandbox-yaml"in a Code node on the kernel — the full path a third-party pack will take. - The
harness gatesurface registry gains the sandbox-packs surface with this test as its selfcheck, keeping the packs from shipping uncovered.
Sequencing
Task 1 → 2 → 4 → 5; Task 3 is wording inside 1–2. Depends on M3 (the compiler is what turns these config-only manifests into modules) and M5 (skill disclosure). cheerio’s fate is decided by M3 Task 1’s measurement, not here.
Exit criteria
- Each shipped pack installs, compiles where it compiles, and imports in a Code node and CodeAct step; every pack’s path is CI-proven end to end.
- A workflow naming an uninstalled pack fails validation with the install hint.
- No
data.*global survives, and every limit those bridges enforced has an equivalent inside a host module, with its test. - A crafted manifest declaring
kind: "host"with an unknown or forged id is refused at discovery and by the dispatcher. npm run checkgreen.