| Navigation: Architecture | Chat & Agents | CodeAct design | Sandbox packages |
Every piece of JavaScript NodeTool did not write itself runs in one place: a
QuickJS WebAssembly guest built by runInSandbox
(packages/agents/src/js-sandbox.ts). A workflow’s Code node, an agent’s code
action, a planner’s graph program and the run_code tool all enter through that
function, and they all get the same engine, the same limits, and the same
marshaling rules.
The guest has its own heap inside the WASM instance, so a runaway or hostile
program cannot corrupt the host V8 heap the way it could under node:vm. What
it can reach is a curated set of host bridges, and each one is a capability the
caller granted for that run.
Where it runs
| Caller | Code | What runs |
|---|---|---|
nodetool.code.Code |
packages/code-nodes/src/nodes/code-node.ts |
A user’s node body, with dynamic inputs on inputs |
| CodeAct step / chat turn | packages/agents/src/codeact/ |
One model-written action per execute_code call |
| Script mode | packages/agents/src/script-runner.ts |
An LLM-authored orchestration script whose agent() calls spawn sub-agents |
GraphPlanner submit_graph |
packages/agents/src/graph-dsl.ts |
A graph DSL program with no host access at all |
run_code tool |
packages/agents/src/tools/code-tools.ts |
A one-shot snippet, no packages declared |
js tool (MiniJSAgentTool) |
packages/agents/src/tools/js-code-tool.ts |
Same, with the bridge surface documented in the tool description |
| Browser runner | packages/workflow-runner/ |
The same Code nodes, in the page, fetching modules over HTTP |
One engine (loadQuickJs, the quickjs-ng release variant) is loaded once per
process and shared. Each invocation gets a fresh runtime and context.
Anatomy of a run
- Resolve limits.
resolveSandboxLimitsapplies defaults and clamps every caller override to a hard ceiling. A caller can tighten a limit, or raise it within bounds, but never switch a protection off. - Build the bridges.
buildSandboxconstructs the host-side objects (fetch,workspace,crypto, …) bound to this run’s context, limits and abort signal. Each async bridge is wrapped inneverRejectandguardAbort. - Build the entry module.
buildEntryModuleparses the code with acorn, hoists staticimportdeclarations above the wrapper, and emits the rest as the body of a top-level-awaited async IIFE, soreturn valuebecomes the module’s default export. Code acorn cannot parse falls through towrapCodeunchanged, so a syntax error reaches the user as they wrote it. - Install the module loader — only when the run declares modules. Without
modules, no loader exists and everyimportresolves nothing. - Init prelude.
eval,Functionand the wrapper’s unconditional stubs (Buffer,process,env,Headers,Request,Response,performance) are deleted. The entry module additionally deletes the timer globals, which the wrapper library re-installs on every evaluation. - Run, under an interrupt handler on a CPU deadline and a wall-clock race.
- Serialize.
serializeResultwalks the returned value (cycle-safe, depth-capped at 32) converting typed arrays at any depth, then truncates tomaxOutputSize. Object-typed globals are deep-replaced on the host, which is how the Code node’sstatesurvives between invocations.
The guest surface
Two kinds of thing, and the difference matters: capabilities are globals, libraries are imports.
Capabilities (globals)
| Global | What it does |
|---|---|
console.* |
Log lines land in the run’s logs array |
fetch(url, options?) |
HTTP, returning {ok, status, headers, body, json, text(), arrayBuffer(), bytes()}. A Uint8Array body is sent as raw bytes |
workspace.* |
read, write, list, readBytes, writeBytes, stat, root, copy, move, mkdir, remove. Needs a ProcessingContext |
getSecret(name) |
The run’s secret store |
sleep(ms) |
The only timer |
crypto.* |
randomUUID, getRandomValues, digest, hmac (WebCrypto-backed; SHA-1/256/384/512) |
format.* |
number, date, relativeTime, list — host Intl, which QuickJS does not ship. All four are async |
image.* |
info, decode, encode, resize, crop, rotate, flip, adjust, composite, convert over encoded bytes |
canvas.measureText / createCanvas(w, h) |
Canvas 2D drawing, recorded in the guest and replayed on a real host context by await surface.toBytes() |
assetToSandbox(assetId, path) / sandboxToAsset(path) |
Move an asset in and out of the workspace |
progress(percent, message?) |
Fire-and-forget progress, rate-limited and capped |
toBase64 / fromBase64 / toHex / fromHex / parallelMap |
Pure guest helpers, no host call behind them |
Core JavaScript — JSON, Math, Date, Map, Set, RegExp, URL,
URLSearchParams, TextEncoder/TextDecoder — is QuickJS’s own, not a
host-bridged version.
Callers add their own globals through RunSandboxOptions.globals:
inputs and state for the Code node, tools/state/finish for CodeAct,
node/graph for the graph DSL. Names in RESERVED_SANDBOX_NAMES cannot be
overwritten this way.
Libraries (imports)
There is no library global. Every library the sandbox offers is a sandbox package the run declares and imports:
import yaml from "@nodetool-ai/sandbox-yaml";
const config = yaml.load(inputs.text);
return { config };
NodeTool ships eight (packages/sandbox-packs/): -dates (date-fns) and
-yaml (js-yaml) run inside the guest; -csv (papaparse), -html (cheerio +
turndown), -xml (fast-xml-parser), -xlsx (exceljs), -diff (diff) and
-zip (fflate) run on the host behind a generated facade, because they need
Node builtins or a DOM, or carry a limit the guest could not enforce on itself
(zip’s 50 MB inflation cap). Third-party packs install the same way. See
Sandbox packages and
packages/sandbox-packs/README.md.
Limits
Every default below is overridable per invocation through
RunSandboxOptions.limits and clamped to the ceiling in the last column.
| Limit | Default | Enforced by | Ceiling |
|---|---|---|---|
| Execution time | 30 s (timeoutMs) |
interrupt handler on a CPU budget + wall-clock race | — |
| CodeAct action timeout | 600 s | DEFAULT_CODEACT_ACTION_TIMEOUT_MS |
— |
| Suspended time | 30 min | suspendAllowanceMs, only with a clock |
— |
| Guest heap | 64 MB | runtime.setMemoryLimit |
512 MB |
| Call stack | 512 KB | runtime.setMaxStackSize |
8 MB |
| Fetch calls | 20 per run | counter in the bridge | 100 |
| Fetch body | 1 MB | truncation in the bridge | 50 MB |
| Fetch timeout | 15 s | per-request AbortController |
120 s |
| Fetch redirects | 5 hops | the bridge | — |
| Output size | 100 KB | serializeResult |
10 MB |
| Random bytes | 64 KB per call | crypto.getRandomValues clamp |
— |
| Progress reports | 1000 per run, one per 100 ms | counter + timestamp | — |
| Host module text input | 5 MB | host-modules/limits.ts |
— |
| Host module byte input | 10 MB | host-modules/limits.ts |
— |
| Image input | 25 MB, 32 M pixels, 16384 px longest edge | assertSurfaceSize |
— |
| Canvas ops | 10 000 per render | renderCanvas |
— |
| Tool calls per action | 50 | DEFAULT_MAX_TOOL_CALLS_PER_ACTION |
— |
QuickJS’s memory limiter counts its own heap objects. String and typed-array
payloads are not charged against it, so memoryLimitBytes bites on object
allocation, not on new Uint8Array(n).
Concurrency
The sandbox is fully asynchronous, and a bridge call starts its host-side work
when it is invoked, not when it is awaited. Promise.all over five fetches
therefore takes one round trip, not five:
const pages = await Promise.all(urls.map((u) => fetch(u)));
parallelMap(items, fn, concurrency = 5) is the bounded form — order-preserving,
maximum concurrency 32, rejecting on the first failure. Parallel calls count
against the per-run fetch cap exactly like serial ones.
Timer globals (setTimeout, setInterval, setImmediate and their clears) are
deleted inside the user-code module. Their callbacks would fire through
ctx.callFunction with errors discarded, outside the never-reject and
abort-guard conventions every bridge follows. sleep is the only timer.
Marshaling rules
Anything crossing the WASM boundary follows four rules. Break one and the symptom is silent data corruption, not an error.
- Host async functions never reject. A failing bridge resolves a tagged
{__nodetool_sandbox_error__: true, name, message}object, and a guest prelude re-throws it as a realError. This works around a handle leak in@sebastianwessel/quickjs@3.0.1that trips an assertion (list_empty(&rt->gc_obj_list)) when the runtime is freed. - Binary crosses asymmetrically. Guest → host is native: typed-array
serializers registered with
addSerializerturn a guestUint8Arrayinto a host one. Host → guest is not: a returnedUint8Arraywould arrive as a numeric-keyed plain object, so byte-producing bridges return a base64 marker object that the guest prelude rebuilds. Follow this for any new binary bridge. - Results are scanned at any depth.
serializeResultwalks for typed arrays through the whole value; the streaming path always nests bytes two levels down, and aUint8Arraythat falls toJSON.stringifybecomes{"0":137,"1":80}— lossy and indistinguishable from a user’s integer-keyed map. - Object globals sync back. After the guest runs, object-typed globals are deep-replaced on the host. Primitives pass by value and do not sync.
Security model
The guest starts with less than plain QuickJS, and every capability past that is one the host granted.
- No dynamic code generation.
evalandFunctionare deleted before any user code evaluates. - No ambient modules. Without a declared resolution there is no loader at
all. With one, only the run’s declared packages and their intra-pack siblings
resolve; dynamic
import()is always denied. Enforcement sits in the normalizer, not the loader, because QuickJS serves an already-cached module without consulting the loader — that is what keepsnode:bufferand the rest of the wrapper’s compat preamble out of reach after bootstrap. - SSRF guard.
fetchrefuses loopback, link-local and private ranges, including IPv6 forms and IPv4-mapped addresses, and re-checks on every redirect.limits.allowPrivateNetworklifts it; it is host-set only, so guest code cannot enable it for itself. - Workspace containment.
workspace.*resolves inside the workspace root and re-checks the symlink-resolved real path immediately before each operation.limits.filesystemAccess: "host"lifts that to the whole filesystem the process can reach. Both switches exist becauselib.httpandlib.osnodes always had that reach and a Code node replacing one must match it; both default to the restrictive value, and the graph migration sets the filesystem switch only on nodes rewritten from such a node. - Cancellation. Once the abort signal fires, every subsequent bridge call
fails fast and the guest unwinds. A purely CPU-bound loop still runs to its
execution timeout — QuickJS’s wrapper exposes no interrupt input — but
runInSandboxreturns as soon as the signal fires. - Bridges are the boundary, not the hiding. The host and WASM module dispatchers validate module identity, export name and argument list before any implementation loads, and their bindings are deleted before user code starts. A pack module that captures a binding during linking gains nothing beyond the run’s own declared surface.
Known accepted risk: the realpath check and the filesystem call after it are
separate awaits, so an in-workspace symlink swapped between them is a TOCTOU
window. Closing it needs fd-based operations (O_NOFOLLOW/openat) that
node:fs/promises does not expose. It requires a local attacker racing inside
the workspace, on surfaces that run first-party or already-trusted code.
The module system
A pack declares its sandbox modules in the nodetool field of its
package.json. Three kinds, one import surface:
| Kind | Runs | Declared as |
|---|---|---|
| Guest JS | inside QuickJS | authored source, or {"npm": "<dependency>"} compiled by packages/sandbox-compiler |
| Host JS | where the sandbox runs | {"kind": "host", "host": "<id>"}, resolved only through SANDBOX_HOST_MODULES in @nodetool-ai/protocol |
| WASM | host worker pool | manifest exports with scalar-only signatures, behind a generated facade |
A host id resolves only if the registry pins that exact package as the one allowed to declare it, so a third-party pack can never bring host code. WASM calls are stateless by contract: each instantiates fresh from the cached module, runs, and is discarded.
Compilation of an npm-declared module is cached by content digest, never by version:
npm run dev:nodetool -- packs compile # every installed pack
npm run dev:nodetool -- packs compile --force # recompile and re-probe
Anything that stops a module short of admission is a named skip, not an error:
npm-module-builtin-import, npm-module-unresolved, npm-module-too-large
(1 MB), npm-module-forbidden-global, npm-module-probe-failed.
The browser runner fetches module sources over
GET /api/sandbox-modules/* by opaque module id, and the catalog authorizes and
retrieves in one call — the route never touches the filesystem. Bodies are
verified before they run, so the loading and denial contract is the same in the
page as on the server.
The Code node
nodetool.code.Code is the sandbox as a workflow node.
// inputs: { rows: [...], threshold: 10 }
const kept = inputs.rows.filter((r) => r.score > inputs.threshold);
progress(50, `kept ${kept.length}`);
return { kept, count: kept.length };
// outputs: kept, count
- Inputs arrive on the
inputsobject, never as globals of their own name. Sharing the global namespace let an input calledenvshadow a bridge and made every undeclared identifier ambiguous between a typo and a missing slot. Values are deep-copied through JSON before entering the guest. - Outputs are the returned object’s keys. Return a non-object and it becomes
a single
outputhandle. Code with noreturngets an implicit return of its last expression. - Streaming. Code containing
yieldruns throughgenProcess: the yields are collected in the guest and emitted one message at a time. stateis a plain object that survives across streaming invocations and resets at the start of each workflow run.progress(percent, message)postsnode_progressto the kernel — the same channel the Python worker uses — so a long snippet drives the node’s progress bar.
Node props map onto sandbox policy: timeout (seconds, 0 for none),
max_response_mb, allow_local_network → limits.allowPrivateNetwork,
allow_host_filesystem → limits.filesystemAccess, and packages → the
declared module resolution. An undeclared or unserveable package fails the node
before the guest starts rather than surfacing as a resolve error inside it;
version or digest drift only warns on the node’s log.
On a server host the node’s code also gets the nodetool object model, backed
by the agent toolbelt. The belt is loaded lazily and only on Node, since the
in-browser runner bundles this module: without one,
nodetool.capabilities() reports {} and each method throws naming the tool it
needs instead of a ReferenceError.
Static checking. nodetool validate parses each Code node body and reports
what a run would hit: invalid JavaScript, top-level export, an import the
node’s packages does not declare, a bare read of a name that is neither a
sandbox API nor one of the node’s own inputs (they live on inputs, so a bare
read is a ReferenceError), an inputs.<name> the node does not declare, no
return, or a declared output left unset on some return path. The analysis lives
in @nodetool-ai/node-sdk (code-analysis.ts, code-node-validation.ts), so
the validator, the submit_code planner and the editor read one AST.
Agents
CodeAct: code as the action space
Every agent step acts by writing a program, not by emitting one JSON tool call.
The model sees a single provider tool, execute_code({code}); the program runs
in the sandbox with the step’s toolbelt exposed as async functions, and one
round trip can chain, loop over, branch on and reduce any number of tool calls.
Design and the research behind it: CodeAct design.
What an action gets on top of the standard surface:
tools.<name>(args)— one wrapper per tool on the belt, generated byTOOLS_PRELUDEover a single__callToolbridge. A tool returning an{error}payload throws in the guest, sotry/catchis the idiom. Each invocation surfaces to the host as atool_call_update(idcodeact_<n>), so composition inside one action stays observable.state— persists across the actions of a step, host-side, synced back after every run.finish(result)— completes the step. For schema’d steps the host validates, and an invalid result throws in the guest with the violation list, so the same action can repair.searchTools(query)— in-sandbox discovery for tools the prompt lists by name only, past the disclosure threshold. Deferred tools stay callable; the split spends prompt tokens, not capability.nodetool.*— the platform as objects (workflows,graph(),nodes,agents,models,media,assets,jobs,collections,web,memory, and the rest), each method wrapping a belt tool. A method whose backing tool is absent throws naming it.openWorkflow(id)— when the belt carries theui_*document tools, a graph object model whose synchronous mutators queue operations against a local mirror, replayed through the same tool contract byawait wf.commit().
The action inherits exactly the privileges tool mode already granted: every
tools.* function is a tool the model could have called directly, and per-step
allowlists stay a privilege boundary. What is genuinely new is composition —
one action can chain calls without per-call visibility in the provider
transcript — which the per-action tool-call cap, the tool_call_update events
and the action timeout bound.
Package consent
A Code node carries packages a person saved; an action is code the model just
wrote. So an action imports only what the session allowed
(sandboxPackages), and the prompt advertises only those specifiers, one
sanitized line each, never the installed catalog. A session that allowed nothing
imports nothing, and an off-allowlist import stops the action before the guest
starts — the model sees the refusal as its observation and can correct it. A
session with packages also carries get_sandbox_package_docs, which serves one
pack’s SKILL.md and wraps an untrusted pack’s body in
<untrusted-package-docs>: reference, never instructions.
The suspending clock
A chat action that calls a gated tool parks on a person’s answer. Charged to the
same budget, that wait kills the program that asked, and answering then resolves
nothing. createSandboxClock gives the caller clock.suspend(): suspended time
is added back to timeoutMs, suspensions nest, and the engine’s own abort moves
out to timeoutMs + suspendAllowanceMs as the backstop for a prompt nobody
answers. The interrupt handler still cuts a runaway loop at exactly timeoutMs
of running time. The websocket chat runner owns one clock per turn and suspends
it around every tool- and plan-approval round trip.
Other agent surfaces
- Script mode (
ScriptRunner) executes an LLM-authored orchestration script in the sandbox, whereagent(),parallel(),pipeline(),log()andbudgetbridge to real sub-agents on the host. - GraphPlanner runs each
submit_graphprogram in the sandbox with no host access — onlynode()andgraph()— so a malformed or hostile program cannot reach anything. run_codeandjsare the plain code tools. Neither declares packages, so nothing is importable in them; library-backed work goes through a Code node with the package declared.
Failure modes
| Symptom | Cause |
|---|---|
url.searchParams.set() does not affect the parent URL |
QuickJS URL limitation. Build the query with URLSearchParams directly |
A Uint8Array arrives as {"0":137,…} |
A host → guest byte path that skipped the base64 marker convention |
An import resolves nothing |
The run declared no modules, or the specifier is off the allowlist |
setTimeout is not defined |
Deleted deliberately. Use sleep, Promise.all or parallelMap |
A bare identifier is a ReferenceError in a Code node |
Node inputs live on inputs, not in the global scope |
nodetool.* throws naming a tool |
The host has no toolbelt (browser runner, no context) or that tool is not on the belt |
| A CPU-bound loop outlives its cancellation | The signal ends runInSandbox, but the guest loop still runs to the execution timeout |
Extending it
- A new bridge: add it in
buildSandbox, wrap async work inneverReject+guardAbort, return bytes as a base64 marker object, add the name toEXPOSED_BRIDGE_NAMES, and describe it inpackages/agents/src/code-gen/sandbox-manifest.ts. The manifest reads limits and names out ofjs-sandbox.tsrather than restating them, so a prompt derived from it cannot advertise an API the sandbox does not marshal —tests/sandbox-manifest-drift.test.tsholds that line. - A new library: ship a sandbox pack. Guest-side if it compiles under the
admission probe, host-side if it needs Node builtins, a DOM, or a limit the
guest could not enforce. Host implementations live in
packages/agents/src/host-modules/with every safety limit inside them, where nothing can route around them.
Tests: packages/agents/tests/js-sandbox.test.ts (surface, limits, async
concurrency), js-sandbox-modules.test.ts, js-sandbox-wasm.test.ts,
host-modules.test.ts, codeact-executor.test.ts, chat-codeact.test.ts,
nodetool-api*.test.ts, and packages/sandbox-compiler/tests/packs.test.ts for
every shipped pack through the real install path.
Related
- CodeAct design — the action protocol and its research
- Sandbox packages — the pack system, trust model, milestones
- Chat & Agents — the agent surfaces whose actions run here
- Execution strategies — where sandboxed work sits among the run modes