Blog

Recipes, Docker in the VM,
and a headless browser.

Until now every sandbox booted as bare Debian. That is the right default for isolation, but the environments agents actually need, a Docker daemon, a browser, a toolchain, were yours to script and checkpoint by hand. 0.8.0 adds the pieces that make those environments one command, without giving up the fresh disk on every run.

Four things shipped: guest services, recipes, two official recipes for Docker and a headless browser, and checkpoint update.

Guest services

The guest has no systemd. PID 1 is a small init written in Rust, which is a large part of why a VM boots in about a second. Daemons needed a way to exist in that model, so an image can now declare them in /etc/shuru/services/<name>.json.

The init starts every service at boot, polls its readiness check, and only then runs your command. On the way down it sends SIGTERM, waits up to the stop timeout, and syncs the disk before the VM powers off. The host now asks the guest to shut down over vsock instead of pulling the plug, so a checkpoint is always cloned from a consistent image, whichever path stopped the VM.

/etc/shuru/services/docker.json
{
  "exec": ["dockerd"],
  "ready": ["docker", "info"],
  "ready_timeout_secs": 60,
  "stop_timeout_secs": 30
}

Recipes

A recipe is a versioned description of a checkpoint: a recipe.json with metadata and services, plus shell steps. Each step runs in a fresh boot from the previous step, so a service declared in the recipe is already running from step two onwards. The result is a normal checkpoint with one extra file next to it that records where it came from and at which revision.

shuru run --recipe builds the checkpoint on first use and boots it on every run after. References to GitHub resolve to a commit SHA, which is printed and recorded. Change the recipe and the next run rebuilds; leave it alone and the next run boots in seconds.

terminal
$ shuru recipe list
NAME     DESCRIPTION
docker   Docker Engine 29.9 with dockerd as a guest service and nginx-unprivileged pre-pulled
browser  Headless Chromium as a guest service, unprivileged user, DevTools protocol on port 9222

$ shuru recipe show docker          # metadata, services and every step, before anything boots
$ shuru run --recipe docker -- docker ps
shuru: building recipe docker from superhq-ai/recipes/docker@6a88421 (2 steps)
shuru: step 1/2: install.sh
shuru: step 2/2: pull.sh
shuru: checkpoint 'docker' saved
CONTAINER ID   IMAGE   COMMAND   CREATED   STATUS   PORTS   NAMES

$ shuru run --recipe docker -- docker ps   # same revision: boots the checkpoint, no rebuild

The official catalog lives in superhq-ai/recipes. A bare name always means that catalog. Any GitHub repository works too, and a local directory while you write one. Third-party recipes print their source and ask before building; official and local ones do not. Builds use the network allowlist the recipe declares, never your project shuru.json, and a recipe with no allowlist builds offline.

recipe.json
{
  "name": "docker",
  "description": "Docker Engine 29.9 with dockerd as a guest service and nginx-unprivileged pre-pulled",
  "min_shuru_version": "0.8.0",
  "disk_size": 4096,
  "memory": 2048,
  "network": {
    "allow": ["deb.debian.org", "security.debian.org", "download.docker.com", "*.docker.io"]
  },
  "services": {
    "docker": {
      "exec": ["dockerd"],
      "ready": ["docker", "info"],
      "ready_timeout_secs": 60,
      "stop_timeout_secs": 30
    }
  },
  "steps": ["install.sh", "pull.sh"]
}
references
shuru run --recipe docker -- ...                    # the official catalog
shuru run --recipe owner/repo/path@v1 --yes -- ...  # any GitHub repository, pinned to a tag
shuru run --recipe ./my-recipe -- ...               # a local directory while you write one
shuru run --recipe docker --checkpoint ci -- ...    # pick the checkpoint name yourself

Docker inside the VM

The docker recipe installs pinned docker-ce and containerd, declares dockerd as a guest service, and pre-pulls one image so the checkpoint is useful offline. Containers get the same boundary as any guest process: no egress without --allow-net, the host allowlist applied by the proxy, host ports only through -p.

To make dockerd happy the guest kernel gained cgroup v2 controllers, cgroup BPF, seccomp filters, veth and the netfilter targets Docker installs, and moved to Linux 6.12.114. The init now mounts cgroup2 and a tmpfs on /run, which also means /run no longer lands in checkpoints. Docker isolation inside the VM stays at its defaults; there is no reason to weaken the inner layer when the outer one is a VM.

terminal
# Serve the pre-pulled image on host port 8080. No --allow-net needed.
shuru run --from docker -p 8080:8080 -- \
  docker run --rm -p 8080:8080 nginxinc/nginx-unprivileged:1.31.7-alpine-slim

# Build an image from a mounted project and run it
shuru run --from docker --mount .:/app -- \
  sh -c 'cd /app && docker build -t app . && docker run --rm app'

A headless browser for agents

The browser recipe installs Chromium from Debian and runs it headless as an unprivileged user, with the DevTools protocol on port 9222 inside the VM. Forward that port and point Playwright, Puppeteer or browser-use at it. The browser, its profile and everything it downloads stay inside the microVM. Only the CDP port reaches the host, and only when you forward it.

The kernel ships user namespaces, so the Chromium sandbox works as designed on top of the VM boundary rather than being switched off. The Browser Use example in the repository now runs on this recipe.

terminal
# Chromium starts at boot; the command runs once /json/version answers
shuru run --recipe browser -p 9222:9222 -- sleep infinity

# Any CDP client on the host
curl -s http://127.0.0.1:9222/json/version

# The browser-use example does exactly this
OPENAI_API_KEY=... uv run --with browser-use --with openai examples/browser-use.py

checkpoint update

Replacing a checkpoint used to mean delete then create, with nothing in between if the create failed. shuru checkpoint update takes the same arguments as create, builds under a staging name, and swaps the new disk in only when the command exits 0. A failed update leaves the old checkpoint exactly as it was.

terminal
# Rebuild in place. The old checkpoint stays until the new build exits 0.
shuru checkpoint update base --allow-net -- apt-get install -y build-essential git make

# Layer onto the existing one instead of starting over
shuru checkpoint update base --from base --allow-net -- apt-get install -y jq

# Force a recipe checkpoint to rebuild at the current revision
shuru checkpoint update docker --recipe docker

Upgrading

The CLI pulls the new kernel and rootfs with the release. In the Rust SDK, Sandbox::stop() is now the graceful stop that lets services shut down and Sandbox::kill() is the old hard stop. The crates are on crates.io as shuru-proto 0.4.0, shuru-proxy 0.3.0, shuru-vm 0.4.0 and shuru-sdk 0.5.0. The agent skill in the repository knows about all of the above, so agents that use shuru through it pick up recipes without any prompting.

terminal
shuru upgrade                    # CLI and OS image
brew upgrade shuru               # if you installed through Homebrew

The catalog has two recipes today. If there is an environment your agents keep rebuilding by hand, a recipe for it is a directory with a recipe.json and a shell script, and shuru run --recipe ./that-directory is the whole test loop. Pull requests to the catalog are open.

article