Native Engine
The VM is driven by gondolind, a small static Rust daemon (the native
engine). Your Node.js process (the controller) spawns it and talks to it
over its stdin/stdout. The daemon starts QEMU (or the krun runner), speaks the
guest protocols, runs the network stack and the HTTP/TLS mediation, serves the
guest filesystem from its own mount table, and calls back into the controller
only for the things that must stay in JavaScript (your hooks, custom VFS
providers).
The design has two goals:
- Remote machines.
gondolindneeds neither Node.js nor any other runtime, so it can run on another machine (for example over SSH) while your policy, credentials, hooks and file providers stay in the local controller. - Speed. Packets, TLS and protocol handling stay out of JavaScript.
Earlier releases also had an in-process TypeScript engine (
node). It was removed:VMOptions.engine: "node",GONDOLIN_ENGINE=nodeand--engine nodenow fail with an error, and there is no fallback when nogondolindbinary is found.engine: "native"and"auto"are still accepted (both mean the native engine). See below for what changed for users of the node engine.
The Daemon Binary
The native engine ships as one multi-call binary, gondolin: the
static CLI, and the daemon when started as
gondolin daemon (or under the name gondolind, e.g. through a symlink). The
SDK starts the daemon from whichever binary it finds, in this order:
GONDOLIN_NATIVE_BIN(path togondolinorgondolind)- a development checkout: the newest of
native/target/{release,debug}/gondolinandnative/target/{release,debug}/gondolind - the optional dependency for the platform:
@earendil-works/gondolin-native-darwin-arm64,-darwin-x64,-linux-arm64or-linux-x64(Linux builds are static musl binaries), installed automatically with@earendil-works/gondolinwhere supported; its npmbinlinks the binary asgondolind
When none is found, starting a VM fails. Installs that skip optional
dependencies (npm install --omit=optional, --no-optional) need
GONDOLIN_NATIVE_BIN or the platform package installed explicitly.
QEMU or the krun runner (whichever backend is selected, see VM Backends) is still needed on the machine the daemon runs on.
CLI
The engine options go before the command and apply to everything the command
starts (including bash --resume, --listen and --ssh):
gondolin --native-command "ssh build-box gondolind" --remote bash| Flag | Meaning |
|---|---|
--native-command CMD |
start the daemon with CMD (words, or a JSON array) |
--remote |
the daemon runs on another machine (see below) |
--engine native\|auto |
accepted for compatibility (both select the native engine); --engine node is an error |
gondolind is the npm bin name the platform package installs; where only
the binary was copied, start it as gondolin daemon (for example
--native-command "ssh build-box ~/bin/gondolin daemon").
gondolin list, attach and exec --sock work for sessions of either CLI:
the session registry lives in the controller.
Remote Daemons
VMOptions.native says how to reach the daemon:
import { VM } from "@earendil-works/gondolin";
const vm = await VM.create({
native: {
command: ["ssh", "build-box", "gondolind"],
remote: true,
image: "alpine-base:latest",
},
});| Option | Meaning |
|---|---|
command |
command that starts gondolind with the controller protocol on its stdin/stdout |
connect() |
returns a connection (wins over command) |
remote |
the daemon runs on another machine: no shared filesystem |
image |
image selector for the daemon (name:tag, build id, or a directory on the daemon's machine); default: the daemon's default image |
The same settings are available as environment variables:
GONDOLIN_NATIVE_COMMAND (words or a JSON array), GONDOLIN_NATIVE_REMOTE=1
and GONDOLIN_NATIVE_IMAGE.
What remote changes
With remote: true the controller never sends paths of its own machine to the
daemon (it refuses to, so mistakes fail loudly instead of pointing at the wrong
files):
- Images are resolved and downloaded by the daemon from its own image
cache (the same layout as on a local machine,
~/.cache/gondolin/images).sandbox.imagePathmust be a selector string; explicit asset paths are rejected. - Root disks are prepared by the daemon: the
rootfs.mode(cow,memory,readonly, or the image manifest's default) androotfs.sizework as usual, the copy-on-write overlay lives in the daemon's session directory. - Checkpoints (
vm.checkpoint(),gondolin snapshot) are written on the controller in the usual single-file format: the daemon stops the VM and streams its disk overlay. Resuming uploads the checkpoint to the daemon, which finds the matching image by build id. - TLS interception uses the daemon's own MITM CA (created and cached on the daemon's machine); the guest trusts that one.
- VFS mounts of controller-side directories (
RealFSProvider, also wrapped inReadonlyProvider/ShadowProvider) are served from the controller through the object tier (see Filesystem), since the daemon cannot see those files. To mount a directory on the daemon's machine, usenativeMounts.host("/path/on/daemon"). - SSH egress credentials (
ssh.credentials) are sent to the daemon;ssh.agentis not supported remotely (the agent socket is on the controller).known_hostsfiles are read by the controller and sent as content. - DNS: the controller's resolvers are not used; the daemon uses explicitly
configured servers or its own
/etc/resolv.conf. - SSH and ingress access (
vm.enableSsh(),vm.enableIngress(),--listen) listen on the controller's machine; the streams are tunneled through the daemon connection. - Backend: the default is
qemu, as the controller cannot see whether the daemon's machine has a krun runner and/dev/kvm. Setsandbox.vmm: "krun"(orGONDOLIN_VMM=krun) to use krun; the runner issandbox.krunRunnerPathon the daemon's machine, elsegondolin-krun-runnerfrom itsPATH. vm.getHostPid()returnsnull(the VMM runs on another machine).- Not supported remotely (clear errors):
sandbox.rootDisk*options and thesandbox.virtio*SocketPathoverrides.
Connections
connect() returns an object with this shape (NativeConnection); pi-env's
openProcess returns a compatible object, and spawnNativeConnection(command)
builds one from a local command:
type NativeConnection = {
/** write to the daemon's stdin; `false` or a pending promise means "buffer full" */
write(data: Buffer): boolean | void | Promise<unknown>;
/** daemon stdout */
onStdout(listener: (data: Buffer) => void): void;
/** daemon stderr (diagnostics, optional) */
onStderr?(listener: (data: Buffer | string) => void): void;
/** close the daemon's stdin: it stops the VM and exits */
close(): void | Promise<unknown>;
/** resolves once the daemon exited */
exited: Promise<{ code: number | null; signal?: string | null }>;
/** optional: forcefully terminate, wait for write buffer drain, local pid */
kill?(): void;
waitDrain?(): Promise<void>;
pid?: number | null;
};const vm = await VM.create({
native: {
remote: true,
// `env`: a pi-env environment (or anything returning a NativeConnection)
connect: async () => env.openProcess("gondolind", []),
},
});The daemon exits (and kills its VM) when its stdin closes, so a crashed or killed controller never leaves VMs running behind. On Linux QEMU is also tied to the daemon's lifetime; on macOS a small watchdog process does the same.
Getting the binary onto another machine
getNativeBinaryPath({ platform, arch }) finds a binary for any supported
platform without network access (development checkout, installed platform
package, download cache). ensureNativeBinary({ platform, arch }) also
downloads the matching platform package from the npm registry (checking its
integrity) into ~/.cache/gondolin/native/<version>/<platform>-<arch>/. Both
return { path, args }: the binary and the arguments that start the daemon
(["daemon"] for the multi-call gondolin), so one file is all a remote
machine needs:
import { ensureNativeBinary } from "@earendil-works/gondolin";
const { path, args } = await ensureNativeBinary({ platform: "linux", arch: "x64" });
// copy `path` to the remote machine (e.g. as ~/bin/gondolin), then
// native: { command: ["ssh", host, "~/bin/gondolin", ...args], remote: true }The daemon also brings its own image tooling, which is handy on machines
without Node.js (gondolind ARGS is the same as gondolin daemon ARGS; the
static CLI's gondolin image manages the same cache):
gondolin daemon --version
gondolin daemon assets list
gondolin daemon assets pull alpine-base:latest
gondolin daemon assets resolve --jsonHow the Engine Serves the VM
Network
- Compressed upstream responses (
Content-Encoding: gzip, ...) are forwarded to the guest unchanged. Hooks see decoded bodies (withoutContent-Encoding), likefetchresponses. - The
Hostheader is kept consistent with the request URL: for absolute-form request targets the URL wins over the guest'sHostheader, and redirects re-targetHost. - Hooks (
onRequest,onResponse,isRequestAllowed,isIpAllowed) run in the controller and cost one round trip per registered phase. Request and response bodies are transferred only when a hook reads them;clone()reads independently instead of buffering a second copy. Hooks that are not registered cost nothing; policy fromcreateHttpHooks()options (allowlists, internal ranges, secrets) is evaluated in the daemon, andcreateDjsHooks()policies run in the daemon too (see Tier-2 Hooks). - Guest HTTP egress is always handled by the daemon; there is no pluggable
upstream client (
VMOptions.fetchis ignored). To intercept or replace upstream requests, usehttpHooks(onRequestmay return aResponse). Upstream HTTP proxies (HTTP_PROXY/HTTPS_PROXY) are not supported. - Upstream TLS certificates are verified against the controller's Node.js
default CA store (including
NODE_EXTRA_CA_CERTS); this also holds for remote daemons. The staticgondolinCLI trusts the Mozilla CA list plus the system's PEM roots (SSL_CERT_FILE/SSL_CERT_DIR, the distribution bundle on Linux,/etc/ssl/cert.pemon macOS) andNODE_EXTRA_CA_CERTS. It does not read the macOS keychain: roots installed only there (e.g. a corporate TLS proxy's CA) need to be exported, e.g.security find-certificate -a -p -c "My Corp CA" > ca.pemandNODE_EXTRA_CA_CERTS=ca.pem.
Filesystem
The daemon serves the guest filesystem (/data and the bind mounts) from its
own mount table. VMOptions.vfs.mounts is translated per mount:
| Provider | Served by |
|---|---|
RealFSProvider(dir) |
the daemon, as a hardened host directory (local daemons) |
ReadonlyProvider(x), ShadowProvider(x, { shouldShadow: createShadowPathPredicate(paths) }) |
the daemon, as read-only / hidden-path rules around x |
nativeMounts.memory() |
the daemon, as an in-memory filesystem |
nativeMounts.host(dir, { readonly, hide }) |
the daemon, a directory on the daemon's machine (also remote) |
createObjectMount(provider, options) / nativeMounts.object(...) |
the controller's ObjectProvider, cached by the daemon |
anything else (MemoryProvider, custom providers, ShadowProvider with a custom predicate) |
the controller's JS provider, through the object tier |
Transport. With a QEMU that supports vhost-user (-device help lists
vhost-user-fs-pci), the guest mounts the tree over virtio-fs: the guest
kernel talks to the daemon directly through shared memory, which makes
metadata operations about 2–4× and large reads and writes 10–15× faster than
sandboxfs (FUSE in the guest, forwarded over a virtio-serial port). Linux
distribution QEMU builds support it; Homebrew's QEMU on macOS does not
(native/crates/gondolin-virtiofs/examples/linux-guest/build-qemu-macos.sh
builds one). With krun, the runner's libkrun (gondolin's patched build,
runner capability virtiofs-socket) provides the virtio-fs device and
forwards each FUSE request to the daemon over a socket: no shared memory, so
it works on macOS too; large I/O is slower than vhost-user (one request at a
time, copied through the runner) but several times faster than sandboxfs.
Without either, the guest uses sandboxfs as before.
GONDOLIN_VIRTIOFS=0 forces sandboxfs, GONDOLIN_VIRTIOFS=1 asks for
virtio-fs and logs a warning when it is unavailable. Guest images whose
sandboxfs has no --virtiofs option (built before this release) always use
sandboxfs.
Native mounts cost no controller round trips. Host directories keep the
RealFSProvider hardening: nothing resolves outside the directory (with
openat2(RESOLVE_BENEATH) on Linux and a component-wise O_NOFOLLOW walk
elsewhere), escaping or dangling symlinks behave as missing, and setuid/setgid
bits are dropped on chmod.
The object tier keeps your JS provider the source of truth: the daemon
serves file handles, offsets and attributes from a cache and calls the
provider with whole-file operations (stat, readDir, readFile ranges,
writeFile, unlink, rename, mkdir, ...). For ordinary VirtualProviders
(a MemoryProvider you also use from the host, custom providers) every guest
write is forwarded before the write returns, metadata is cached for 1 s (as
the guest kernel does anyway) and contents are revalidated on every open, so
changes made on the host are seen by the guest. Each uncached operation is
one round trip to the controller, so metadata-heavy work on such mounts is
slower than on native mounts; prefer native mounts for scratch space.
For providers that naturally work on whole files (APIs, object stores),
implement ObjectProvider and mount it with createObjectMount():
import { VM, createObjectMount, nativeMounts, type ObjectProvider } from "@earendil-works/gondolin";
const notes: ObjectProvider = {
async stat(path) { /* { kind: "file" | "dir" | "symlink", size, mode?, mtimeMs? } */ },
async readDir(path) { /* [{ name, kind }] */ },
async readFile(path, range) { /* bytes (the range or the whole file) */ },
async writeFile(path, data, options) { /* replace the whole file (options?.mode) */ },
// optional: unlink, rename, mkdir, rmdir, truncate, chmod, symlink, readlink, link, write
};
const vm = await VM.create({
vfs: {
mounts: {
"/notes": createObjectMount(notes, { cacheTtlMs: 5000, commit: "close" }),
"/scratch": nativeMounts.memory(),
"/srv": nativeMounts.host("/srv/project", { readonly: true, hide: ["/.env"] }),
},
},
});Options: writable (default: when writeFile exists), enumeration
("list", "materialized": only entries the guest has seen, "none"),
commit ("close": the whole file is written when the guest closes it, the
default; "write": every write is forwarded), cacheTtlMs,
revalidateOnOpen, maxCacheBytes and maxFileBytes. Large files are read
lazily in 1 MiB ranges and written back in 1 MiB chunks of one writeFile.
On the host side (vm.fs, or using the returned provider directly) the mount
is an ObjectVirtualProvider: files are read on open and written back on
close.
vm.fs reads and writes daemon-only mounts (nativeMounts.memory(),
daemon-side host directories, object mounts) through the daemon; this starts
the VM if it is not running. Other paths use your JS providers directly.
Behaviour notes:
- VFS hooks (
vfs.hooks):beforehooks cost a round trip per hooked operation (only when you register one);afterhooks are called asynchronously, after the guest got its reply, and theirresultis the error or the transferred byte count. - Close errors: with
commit: "close"over virtio-fs, a failingwriteFileis the error of the guest'sclose()orfsync(). Over sandboxfs (no vhost-user QEMU, an older krun runner) it cannot be: the guest's FUSE helper does not forward those calls. The data then stays cached and is written again when the next writer closes the file; the failure is reported as an error of thereleaseoperation (debug output andafterhooks). - Stricter native mounts: hard links work on mounts with hidden paths,
a read-only
MemoryProvidertranslated to a static mount rejects every write, and in-memory renames follow POSIX rules (a file cannot replace a directory and so on).
Errors and diagnostics
- Some configuration errors are reported when the VM starts rather than when
VM.create()returns, because the daemon validates them on its machine (for example SSH credential files, a missing QEMU binary, an unknown image). - Explicit
sandbox.virtio*SocketPathoptions are rejected (the daemon owns its private socket directory).
Process model
- The VMM runs under the daemon (controller →
gondolind→ QEMU or the krun runner). Starting a VM costs one process spawn and handshake for the daemon (a few milliseconds).
Tier-2 Hooks
Hooks normally run in your Node process: the daemon sends each registered
phase to the controller and waits for the answer (tier 3). A tier-2 policy
is self-contained JavaScript that the daemon runs itself, on djs (Earendil's
embeddable JavaScript engine, compiled into gondolind). It needs no
controller round trip, so it also works with the static gondolin CLI and
with remote daemons, and costs far less per request. The declarative
createHttpHooks() options (tier 1) remain the cheapest and should be
preferred for anything they can express.
import { VM, createDjsHooks, createHttpHooks } from "@earendil-works/gondolin";
const policy = `
const DENY = ["/admin", "/internal"];
async function onRequest(request) {
const url = new URL(request.url);
if (DENY.some((p) => url.pathname.startsWith(p))) {
throw new HttpRequestBlockedError("path not allowed", 403);
}
if (request.method === "POST") {
const body = await request.clone().text();
if (body.includes("DROP TABLE")) return new Response("no", { status: 400 });
}
request.headers.set("x-sandbox", "gondolin");
}
function onResponse(response) {
const r = new Response(response.body, response);
r.headers.delete("server");
return r;
}
`;
const { httpHooks, env } = createHttpHooks({
allowedHosts: ["api.example.com"],
secrets: { API_KEY: { hosts: ["api.example.com"], value: process.env.API_KEY! } },
...createDjsHooks(policy),
});
const vm = await VM.create({ httpHooks, env });The static and Node CLIs take a policy file with --hooks-script FILE
(.js a script, .mjs an ES module, .ts/.mts a TypeScript module). For
modules, the CLI reads the files the policy imports with relative specifiers
(./lib/hosts.ts, ./deny.json), transitively from next to the script, and
sends them along; the daemon itself never reads files. Bare specifiers and
imports above the script's directory are not resolved
(djsHooksOptionsFromFile(file) does the same for SDK users):
gondolin bash --allow-host api.example.com --hooks-script policy.jsThe contract. A policy defines (script) or exports (module, kind:
"module" or "typescript") any of isRequestAllowed(request), isIpAllowed(info),
onRequest(request) and onResponse(response, request), with the same
meaning and return values as HttpHooks (see
SDK: Network Access). Only the phases it defines are
registered, so a policy with only onResponse keeps the request fast path.
The environment offers Request, Response, Headers, URL,
URLSearchParams, HttpRequestBlockedError, TextEncoder/TextDecoder,
atob/btoa, console (written to the daemon log, visible with the net
debug flag) and the standard JavaScript built-ins. Bodies stay in the daemon
until the policy reads them (text(), json(), bytes(), arrayBuffer());
an unread body costs nothing and is streamed through. As with tier-3 hooks,
forwarding a request whose body the hook consumed is refused (400): read
from request.clone(). Compressed responses (gzip, deflate) are decoded
for onResponse, as on tier 3.
createDjsHooks() options:
| option | meaning |
|---|---|
source |
the policy (or pass the source string directly) |
kind |
"script" (default), "module" or "typescript" (an ES module whose types are erased, not checked) |
filename |
name in stack traces, the module's key (default policy.js / policy.mjs / policy.ts) |
modules |
{ key: source } a module policy can import (./lib.js resolves against filename); the key's extension picks the language: .ts/.mts/.cts TypeScript, .json a JSON module (import deny from "./deny.json" with { type: "json" }), else JavaScript. There is no file or network access: anything else must be bundled into the policy |
limits |
{ fuel, memoryBytes, timeoutMs } per call (defaults 50M fuel ≈ 0.2 s of interpretation, 64 MiB heap, 30 s wall clock including body reads) |
Evaluation. The daemon compiles the policy once, when the VM is
configured; a syntax error, a missing import or an exception at the top level
fails VM.create()/the CLI before boot. Every call then starts from that
compiled state: globals a call changes are gone in the next call, nothing is
shared between requests or VMs. Builtins are frozen. Date.now() is the
daemon's clock at the start of the call.
Fail closed. An exception, a rejected or never-settling promise,
exhausting fuel, memory or time, a malformed return value (an invalid header,
a non-HTTP URL, a bad status) or an engine error deny the request with
502 Bad Gateway, like a throwing tier-3 hook. HttpRequestBlockedError
answers with its own status. The budgets cannot be caught by the policy.
Order of tiers. Declarative policy first (head validation, and for
policies without onRequest the early allowlist/internal-range check), then
tier 2, then tier 3, in every phase: isRequestAllowed/isIpAllowed must
allow in both (the first denial wins), onRequest of tier 3 sees the request
as tier 2 left it (including a replaced body), and a tier-2 Response or
block ends the phase. Secrets are substituted after all onRequest hooks, so
hooks see placeholders, not secret values, and placeholders a hook adds are
substituted too. The allowlist and internal-range checks run again on the
rewritten request.
Restrictions compared with Node hooks:
- No closures over your program's state, no Node or npm APIs (
fs,process,Buffer,require): state comes from the source (generate it with your configuration) or frommodules. - No outbound I/O: no
fetch(), timers (setTimeout),AbortSignal,crypto.subtleorcrypto.randomUUID. - No streams: bodies are whole values within the body limits and the policy's
heap;
request.body/response.bodyis an opaque handle that can only be passed on (new Response(response.body, response)keeps the original body), andformData(),blob()andReadableStreamare missing. Fetch-onlyRequestfields (signal,cache,mode,credentials, ...) are absent. instanceofchecks against Node's own classes do not apply;URL,Headersand friends are the daemon's implementations (URLs parse exactly as egress parses them).- Calling the returned functions directly in your process (outside a VM) runs
a script policy in a
node:vmcontext with Node's own fetch classes (globals persist between calls there); module policies only run in the daemon and throw. A daemon without tier-2 support (older releases, or built without thedjs-hooksfeature) refuses such a configuration instead of ignoring it.
Typical cost on an Apple M5 Max (shared machine; host/bench, guest-measured
per request): a no-op onRequest/onResponse pair adds about 0.23 ms per
request as tier 2 against 0.29 ms as tier 3, a
policy reading 32 MiB bodies keeps ~415 MB/s downloads (tier 3: ~325 MB/s).
In process, an empty call is ~8 µs and a header allowlist policy ~38 µs
(cargo run --release -p gondolin-hooks-djs --example bench); research notes
and engine details are in native/docs/djs-hooks.md.
Troubleshooting
native engine binary not found: build it in a checkout (make native,
or cargo build --release -p gondolin-cli in native/), install the platform
package, or set GONDOLIN_NATIVE_BIN.
the node engine has been removed: drop engine: "node",
GONDOLIN_ENGINE=node or --engine node (see
Migrating from the node engine).
not supported by the native engine yet: ... or not supported with a
remote native daemon: ...: the option cannot be honoured; the message names
it.
remote native session must not send a controller-local path: in remote
mode something resolved to a path on the controller (typically
sandbox.imagePath pointing at a local directory). Use an image selector the
daemon can resolve, or the daemon's own default image.
Debug output: GONDOLIN_DEBUG (or VMOptions.sandbox.debug) takes the
flags net, exec, vfs, protocol, all (see
Debug Logging). The flags are passed to the daemon, which
forwards its events (QEMU output with protocol, network tracing with net)
to your debug callback. Lines the daemon writes to its stderr arrive as the
error component prefixed with gondolind:.
Inspecting the daemon:
# QEMU command line the daemon would use for a configuration (JSON on stdin)
echo '{"vm": {"qemu_path": "qemu-system-aarch64", "kernel_path": "/k", "initrd_path": "/i", "memory": "1G", "cpus": 2}}' \
| gondolind --qemu-args
# load or create the MITM CA in a directory and print its certificate
gondolind --mitm-ca ~/.cache/gondolin/sslMigrating from the Node Engine
The in-process TypeScript engine was removed. What changes for code that used
it (engine: "node", GONDOLIN_ENGINE=node, or the old automatic fallback
when no gondolind was found):
- Remove the engine selection.
engine: "node"throws; make sure the platform package (@earendil-works/gondolin-native-*) is installed, or setGONDOLIN_NATIVE_BIN. SandboxServerandFsRpcServiceare no longer exported; useVM.VMOptions.fetchis ignored: it was the node engine's upstream client. Intercept or replace upstream requests withhttpHooks.onRequest(it may return aResponse). There is no upstream HTTP proxy support.- Custom JS
VirtualProvidermounts (MemoryProvider, your own providers) are reached through the object tier (see Filesystem): each uncached operation is a round trip to your process. Prefer native mounts (RealFSProvider,nativeMounts.memory(),nativeMounts.host()) orcreateObjectMount()where you can. - Compressed upstream responses reach the guest compressed (hooks still see
decoded bodies), VFS
afterhooks run asynchronously and get an error or byte count, and the internal debugging switchesGONDOLIN_NATIVE_NETandGONDOLIN_NATIVE_VFSare gone.
For the protocol between controller and daemon and the internals, see
native/DESIGN.md.