Execution backends
The core, novel work: run a repo's real commands against a RAM filesystem, isolated from the host. Three pluggable backends implement one ExecBackend interface; the orchestrator picks via BackendType (auto | native | vfs | os). auto resolves to native today — no non-native backend has beaten the gates by default.
How it works
flowchart TB
cmd["exec(command)"] --> which{backend}
which -->|native| spawn["spawn on host<br/>(the baseline + fallback)"]
which -->|vfs| parse{"parseNodeInvocation<br/>recognised?"}
parse -->|"node -e / --eval"| inproc["runNodeInProcess<br/>vm.runInThisContext"]
parse -->|"node <file> (no args)"| inproc2["runNodeInProcess<br/>require(file)"]
parse -->|"anything else — it falls back"| spawn
inproc --> mount["real cwd, in-process<br/>streams/exit/require patched, restored after"]
inproc2 --> mount
which -->|os| host{"host?"}
host -->|Linux| bwrap["bwrap --unshare-all<br/>RO lower + tmpfs upper over cwd"]
host -->|win32| wsl["wsl.exe --exec bwrap …<br/>paths via wslpath, memoized"]
host -->|"unsupported<br/>(no bwrap / no WSL node)"| throw["throw — never a silent<br/>un-isolated run"]
wsl --> bwrap
bwrap --> store["bind-mount .virrun/store/pnpm<br/>(shared dep store)"]
native backend — the baseline and the fallback
Runs the command on the host, unchanged. A command string goes through the shell; an argv array never does, so a command built from data cannot be reinterpreted as shell syntax. On win32 that argv still has to reach a .cmd shim — pnpm, npx, every node_modules/.bin entry — which a direct spawn cannot, so every argv spawn goes through cross-spawn: it resolves the file through PATHEXT and runs a shim under cmd.exe with each argument escaped. That escaping leaves a CR or LF bare, and cmd.exe ends the command at one even inside quotes, so spawnHidden refuses a win32 argv carrying a line break rather than let the rest run as a second command. Without it, virrun -- pnpm … could not run at all on a Windows host whose sandbox was unavailable, which is exactly when the fallback is needed.
vfs backend — in-process, pure npm
Runs JS workloads in-process so the virtual filesystem intercepts their fs calls and module loading. A shell-aware tokenizer parses the invocation; inline code (node -e/--eval) runs via vm.runInThisContext, a file (node <file>, a lone non-flag path, no script args) via require — both against the real working directory. Nothing is mounted over it: a vfs mount lives in a reserved namespace of its own and never shadows a real path (below), so until a layered provider can serve the cwd from inside a mount, the backend buys in-process speed, not a virtual filesystem. Process streams/exit and require are patched for the run and restored after; the require cache is cleared back to its pre-run state so each run re-executes like a fresh process.
Correctness is preserved by falling back to native for anything not run faithfully in-process — shell features, other flags, file runs with args, syntax errors, uncaught errors, async results, missing files — so the observable result always matches the baseline. Opt-in only: it runs code in the host process with no isolation, so Auto never selects it.
The virtual filesystem underneath
The FS layer is reused, not built. Node is standardizing a core node:vfs module (nodejs/node#61478); @platformatic/vfs (MIT) is the same work extracted to userland. virrun depends on platformatic behind a thin internal interface (FsProvider) — one module owns the import, so the swap to node:vfs once it needs no --experimental-vfs flag is a single-file change. It provides fs API compatibility (read/write/streams/promises/symlinks/watchers), mounting, and module loading from virtual files. Since 0.5 it mounts the way core does: mount() takes no path and returns a mount point inside a reserved namespace under os.devNull that cannot hold real entries, so every path is served by exactly one VFS or by the disk, never both. Core rejects an overlay at a real path as ambiguous, not as a security measure — its docs say VFS is no security boundary either. Core's open layered-mount work (nodejs/node#66235, a composable provider stacking a memory layer over a RealFSProvider with copy-up on write) is what would let the in-process runner serve a RAM overlay of the cwd from inside a mount.
Usage contract: the provider's own paths are rooted at /, and mount() returns where the process sees them — the provider's /a.js is <mount point>/a.js. Write before or after mounting; a real path of the same name is never touched. dispose() unmounts and is safe to call when already torn down.
Hard limit: in-process JS only. Child processes and native binaries bypass it with raw syscalls — the subprocess wall in architecture. Closing that gap is the os backend's job.
os backend — native, generic (Linux core, WSL2 bridge)
Makes every process, including spawned native binaries, see the RAM FS by moving the filesystem and isolation down to the OS:
- RAM filesystem —
bubblewrapsupplies it directly:--overlay-src <cwd>is the read-only lower (the source) and--tmp-overlay <cwd>overmounts the working directory with an overlay whose upper is an invisibletmpfs. Reads hit the real source; all writes (node_modules, build output) stay in RAM. - Isolation — bubblewrap: one unprivileged tool collapses overlay + tmpfs + namespaces (
--unshare-all,--die-with-parent). Unlikevfs, theosbackend never falls back to native at run time: an unsupported host throws, because a silent un-isolated run would be a wrong answer disguised as success. (Backend selection may still degrade to native before a run starts — and says so on stderr, because the sandbox's own lines are the only other trace of it.) - Dep store —
.virrun/store/pnpmis created lazily in the consuming repo, gitignored, bind-mounted writable into the sandbox, and exposed through pnpm env so deps download once; why imports copy rather than hardlink is cache. - Package-manager bootstrap —
COREPACK_HOMEpoints at.virrun/store/corepack, bound writable into every run, the install and the ordinary commands alike; theEROFSit prevents under a read-only/is cache's. - Guest toolchain (win32) —
wsl.exe --execskips the login + rc files, so a profile-bound version manager's node is offPATH.readWslLoginEnvironmentcaptures thePATHa real interactive login shell sees — plus thatPATH's node version, which is the node the sandbox actually runs and what the run banner reports. The capture is persisted host-side and checked against the distro before every reuse, so a bump that removes the captured install can't silently pin the sandbox to the old version; a switch that leaves it on disk is the one a stat can't see, and the six-hour age bound andVIRRUN_FORCE_PROBEare what cover that (cache). - Sandbox
PATH(win32) — two parts, both stated rather than inherited. First the repo's ownnode_modules/.binat the logical/mnt/<drive>path the overlay is mounted at — never the ext4 mirror's, which excludesnode_modulesby construction and so names a directory that does not exist. Then the captured loginPATHwith every Windows drive mount dropped: WSL interop appends the launching process's whole WindowsPATHto a login shell, and none of it can serve a Linux sandbox — each entry holds win32 binaries or anshshim that execs a Windows node, which is what turned a missing Linux toolchain into a crypticexec: node: not foundrather than a plain command-not-found. Dropping them also makes the capture launcher-independent:pnpm runputs the repo's bin on the WindowsPATHand a bare terminal does not, and before the repo bin was named outright that leak was the only reason a bareoxlintresolved at all. - Windows bridge — on win32,
createOsBackendinvokeswsl.exe --exec bwrap ...against the same bwrap argv. Windows cwd and bind paths are translated once throughwslpath(memoized), and pnpm store env is translated before entering Linux, so the public backend contract stays unchanged. Source reads come from an ext4 mirror, not/mnt/c. - macOS bridge — deferred; there is no WSL equivalent to target.
- What a missing status block means — bwrap reports its own setup through
--json-status-fd, so a child that closes without one never got that far. Only one of the reasons is bubblewrap. On win32 the child is thewsl.execlient and the script it runs has two preludes ahead of the sandbox — the source-mirror sync and the sharedflockover the mirror lower — each of which prints its own marker line before exiting, and any run can simply be killed from outside, which prints nothing at all and arrives as node'ssignalor as a 128+n exit status.getNoStatusFailureHeadlinereads those four cases apart. Naming them matters most for the kill: a run that another process TERMs reports a sandbox-setup failure with an empty stderr, so blaming bubblewrap for it throws away the one fact that explains it.
The acceptance test that proves the subprocess wall is broken: pnpm install on a repo with a native postinstall (sharp or esbuild) completes fully in RAM, isolated from the host, and the resulting node_modules is invisible to the real disk. It is parked as describe.todo and run on demand (correctness).
Key files
Paths relative to packages/virrun/src/.
| File | Role |
|---|---|
models/exec/ExecBackend.ts | interface every backend implements |
services/exec/native/createNativeBackend.ts | native passthrough (the baseline + fallback) |
services/exec/vfs/createVfsBackend.ts | parse-and-delegate: in-process when recognised, else native |
services/exec/vfs/parseNodeInvocation.ts | recognise node -e/--eval and node <file> |
services/exec/vfs/runNodeInProcess.ts | in-process runner against the real cwd |
models/vfs/FsProvider.ts | internal FS interface the runtime codes against |
services/vfs/createPlatformaticFsProvider.ts | adapter over @platformatic/vfs; the lone import = the node:vfs swap shim |
services/exec/os/createOsBackend.ts | chooses Linux bwrap or Windows/WSL bwrap |
services/exec/bwrap/createLinuxOsBackend.ts | spawns commands inside the Linux bwrap RAM overlay |
services/exec/wsl/createWslOsBackend.ts | spawns Linux bwrap through wsl.exe on Windows |
services/exec/bwrap/buildBwrapArgs.ts | pure builder for the bwrap overlay argv |
services/exec/bwrap/getNoStatusFailureHeadline.ts | names the failure behind a child that reported no bwrap status |
services/exec/os/checkIsOsBackendSupported.ts | Linux/WSL + bubblewrap availability check |
Notes
- Native-binary support across platforms is impossible in pure JS; the
osbackend is Linux-core and bridged elsewhere — accepted, see the platform table in architecture. - The shell layer (just-bash parser/builtins) is optional sugar for running shell scripts; it is not an exec engine and never spawns native binaries (pure-JS exec).
- Do not use just-bash's FS abstraction — platformatic is node's fs, not a parallel one, so in-process tooling and the module loader see virtual files for free.