A dumb firecracker: real, disposable machines for versioned agent workspaces
Project description
dud 🧨
A dumb firecracker. Real, disposable microVMs that never go off: virtualize the state, not the machine. Tree in, execute against a real filesystem, diff out. Versioning belongs to the layer above (e.g. nontainer's providers) — dud is deliberately storage-blind, and the machines are deliberately dumb: all the smarts live in state, so any VM can vanish at any moment and nothing of value goes with it.
Status: alpha, on PyPI, all three backends live. The subprocess backend (real bash, real Python, zero isolation — own-agent-own-laptop posture), the vfkit microVM backend (macOS/HVF), and the firecracker microVM backend (Linux/KVM) all pass the same conformance corpus over the same wire protocol. OCI-image workspaces with pip-layered packages, warm VM pooling with state affinity, a disk-backed scratch plane, and snapshot parking on firecracker (a parked VM is inert files — zero RAM, ~tens-of-ms resume) all work today. See DESIGN.md for why it's shaped this way and ROADMAP.md for what's left.
Install
pip install dud
That's the whole install for the subprocess backend (zero dependencies, zero isolation). The VM backends each want two more things:
macOS (vfkit):
brew install vfkit # the VMM
python -m dud.kernels # the pinned guest kernel (~18 MB, digest-verified)
Linux (firecracker): a firecracker binary on $PATH (or point
$DUD_FIRECRACKER at one), access to /dev/kvm, and the same
python -m dud.kernels fetch.
Everything else — OCI image pulls, rootfs builds, scratch volumes —
is pure Python and arrives on first use, cached under ~/.dud.
Requesting a backend the host can't provide fails loud
(IsolationUnavailable) with the missing piece named.
Quick look
import dud
# backend="subprocess" | "vfkit" | "firecracker" | "vm" (the best VM
# backend for this host — config written against "vm" keeps working
# as new ones land)
with dud.session("vm", image="python:3.12-slim") as s:
s.shell("mkdir -p data && echo 'a,b\n1,2' > data/in.csv")
r = s.python("""
import csv
rows = list(csv.reader(open('data/in.csv')))
cache['n'] = len(rows) # persists across calls (opaque to the host)
emit('status', {'rows': len(rows)})
rows # last expression echoes, REPL-style
""")
print(r.transcript) # the echo
print(r.outputs) # harvested top-level bindings (codec values)
d = s.diff() # Diff(writes={'data/in.csv': b'a,b\n1,2\n'},
# # deletes=[]) — paths relative to the root
# hand d to your versioned store; dud doesn't care what it is
Where the files live
The guest mounts its workspace at /workspace (session(..., workspace="/path") to move it), and execs start there — so relative
paths just work, and /workspace/data/in.csv is a real absolute path
inside the VM. That matters because it's the same path the layer
above teaches: nontainer's local sandbox presents its VFS at the same
root, so agent code that hardcodes an absolute path means the same
file whether it runs in-process or on a real machine.
The root contains exactly the workspace. Staging internals live outside it, on a separate tmpfs, so a listing never shows dud's bookkeeping and a write anywhere under the root lands in the diff. Diff paths are relative to the root.
(The subprocess backend is the exception: a host temp dir can't claim
/workspace, so it roots the workspace in its scratch dir —
relative paths behave identically, absolute ones don't. Known gap,
and it only bites if you develop against that backend and deploy on
a VM one.)
Pooling and parking
pooled=True reuses VMs across sessions from a process-wide warm
pool; state="<your content hash>" parks a VM — sets it aside
still holding that exact tree — so the next session with the same
content resumes it instead of booting and re-pushing.
How a park is stored differs by backend, but the contract doesn't: on macOS the VM stays running, on firecracker it's snapshotted to disk — zero RAM, files that outlive the process that made them.
Host objects cross the boundary as names, not references — guest code gets a proxy whose only power is making allowlisted calls:
s = dud.session(host_objects={"db": my_db}, allow={"db": {"query"}})
s.python("rows = db.query(filter='active')") # ok
s.python("db.drop_all()") # PermissionError, host-side
No pickle ever crosses the wire; cache values are opaque bytes to the host, and everything else rides a tagged json/bytes/file codec.
The ladder
The backends aren't peers — they're ordered by how hard a boundary
they put around agent code, and everything above them is identical.
Same guest supervisor, same wire protocol, same conformance corpus;
only the substrate hardens. Hence "the ladder", and rung for a
step on it (these docs use "rung" where the ordering is the point and
"backend" where you'd be typing it into session(backend=...)).
| rung | backend | platform | isolation |
|---|---|---|---|
| 1 | subprocess |
any OS | none — dev/CI floor |
| 2 | vfkit |
macOS (HVF) | real Linux microVM |
| 3 | firecracker |
Linux/KVM | microVM + snapshot parking (jailer planned) |
The conformance suite in tests/conformance/ is the contract: a
backend that can't pass it unchanged isn't a rung. Requesting one the
host can't provide raises (IsolationUnavailable) — nothing silently
degrades.
(If you know Kubernetes, this is the same idea as RuntimeClass: one workload contract, swappable isolation underneath.)
What it costs
Measured on the DS image (numpy/pandas/pyarrow/matplotlib/plotly), warm caches, erofs root:
| boot to served channel | ~0.9 s |
| guest RAM at boot | 79 MB (600 MB on the initramfs medium) |
exec_python |
~30 ms |
| read-only view exec | ~117 ms, flat in import weight |
diff() with one change, 210 MB tree |
~1 ms |
| pool hit (warm VM, same image + config) | no boot — reset + push |
The shape behind the numbers: an immutable read-only root demand-pages from the host page cache instead of living in guest RAM; the workspace is an overlayfs mount, so a diff is a walk of what changed rather than a scan of the tree; and view execs fork from a warm import template instead of paying interpreter startup per request.
Development
uv sync --extra dev
uv run pytest
VM-backend conformance needs that backend's platform: on macOS,
DUD_BACKEND=vfkit uv run pytest tests/conformance; the firecracker
corpus runs on any Linux with /dev/kvm — including, on Apple
silicon (M3+), a nested-virt Lima guest (dev/lima-fc.yaml,
dev/fc-test.sh).
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dud-0.2.1.tar.gz.
File metadata
- Download URL: dud-0.2.1.tar.gz
- Upload date:
- Size: 136.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aeca84172cbf0b2ffca2bc4ef76cf627702cfb1e0183c4c5e430aea1dd7ce84c
|
|
| MD5 |
c4655e3c8a36de40bfdb548b1fcf6467
|
|
| BLAKE2b-256 |
15a5d0e21ee8a4214707bded7ba928d43b95a4fc868ad78d5d24b88cf5c98ca9
|
File details
Details for the file dud-0.2.1-py3-none-any.whl.
File metadata
- Download URL: dud-0.2.1-py3-none-any.whl
- Upload date:
- Size: 94.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a91be0edea7a484b3361b25f70c988449b4a33b3a0af51896467169081f67a16
|
|
| MD5 |
2db9bbada4eef6c07bfc3cf8b49aadc1
|
|
| BLAKE2b-256 |
45c83fc5b8fa91212ed2041f42a6aa97f28e90a92021ee30656686ca8752108c
|