Skip to content

Repository files navigation

spawnbox

Spawn AI agent sandboxes across pluggable backends. Node.js / Bun module with streaming exec, file IO, host file copy, and drop-in connectors for the Vercel AI SDK, Vercel Eve, and Flue.

Two backends ship today, both behind one backend-agnostic Sandbox façade:

  • orbstackOrbStack persistent Linux VMs. Distro-based, cheap copy-on-write clone, real start/stop lifecycle. The better fit for repeated agent sessions.
  • apple — Apple's container CLI (the containerization framework). Image-based, requires macOS 26 on Apple silicon. No clone (templating is via images).

Pick one explicitly, or let "auto" detect what's available (prefers OrbStack, falls back to Apple). Add your own backend with registerDriver().

Requirements

  • Node 20+ or Bun 1.0+.
  • At least one backend:
    • OrbStack installed and running, or
    • Apple container available (macOS 26, Apple silicon).

Install

bun add spawnbox
# or
npm i spawnbox

Quick start

import{Sandbox}from"spawnbox";// driver: "auto" (default) picks the first available backend.constsb=awaitSandbox.create({distro: "alpine",isolated: true,isolateNetwork: true});constr=awaitsb.exec(["echo","hello"]);// bufferedconsole.log(r.stdout);constproc=sb.spawn("for i in 1 2 3; do echo $i; sleep 0.5; done",{shell: true});proc.on("stdout",(chunk)=>process.stdout.write(chunk));awaitproc.done;awaitsb.writeFile("greeting.txt","hi\n");// relative -> /workspace/greeting.txtconstback=awaitsb.readTextFile("greeting.txt");awaitsb.destroy();

Choosing a backend

// Explicit OrbStack VM (distro-based).constorb=awaitSandbox.create({driver: "orbstack",distro: "ubuntu"});// Explicit Apple container from a distro (mapped to a Docker Hub image)…constapple=awaitSandbox.create({driver: "apple",distro: "alpine"});// …or from an explicit OCI image (image-source drivers only).constfromImage=awaitSandbox.create({driver: "apple",image: "node:22-bookworm"});// Let spawnbox detect. OrbStack wins if running; otherwise Apple.constauto=awaitSandbox.create({driver: "auto"});// "auto" is the default

distro works on both drivers. On Apple the distro name maps to a Docker Hub library image (alpine, ubuntu, archlinux, …); pass image to override. On OrbStack, image is meaningless and passing it throws DriverUnsupportedError — fail loud, never silently ignore an option you set on purpose.

Capabilities

Each driver declares what it can do. Ask for something it can't and you get a DriverUnsupportedError, not a silent no-op:

capabilityorbstackapple
distroSourceyesyes (distro → image)
imageSource (OCI image)noyes
clone (copy-on-write)yesno
networkIsolationyesyes
mountsyesyes
pauseResumeyesyes
import{resolveDriver}from"spawnbox";constdriver=awaitresolveDriver("auto");if(driver.capabilities.clone){awaitSandbox.clone("base","session-1",{driver: driver.name});}else{awaitSandbox.create({driver: driver.name,image: "my-template:latest"});}

Custom drivers

Implement SandboxDriver, register it under any name, and it joins auto-detection (registration order = priority):

import{registerDriver,typeSandboxDriver}from"spawnbox";constdocker: SandboxDriver={name: "docker",capabilities: {distroSource: false,imageSource: true,clone: false,networkIsolation: true,mounts: true,pauseResume: true},asyncisAvailable(){/* cheap, non-throwing probe */returntrue;},asyncpreflight(){/* throw DriverNotInstalled/NotRunning if unusable */},asynccreate(name,cfg){/* ... return a DriverHandle */},asyncattach(id){/* ... */},asyncclone(source,newName){/* throw DriverUnsupportedError if !clone */},asynclist(){return[];},asyncexists(id){returnfalse;},};registerDriver("docker",()=>docker);constsb=awaitSandbox.create({driver: "docker",image: "alpine"});

Declare capabilities honestly and gate the ones you don't support with DriverUnsupportedError. The façade and connectors consult capabilities before attempting an operation, so honesty there is what makes graceful fallback work.

Vercel AI SDK integration

import{generateText}from"ai";import{Sandbox,toAiSandbox}from"spawnbox";constsb=awaitSandbox.create({distro: "alpine"});constresult=awaitgenerateText({model: ...,prompt: "Run `uname -a`",experimental_sandbox: toAiSandbox(sb),});awaitsb.destroy();

toAiSandbox(sandbox) (or new AiSandboxSession(sandbox)) returns an object structurally compatible with Experimental_SandboxSession from @ai-sdk/provider-utilsrun, spawn, readFile, readBinaryFile, readTextFile, writeFile, writeBinaryFile, writeTextFile, removePath, resolvePath, setNetworkPolicy, id, description.

Vercel Eve integration

sandboxBackend() returns a SandboxBackend that satisfies Eve's contract. The driver comes from create.driver (defaults to auto-detection):

import{defineSandbox}from"eve/sandbox";import{sandboxBackend}from"spawnbox";exportdefaultdefineSandbox({backend: sandboxBackend({create: {driver: "auto",distro: "ubuntu",isolated: true,memory: "4G",cpus: 2},}),asyncbootstrap({ use }){constsb=awaituse();awaitsb.run({command: "apt-get update && apt-get install -y ripgrep curl"});},asynconSession({ use }){constsb=awaituse();awaitsb.writeTextFile({path: "session-marker.txt",content: "ready"});},});

Eve seed files in agent/sandbox/workspace/ land at /workspace. Relative paths in file methods anchor to /workspace (same as Eve and the AI SDK's sandbox surface).

What you get from the backend

  • prewarm() builds a template from your bootstrap hook + seed files, then stops it so clones see a quiescent snapshot. Idempotent across runs via the template name. On drivers withoutclone support it skips baking a template entirely — sessions just provision fresh.
  • create() reattaches an existing sandbox (from existingMetadata), clones the template (near-free on OrbStack's copy-on-write snapshots), or provisions fresh — picking whichever is possible for the active driver.
  • dispose() deletes the per-session sandbox. The template stays for the next session.

orbstack() is kept as a deprecated alias for sandboxBackend() that pins driver: "orbstack". Use sandboxBackend({ create: { driver: "orbstack" } }) instead.

Network policy

Network isolation is decided at create time on both backends (OrbStack --isolate-network, Apple --network none), so setNetworkPolicy() accepts only "allow-all" / "deny-all", and only when they match how the sandbox was created. Anything else throws — better to fail loud than silently pretend to enforce. For fine-grained allowlists you'd need a firewall sidecar (open an issue).

Flue integration

flue() returns a SandboxFactory matching Flue's Sandbox Adapter API. The driver comes from create.driver. Wire it into the provider() function Flue calls:

// agent/sandbox/provider.tsimport{createSandboxSessionEnv}from"@flue/runtime";import{flue}from"spawnbox";exportfunctionprovider(_sandbox: unknown){returnflue({createSessionEnv: createSandboxSessionEnv,create: {driver: "auto",distro: "ubuntu",isolated: true,isolateNetwork: true},bootstrap: async(sb)=>{awaitsb.exec("apt-get update && apt-get install -y ripgrep curl",{shell: true});},});}

What you get:

  • createSessionEnv({ id }) provisions an isolated sandbox per Flue session. On drivers with cheap snapshots it clones from a prewarmed template (near-instant after the first call); on drivers without clone, every session provisions fresh.
  • The exposed SandboxApi covers readFile / readFileBuffer / writeFile / stat / readdir / exists / mkdir / rm / exec. Relative paths anchor at /workspace.
  • exec() honours cwd, env, timeoutMs, and signal (mid-flight AbortSignal cancels the underlying process).
  • dispose() on the returned SessionEnv deletes the per-session sandbox. The template stays around for the next session.

@flue/runtime is an optional peer dependency — install it only if you actually use Flue. flue() also works without createSessionEnv and returns a structurally-compatible { id, api, dispose } directly.

For single-shared-sandbox setups (no session-per-id provisioning), wrap an existing Sandbox:

import{Sandbox,SandboxFlueApi}from"spawnbox";constsb=awaitSandbox.create({distro: "alpine"});constapi=newSandboxFlueApi(sb);// SandboxApi-compatible

OrbboxFlueSandboxApi remains as a deprecated alias for SandboxFlueApi.

API reference

Sandbox.create(config?)

fieldtypedefaultnotes
driver"auto" | "orbstack" | "apple" | string"auto"which backend; "auto" detects (OrbStack, then Apple)
namestringrandom spawnbox-…sandbox name
distroDistro"ubuntu"distro name; on Apple maps to a Docker Hub image
versionstringdistro/image defaulte.g. "24.04"
imagestringunsetOCI image ref. Image-source drivers only; wins over distro. Throws on orbstack
arch"arm64" | "amd64"host arch
userstringbackend defaultdefault sandbox user
memorystringunsete.g. "4G"
cpusnumber | stringunset
diskstringunsete.g. "64G" (orbstack)
isolatedbooleanfalsedisables host file sharing / integration
isolateNetworkbooleanfalseblocks LAN + host IPs; requires isolated
forwardSshAgentbooleanfalseonly with isolated
mountsArray<string | { source, dest? }>[]host→guest mounts (isolated only)
userDataPathstringunsetcloud-init user data (orbstack)

Sandbox.attach(name, { driver? }) / Sandbox.clone(source, newName?, { driver? }) / Sandbox.list({ driver? })

clone throws DriverUnsupportedError on drivers without copy-on-write snapshots (Apple). Check capabilities.clone or fall back to create.

sandbox.exec(command, options?)

Returns { stdout, stderr, exitCode, signal, durationMs, args }.

optiontypenotes
shellbooleanrun the string via sh -lc
userstringoverride the run-as user
workdirstringworking directory
envRecord<string,string>extra env (forwarded per-driver)
stdinstring | Bufferpiped into the process
timeoutMsnumberkill after N ms
throwOnNonZerobooleandefault true (exec), false (spawn)

sandbox.spawn(command, options?)

Returns a SpawnHandle:

interfaceSpawnHandle{process: StreamingProcess;stdout: NodeJS.ReadableStream;// utf-8stderr: NodeJS.ReadableStream;stdin: NodeJS.WritableStream;done: Promise<ExecResult>;kill(signal?: NodeJS.Signals): void;on(event: "stdout"|"stderr",listener: (chunk: string)=>void): SpawnHandle;}

File IO

sandbox.writeFile(path,content)// string | Buffer | Uint8Arraysandbox.readFile(path)// -> Buffer | nullsandbox.readTextFile(path,{encoding? })sandbox.removePath(path,{recursive?,force? })sandbox.resolvePath(path)// anchors relative paths under /workspacesandbox.push(hostPath,sandboxPath)// host -> sandboxsandbox.pull(sandboxPath,hostPath)

Binary reads round-trip via base64 internally to avoid utf-8 corruption of raw bytes.

Lifecycle

sandbox.start()/stop()/restart()sandbox.info()sandbox.destroy()// idempotentsandbox.isDestroyedsandbox.driver// active driver name

Driver registry

import{resolveDriver,registerDriver,listDriverNames}from"spawnbox";

Errors

All thrown errors descend from SpawnboxError:

  • DriverNotInstalledError — backend CLI not on PATH
  • DriverNotRunningError — backend service installed but stopped
  • DriverUnsupportedError — driver can't do the requested op (carries driver, operation)
  • DriverNotFoundError — no driver available, or unknown driver name
  • CommandError — backend CLI exited non-zero (carries args/stdout/stderr/exit/signal)
  • SandboxExistsError — name already taken
  • SandboxNotFoundError — name doesn't exist (or was destroyed)
  • ValidationError — config rejected by schema (carries .issues[])
  • ExecKilledError — killed by signal / timeout

Deprecated aliases (kept one release cycle): OrbboxErrorSpawnboxError, OrbCommandErrorCommandError, OrbNotInstalledErrorDriverNotInstalledError, OrbNotRunningErrorDriverNotRunningError.

Testing

Unit tests run against the real backend CLIs for output parsing (no mocks). E2E tests provision real sandboxes and tear them down:

bun test# unit
SPAWNBOX_E2E=1 bun run test:e2e # e2e (creates real sandboxes)

Env overrides: SPAWNBOX_ORB_BIN (orbctl path, falls back to the old ORBBOX_ORB_BIN), SPAWNBOX_CONTAINER_BIN (Apple container path).

Isolation caveats

OrbStack VMs are real Linux kernels; Apple container sandboxes are lightweight VMs from the containerization framework. Neither default is a hardened multitenant boundary. For an actual sandbox, pass isolated: true and ideally isolateNetwork: true at create time — these can't be flipped after the fact.

This is sufficient for "don't let the agent rm -rf ~". It is NOT safe for hostile, internet-supplied code without additional layers.

The Apple driver's CLI flags are a best-effort mapping against Apple's documented container command reference and may need verification on a macOS 26 host. Any drift is contained to src/drivers/apple/index.ts.

License

MIT

About

Programmatically run sandboxes using OrbStack or Apple Containers

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages