A rootless OCI sandbox preloaded with Cartesi tooling (cartesi-machine, cartesi-cli, foundry, alto, podman, node, claude-code, …) for isolated development of Cartesi applications and AI agents.
curl -fsSL https://raw.githubusercontent.com/lynoferraz/cartesi-container-env/main/install.sh | shThis drops cartesi-sandbox into ~/.local/bin/, downloads a CI-built rootfs tarball for
your architecture from GitHub Releases, verifies it against SHA256SUMS, and installs it
under ~/.local/share/cartesi-sandbox/. The installer aborts early if host prerequisites
are missing — it prints the exact apt-get / usermod commands to fix them. Use --no-install to skip the rootfs installation, and --branch to specify the branch or tag the script, Dockerfile and config template are fetched from.
To build the rootfs locally instead (slow, needs docker with buildx). The Dockerfile is self-contained, so only it is downloaded — no clone of this repo is needed:
curl -fsSL https://raw.githubusercontent.com/lynoferraz/cartesi-container-env/main/install.sh \
| sh -s -- --from-source(--from-source --tag v0.4.0 builds the Dockerfile of that tag instead of main.)
To use a specific release of the rootfs:
curl -fsSL https://raw.githubusercontent.com/lynoferraz/cartesi-container-env/main/install.sh \
| sh -s -- --tag v0.4.0In any project directory:
cd path/to/project
cartesi-sandbox init # writes ./sandbox-config.json from the template, with this project's path baked in
cartesi-sandbox run # exec into the sandbox (uses crun if available, else runc)The current directory is bind-mounted at /projects/<basename> inside the sandbox, and
becomes the container's working directory. ./sandbox-config.json itself is masked over
/dev/null inside the container so the sandboxed code can't tamper with its own runtime
config. The .secrets/ directory in the project is also masked — use it for test secrets
you do NOT want exposed to the agent.
when you run the sand box for the first time, install you favorite AI tool, and it will be available on every sandboxes
| Command | What it does |
|---|---|
cartesi-sandbox doctor |
Check host prereqs and print fixes |
cartesi-sandbox update |
Re-download / rebuild the rootfs |
cartesi-sandbox uninstall |
Remove the installation |
cartesi-sandbox version |
Print version |
cartesi-sandbox doctor checks all of these. None are installed automatically — run the
suggested commands yourself:
- OCI runtime:
crun(preferred) orrunc—sudo apt-get install -y crun - uidmap tools:
newuidmap/newgidmap—sudo apt-get install -y uidmap - subuid/subgid entries for your user —
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER - zstd (for extracting the release tarball) —
sudo apt-get install -y zstd - /dev/net/tun readable and writable by your user (it is bind-mounted into the sandbox for
podman's rootless networking) —
sudo modprobe tun(normally present and mode 0666)
For rootless podman inside the sandbox, also:
sudo sysctl -w kernel.unprivileged_userns_clone=1
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0(Persist in /etc/sysctl.d/.)
sandbox-config.template.json is tuned so that rootless podman works nested inside the
rootless sandbox:
/procis mounted without masked or read-only sub-paths. The kernel refuses a newprocmount in a nested user namespace while the parent/prochas such overmounts, which breakspodman runand everyRUNstep ofpodman build. In a rootless sandbox the user namespace already blocks what those masks guard;/sys/firmwareand.secretsstay masked./dev/net/tunis bind-mounted from the host so pasta can set up per-container networks and publish ports./run/user/1000is a per-session tmpfs andXDG_RUNTIME_DIRpoints at it, so podman's runtime state (pause process pid, sockets, locks) never leaks from one session into the next.dockeris a shim overpodman/podman-compose(defined inline in theDockerfile, installed at/usr/bin/docker). It keeps argument quoting intact, answers the version probes of the cartesi CLI, remembers compose files passed on stdin per project (stored as.docker-shim.<project>.compose.ymlplus a.docker-shim.<project>.configs/directory in the project directory untilcompose down; add both to your.gitignore), rewrites composeconfigsinto bind mounts (podman-compose ignores them, which would leave thecartesi runproxy without routes), and startspodman-healthcheck-runner, which runs container healthchecks because there is no systemd to schedule them. Forbuildit drops the buildx-only--output type=docker(buildah only knowstype=local|tar, and the image is committed to the store anyway) and emulates--metadata-filethrough--iidfile, writing thecontainerimage.config.digestthatcartesi buildreads afterwards. SetDOCKER_SHIM_DEBUG=1to see the podman commands it runs.
If you don't want to pipe a script from the internet, the manual flow still works. Building
the rootfs needs only the Dockerfile (every file it installs is inlined in it and everything
else is fetched during the build), so there is nothing to clone. Use an empty directory as the
build context so nothing else is sent to the builder:
mkdir -p ~/sandbox/build && cd ~/sandbox/build
curl -fsSLO https://raw.githubusercontent.com/lynoferraz/cartesi-container-env/main/Dockerfile
docker buildx build -f Dockerfile --output type=tar,dest=../docker-sandbox.tar .
cd ..
mkdir -p docker-sandbox/rootfs
tar xf docker-sandbox.tar -C docker-sandbox/rootfs
sudo cp /etc/resolv.conf docker-sandbox/rootfs/etc/resolv.conf
# SUBUID is the start of your range in /etc/subuid (e.g. 100000); the sandbox's root maps to it,
# and the sandbox's "ubuntu" user (uid 1000 inside) maps to your own uid.
SUBUID=$(grep "^$USER:" /etc/subuid | cut -d: -f2)
sudo chown -R $SUBUID:$SUBUID docker-sandbox/rootfs/
sudo chown -R $(id -u):$(id -g) docker-sandbox/rootfs/home/ubuntu/
sudo chown -R $(id -u):$(id -g) docker-sandbox/rootfs/opt/venv
sudo chmod 777 docker-sandbox/rootfs/tmp docker-sandbox/rootfs/var/tmp/
sudo setcap cap_setuid+ep docker-sandbox/rootfs/usr/bin/newuidmap
sudo setcap cap_setgid+ep docker-sandbox/rootfs/usr/bin/newgidmap
sudo mkdir -p docker-sandbox/rootfs/run/user/1000
sudo chown $(id -u):$(id -g) docker-sandbox/rootfs/run/user/1000Per project (the template's quoted {{...}} placeholders become numbers; cartesi-sandbox init
does the same substitutions):
cd path/to/project
curl -fsSL https://raw.githubusercontent.com/lynoferraz/cartesi-container-env/main/sandbox-config.template.json \
-o sandbox-config.json
sed -i "s#{{project_path}}#$(pwd)#g" sandbox-config.json
sed -i "s#{{project}}#$(basename $(pwd))#g" sandbox-config.json
sed -i -e "s#\"{{host_uid}}\"#$(id -u)#g" -e "s#\"{{host_gid}}\"#$(id -g)#g" \
-e "s#\"{{subuid_start}}\"#$SUBUID#g" -e "s#\"{{subgid_start}}\"#$SUBUID#g" \
-e "s#\"{{subuid_start_plus_1}}\"#$((SUBUID+1))#g" -e "s#\"{{subgid_start_plus_1}}\"#$((SUBUID+1))#g" \
-e "s#\"{{subuid_size_minus_1}}\"#65535#g" -e "s#\"{{subgid_size_minus_1}}\"#65535#g" \ # (range size - 1)
sandbox-config.json
crun run --config sandbox-config.json --bundle ~/sandbox/docker-sandbox/ $(basename $(pwd))For runc, the config has to live in the bundle dir:
mkdir ~/sandbox/myproject
ln -sr ~/sandbox/docker-sandbox/rootfs ~/sandbox/myproject/rootfs
cp sandbox-config.json ~/sandbox/myproject/config.json # the substituted file from above
runc run --bundle ~/sandbox/myproject/ $(basename $(pwd))[rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sudo sysctl -w kernel.unprivileged_userns_clone=1write to uid_map: Operation not permitted — install uidmap and check /etc/subuid / /etc/subgid as above.
newuidmap: uid range X -> X not allowed — add the subuid range:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER**mount \proc` to `proc`: Operation not permitted** (from podman runor aRUNstep ofpodman build) — the sandbox was started with an old sandbox-config.jsonthat masks paths under/proc. Re-run cartesi-sandbox init` to regenerate it from the current template and
start a new session.
pasta failed with exit code 1: ... Failed to open() /dev/net/tun — same cause: the old
config does not bind-mount /dev/net/tun. Regenerate the config as above and check
cartesi-sandbox doctor on the host.
podman stop reports given PID did not die within timeout / containers stuck in Stopping
— the container was started with --pid host (or pidns = "host" in a containers.conf),
which leaves podman no cgroup to signal. Kill the process by hand (podman inspect --format '{{.State.Pid}}', then kill -9), remove the pidns/netns overrides from
~/.config/containers/containers.conf, and use the current template so containers get their
own pid namespace.
Containers stay starting and cartesi run never becomes ready — healthchecks are not
being executed. podman-healthcheck-runner status should say running; docker compose up
starts it automatically, or start it by hand with podman-healthcheck-runner start.
After pulling a new template, run cartesi-sandbox init again in each project and start a
new session. If podman inside your sandbox already has an image store, it keeps the old
runtime directories recorded in its database (/tmp/containers-user-1000,
/tmp/podman-run-1000); run podman system reset once inside the sandbox to move to the
per-session XDG_RUNTIME_DIR (this deletes local images and containers, which are re-pulled
on demand).
To patch a rootfs in place instead of re-installing it, extract just the shim files from a
freshly built rootfs tar (see "Manual install" above for building docker-sandbox.tar) and
give them back to the sandbox's root (the start of your subuid range):
ROOTFS=~/.local/share/cartesi-sandbox/bundle/rootfs
sudo tar -xf docker-sandbox.tar -C "$ROOTFS" \
usr/bin/docker usr/local/bin/podman-healthcheck-runner usr/local/bin/docker-shim-compose-fixup etc/containers
SUBUID=$(grep "^$USER:" /etc/subuid | cut -d: -f2)
sudo chown -R "$SUBUID:$SUBUID" "$ROOTFS/usr/bin/docker" "$ROOTFS/usr/local/bin" "$ROOTFS/etc/containers"Then delete any netns, pidns or helper_binaries_dir lines from
$ROOTFS/home/ubuntu/.config/containers/containers.conf. (cartesi-sandbox update does a full
re-install instead.)