Skip to content

Sandbox backends: which agent sandboxes can replace LayerFS's Docker container? Local stand-ins first #512

Open
0 of 1 issue completed

Description

@yifanxuaaa

Status (2026-10-30)

Every environment hosted real LayerFS. Route A (no Docker at all) passed end to end in Apple Container, Microsandbox and Firecracker. The proposed backend is in draft PR #515.

Environment FUSE + privilege split Store Real LayerFS Comments
Microsandbox pass per-VM ext4 disk; survives VM deletion route B pass; route A pass results, route A
Daytona OSS (local) pass loop ext4 inside the sandbox route B pass (arm64 runner patched locally) iteration 1, iteration 2
Apple Container pass with --cap-add SYS_ADMIN named ext4 volume, one VM per project route B pass; route A pass iteration 1, iteration 2, route A
Lima VM (EC2 stand-in) pass ext4 route B pass results
Firecracker guest (E2B/Vercel stand-in) pass second ext4 drive route A pass; route B probe only probes, route A

What holds everywhere:

  1. One VM (or one privileged sandbox) per project, not per Sandbox. VMs don't share a kernel, so the Store goes on the project VM's own ext4 disk, and its daemons run inside it, each in its own mount namespace.
  2. Commands through setpriv (uid:gid, no capabilities, no-new-privileges). Unmount is denied.
  3. User namespaces must be blocked by the backend. No platform blocks them by default. Use user.max_user_namespaces=0 in a per-project VM, or a seccomp filter in the command launcher where the kernel is shared, as on Daytona.
  4. The daemon endpoint can be reached privately on each platform: a loopback port forward, the SSH gateway, an exec stdio bridge, or vsock.

Open: hosted providers' own kernels and policies; a real backend in place of the test-only stand-in (provider adapter, persistence of backend state, seccomp and cgroups); the SDK outside the VM (the worker route).

Goal

LayerFS should be able to run on more than one Sandbox backend. Today it has one, Docker (core/crates/layerfs-sandbox/src/backend/docker/). This experiment finds out which agent-sandbox platforms could become a backend that replaces the Docker container and does every SDK operation the Docker backend does today. Start with environments that run on a developer Mac, then move to the hosted providers they stand in for.

(Revised 2026-10-30, same day: the first version asked mainly whether LayerFS's current Docker containers could run nested inside a provider. That is now a secondary route.)

Background

Today, for each Sandbox LayerFS starts one Docker container. Its daemon mounts the Workspace inside it and the agent's commands run there too. The container gives LayerFS:

  • a real Linux kernel with FUSE: the daemon opens /dev/fuse and calls mount(2) itself (core/crates/layerfs-fuse/src/session/startup.rs);
  • a privilege split: the daemon runs as root with CAP_SYS_ADMIN and mounts with allow_other, while commands run as a separate nonroot uid:gid with no capabilities, no-new-privileges, and a seccomp filter that denies user namespaces. So a command cannot unmount the Workspace or copy the mount into a namespace of its own (container.rs, topology.rs, core/docs/architecture/76-native-mount-session.md);
  • the Store on a local ext4, xfs or btrfs volume, shared by the project's daemons on one kernel. On Linux, SDK Store calls also run in the caller's own process.

Two routes per platform

  • Route A, a new backend (the main question): the platform's sandbox is the Sandbox. The daemon mounts directly in it and there is no Docker. This needs new backend code, and the platform must provide the privilege split and every operation in the contract below.
  • Route B, nested Docker (secondary): LayerFS runs unchanged and starts its usual container inside the platform's VM. No code changes, but the platform must allow Docker inside with these flags.

Desk research (2026-10-30)

Provider Finding
EC2 the only confirmed fit
E2B, Vercel Sandbox, Modal VM runtime, Daytona Linux VM likely fits: microVMs with root and documented FUSE
Fargate, Lambda, Azure Container Apps, Modal's default gVisor runtime not viable

The Mirage project (strukto-ai/mirage) needs the same --cap-add SYS_ADMIN --device /dev/fuse for container mounts.

The backend contract (what the Docker backend does at HEAD)

SDK call Backend steps
sandbox.create B1 create, B2 attach Store volume, B3 upload, B4 start, B5 startup log, B6 endpoint
workspace.mount / commit / status / unmount daemon calls over B6's TCP endpoint (no backend step)
workspace.exec B7 exec
sandbox.get_log B5 log tail
SDK restart, sandbox.reclaim B8 reopen by ID, B10 discover
sandbox.close B9 stop then delete, Store volume kept
Store calls (fs, branch, project) S1: open the Store file directly from a Linux process on the same kernel

Each platform is checked item by item:

  • B1 Create from a pinned image (Docker uses sha256: digest) or an
    equivalent pinned template, with labels; one acknowledged full unique ID; no
    adoption by name.
  • B2 Shared Store volume: one persistent local ext4/xfs/btrfs volume,
    read-write at a fixed path (/layerfs-store), attached to several sandboxes
    of one project at the same time on ONE Linux kernel (SQLite locking plus a
    Linux directory lock). Record the filesystem type and whether two sandboxes
    can share it. If each sandbox is its own VM, they cannot share a kernel:
    record that, and whether several daemons can run inside one VM instead.
  • B3 Upload the daemon executable (0755) and private config (0600) before
    start (Docker: archive into the stopped container). A file upload API, or a
    pinned image that already contains them.
  • B4 Start the daemon as root with CAP_SYS_ADMIN and /dev/fuse, an init
    that reaps orphans, and the privilege split in lfs-route-a-probe.sh.
  • B5 Startup log: read the daemon's stdout stream to see its listener
    marker; a bounded tail of its log after a failed start.
  • B6 Endpoint: the daemon's TCP port reachable from the SDK host. Docker
    publishes it on host 127.0.0.1 only. Record how (port forward, tunnel,
    preview URL) and whether it can stay private (not public internet).
  • B7 Exec: non-TTY, as a given nonroot uid:gid, argv with no shell, env,
    working directory = the mount, optional stdin with EOF, stdout and stderr as
    separate streams, exit code, a unique exec ID. Docker has no per-Exec
    cancel (it returns Unsupported): record whether the provider can kill one
    exec (a gain over Docker).
  • B8 Reopen an existing sandbox by exact ID after the SDK process
    restarts; see running vs stopped; read its configuration to check it.
  • B9 Stop and delete one sandbox; the Store volume survives.
  • B10 Discover sandboxes by label or name prefix.
  • S1 Store access: the SDK opens the Store file in its own process on
    Linux. With a remote provider the SDK runs elsewhere, so this needs either
    the SDK running inside the provider VM or the existing worker route (a
    Linux worker beside the Store, reached over a private authenticated port).
    Record which is possible.

Environments

Environment Stands in for Notes
Daytona open source v0.190.0, local Docker Compose Daytona cloud container sandboxes The repo was archived in June 2026. Its sandboxes are privileged containers inside a privileged runner with its own Docker, so a pass here is a best case: the cloud likely runs a different container runtime (the repo mentions Sysbox)
Microsandbox (libkrun, Apple Silicon) E2B, Vercel and Modal VM-style microVMs Also records how virtio-fs volumes look
Apple Container (macOS 26) VM-per-container platforms --cap-add SYS_ADMIN
Lima VM with nested virtualization, then Firecracker a plain Linux VM such as EC2, then E2B and Vercel (both Firecracker) Needs /dev/kvm inside Lima

Levels

  • L1, FUSE in the environment (lfs-sandbox-probe.sh): /dev/fuse, capabilities, no_new_privs, seccomp, user namespaces, privilege to mount, a real FUSE mount (fuse-overlayfs), the filesystem of / and /tmp.
  • L2, the privilege split (lfs-route-a-probe.sh): root mounts with allow_other; a capability-less uid-1500 command reads and writes the mount but cannot unmount it; whether a command can create a user namespace and copy the mount, and whether root can block that (for example user.max_user_namespaces=0).
  • L3, the backend contract: B1–B10 and S1 against the platform's own API, each yes / no / how.
  • L4, real LayerFS where L1–L3 allow: the sealed arm64 Linux build build-linux-bb5f636bcbca4078a0d7a5368b3e101b (source 7ab54bcf1a). Init a project, mount a Workspace, run a command through it, Commit, and unmount.
  • Route B, optional: lfs-sandbox-probe.sh's nested-Docker step with LayerFS's exact flags.

Reference run of the L2 probe on Docker Desktop with seccomp=unconfined, which is how a plain VM looks: the mount, the command's read and write, and the unmount denial all pass, but cmd_userns=allowed and cmd_copy_mount=SUCCEEDED. With no Docker filter, the backend must block user namespaces some other way.

Rules

  • One attempt per step, no retries.
  • Every command bounded at 2 minutes, and never more than 5 for a single large pull.
  • Disposable Stores only, created for the experiment. Never the owner's Stores, containers or volumes.
  • Each environment's own containers, VMs and volumes are removed afterwards.
  • Installed tools and pulled images are listed in the results.

Results

Per environment, this issue gets one comment with the probe outputs, an L1–L4 verdict, the contract table, and what the result does and does not prove.

  • Daytona OSS (local)
  • Microsandbox
  • Apple Container
  • Lima, then Firecracker
  • Route A end to end (test-only Engine API stand-in, no Docker)
L2 probe (lfs-route-a-probe-v2.sh, version 2: error files go in a directory the invoking shell owns)
#!/bin/sh
# LayerFS route-A probe: can this environment REPLACE the Docker container,
# i.e. host the daemon's FUSE mount and LayerFS's privilege split with no
# Docker? Prints KEY=VALUE lines. Writes only under /tmp/lfsrouteA, and adds a
# throwaway user `lfsprobe` (uid/gid 1500) if missing.
#
# LayerFS today (Docker backend): the daemon runs as root with CAP_SYS_ADMIN
# and /dev/fuse and mounts with allow_other; commands run as a separate nonroot
# uid:gid with no capabilities, no-new-privileges, and a seccomp filter that
# forbids user namespaces, so a command cannot unmount the Workspace or copy
# the mount into a namespace of its own. Route A needs the same split here.
#
# Needs root or passwordless sudo. Each step is one attempt with a timeout.
# Usage: sh lfs-route-a-probe.sh   (PROBE_INSTALL=1 installs fuse3 +
# fuse-overlayfs + util-linux via apt/apk if missing)
set -u
W=/tmp/lfsrouteA
say() { printf '%s=%s\n' "$1" "$2"; }
T() { if command -v timeout >/dev/null 2>&1; then timeout "$@"; else shift; "$@"; fi; }
SUDO=""
if [ "$(id -u)" != 0 ]; then
  if sudo -n true 2>/dev/null; then SUDO="sudo -n"; else say route_a "skipped: needs root or passwordless sudo"; exit 0; fi
fi
say route_a_version 2
# error and output files are written by this (maybe nonroot) shell, so they go
# in a directory it owns, not under the root-owned $W
E=$(mktemp -d)
$SUDO mkdir -p "$W/src/pub" "$W/up" "$W/wk" "$W/mnt" "$W/copy"
echo "probe-file" | $SUDO tee "$W/src/hello.txt" >/dev/null
$SUDO chmod 0777 "$W/src/pub"; $SUDO chmod 0755 "$W" "$W/src" "$W/mnt" "$W/copy"

if [ "${PROBE_INSTALL:-0}" = 1 ]; then
  if command -v apt-get >/dev/null 2>&1; then
    T 110 $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fuse3 fuse-overlayfs util-linux >/dev/null 2>&1 \
      || { T 110 $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1; \
           T 110 $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fuse3 fuse-overlayfs util-linux >/dev/null 2>&1; }
  elif command -v apk >/dev/null 2>&1; then
    T 110 $SUDO apk add --no-cache fuse3 fuse-overlayfs util-linux setpriv shadow >/dev/null 2>&1
  fi
fi
if ! command -v fuse-overlayfs >/dev/null 2>&1; then say route_a "skipped: no fuse-overlayfs"; exit 0; fi

# the command identity: a plain nonroot user, like LayerFS's CommandIdentity
if ! id lfsprobe >/dev/null 2>&1; then
  if command -v useradd >/dev/null 2>&1; then $SUDO useradd -u 1500 -U -M -s /bin/sh lfsprobe 2>/dev/null
  else $SUDO addgroup -g 1500 lfsprobe 2>/dev/null; $SUDO adduser -D -H -u 1500 -G lfsprobe -s /bin/sh lfsprobe 2>/dev/null; fi
fi
say cmd_user "$(id lfsprobe 2>/dev/null || echo absent)"
# run a shell snippet as the command identity, dropping every capability and
# setting no-new-privileges, as LayerFS's Docker Exec does
if command -v setpriv >/dev/null 2>&1; then
  AS() { T 20 $SUDO setpriv --reuid=1500 --regid=1500 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privs sh -c "$1"; }
  say cmd_launcher "setpriv"
else
  AS() { T 20 $SUDO su -s /bin/sh lfsprobe -c "$1"; }
  say cmd_launcher "su (no no-new-privs, no bounding-set drop)"
fi

# 1. the daemon side: root mounts with allow_other
if T 30 $SUDO fuse-overlayfs -o "allow_other,lowerdir=$W/src,upperdir=$W/up,workdir=$W/wk" "$W/mnt" 2>"$E/mount.err"; then
  say daemon_mount_allow_other "ok $(awk -v m="$W/mnt" '$2==m{print $3" "$4}' /proc/mounts)"
else
  say daemon_mount_allow_other "failed: $(head -c 200 "$E/mount.err" | tr '\n' ' ')"
  say done ok; exit 0
fi

# 2. the command side, as uid 1500 with no capabilities
say cmd_caps "$(AS 'awk "/^CapEff|^CapBnd|^NoNewPrivs|^Seccomp:/{printf \"%s%s \", \$1, \$2}" /proc/self/status' 2>&1)"
say cmd_read "$(AS "cat $W/mnt/hello.txt" 2>&1 | head -c 120)"
say cmd_write "$(AS "echo from-cmd > $W/mnt/pub/c.txt && cat $W/mnt/pub/c.txt" 2>&1 | head -c 120)"
say cmd_write_owner "$(stat -c '%u:%g' "$W/mnt/pub/c.txt" 2>/dev/null || echo none)"
# must FAIL for parity: the command cannot unmount the Workspace
if AS "umount $W/mnt 2>/dev/null || fusermount3 -u $W/mnt 2>/dev/null || fusermount -u $W/mnt" >/dev/null 2>&1; then
  say cmd_unmount "SUCCEEDED (parity broken)"
  T 30 $SUDO fuse-overlayfs -o "allow_other,lowerdir=$W/src,upperdir=$W/up,workdir=$W/wk" "$W/mnt" 2>/dev/null
else say cmd_unmount "denied (parity ok)"; fi
# must FAIL for parity: no user namespace, so no private mount namespace
if AS "unshare -U true" >/dev/null 2>&1; then
  say cmd_userns "allowed (parity broken: needs a seccomp/sysctl block)"
  if AS "unshare -Urm sh -c 'mount --bind $W/mnt $W/copy && cat $W/copy/hello.txt'" >"$E/copy.out" 2>&1; then
    say cmd_copy_mount "SUCCEEDED $(head -c 60 "$E/copy.out" | tr '\n' ' ') (a command can hold its own copy of the mount)"
  else say cmd_copy_mount "failed: $(head -c 120 "$E/copy.out" | tr '\n' ' ')"; fi
else say cmd_userns "denied (parity ok)"; fi
say userns_sysctl "max_user_namespaces=$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo ?) unprivileged_userns_clone=$(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null || echo n/a) apparmor_restrict=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo n/a)"
# can root here deny user namespaces for commands without Docker? (sysctl)
say root_can_block_userns "$([ -w /proc/sys/user/max_user_namespaces ] || $SUDO sh -c '[ -w /proc/sys/user/max_user_namespaces ]' && echo yes-sysctl-writable || echo no)"

# 3. a process left behind by a command is reaped (LayerFS uses an init as PID 1)
say pid1 "$(cat /proc/1/comm 2>/dev/null)"

# 4. teardown: the daemon side can unmount
if T 20 $SUDO fusermount3 -u "$W/mnt" 2>/dev/null || T 20 $SUDO umount "$W/mnt" 2>/dev/null; then say daemon_unmount ok; else say daemon_unmount failed; fi
$SUDO rm -rf "$W"; rm -rf "$E"
say done ok
Store probe (lfs-store-probe.sh)
#!/bin/sh
# LayerFS Store-volume probe: could a LayerFS Store live in DIR?
# LayerFS accepts a Store only on ext4, xfs or btrfs, checked with statfs
# (core/crates/layerfs-store-service/src/kernel_guard.rs), and uses SQLite in
# WAL mode with several processes, file locks, renames and in-place writes.
#
# Usage: sh lfs-store-probe.sh DIR
#   PROBE_LOOP=1  instead of testing DIR itself, make a sparse ext4 image file
#                 DIR/lfs-store.img (PROBE_LOOP_MB, default 512), loop-mount it
#                 at /tmp/lfsstore-loop and test that (needs root or sudo).
#   PROBE_KEEP=1  leave the loop mount in place (to test it again after a restart)
# Prints KEY=VALUE lines. One attempt per step, each with a timeout.
set -u
say() { printf '%s=%s\n' "$1" "$2"; }
T() { if command -v timeout >/dev/null 2>&1; then timeout -k 5 "$@"; else shift; "$@"; fi; }
D=${1:?usage: lfs-store-probe.sh DIR}
SUDO=""
if [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null; then SUDO="sudo -n"; fi
say store_probe_version 1
fsinfo() { # path -> type and magic as LayerFS's guard sees them
  m=$(stat -f -c %t "$1" 2>/dev/null)
  case "$m" in ef53) g=ext4;; 58465342) g=xfs;; 9123683e) g=btrfs;; *) g="rejected";; esac
  echo "magic=0x$m guard=$g mount=$(awk -v p="$1" 'index(p,$2)==1 && length($2)>=n {n=length($2); l=$1" "$3} END{print l}' /proc/mounts)"
}
if [ "${PROBE_LOOP:-0}" = 1 ]; then
  say dir_fs "$(fsinfo "$D")"
  IMG="$D/lfs-store.img"; MNT=/tmp/lfsstore-loop
  $SUDO mkdir -p "$MNT"
  if ! grep -qs " $MNT " /proc/mounts; then
    if [ ! -e "$IMG" ]; then
      T 30 $SUDO truncate -s "${PROBE_LOOP_MB:-512}M" "$IMG" && T 60 $SUDO mkfs.ext4 -q -F "$IMG" >/dev/null 2>&1 \
        && say loop_image "created $IMG" || { say loop_image "failed to create (truncate/mkfs.ext4)"; exit 0; }
    else say loop_image "reused existing $IMG"; fi
    if T 30 $SUDO mount -o loop "$IMG" "$MNT" 2>/tmp/lfsstore-mount.err; then say loop_mount ok
    else say loop_mount "failed: $(head -c 200 /tmp/lfsstore-mount.err | tr '\n' ' ')"; exit 0; fi
  else say loop_mount "already mounted"; fi
  $SUDO chmod 1777 "$MNT"
  say loop_dev "$(awk -v m="$MNT" '$2==m{print $1}' /proc/mounts)"
  D="$MNT"
fi
say store_fs "$(fsinfo "$D")"
P="$D/lfsstoreprobe.$$"
mkdir -p "$P" || { say store_write "failed: cannot mkdir in $D"; exit 0; }
[ -f "$D/marker" ] && say marker_before "$(cat "$D/marker")" || say marker_before none
date -u +%Y-%m-%dT%H:%M:%SZ > "$D/marker"
# in-place write at an offset, then read back
if command -v python3 >/dev/null 2>&1; then
  say pwrite "$(T 20 python3 -c "
import os,sys
p=sys.argv[1]+'/f'; fd=os.open(p,os.O_RDWR|os.O_CREAT,0o600); os.pwrite(fd,b'A'*8192,0); os.pwrite(fd,b'B',4096); os.fsync(fd)
print('ok' if os.pread(fd,1,4096)==b'B' else 'mismatch')" "$P" 2>&1 | tail -1)"
  # rename over an existing file
  say rename_over "$(T 20 python3 -c "
import os,sys
d=sys.argv[1]; open(d+'/a','w').write('new'); open(d+'/b','w').write('old'); os.rename(d+'/a',d+'/b'); print('ok' if open(d+'/b').read()=='new' else 'mismatch')" "$P" 2>&1 | tail -1)"
  # SQLite WAL with a writer process and a separate reader process
  say sqlite_wal "$(T 60 python3 -c "
import sqlite3,sys,subprocess
db=sys.argv[1]+'/s.sqlite'
c=sqlite3.connect(db); m=c.execute('pragma journal_mode=wal').fetchone()[0]
c.execute('create table t(x)'); c.execute('insert into t values (1)'); c.commit()
c.execute('begin immediate'); c.execute('insert into t values (2)')
code='import sqlite3,sys; r=sqlite3.connect(sys.argv[1],timeout=0); print(r.execute(\"select count(*) from t\").fetchone()[0]);\ntry:\n  r.execute(\"begin immediate\"); print(\"second-writer-allowed\")\nexcept sqlite3.OperationalError as e: print(\"second-writer-refused:\"+str(e))'
out=subprocess.run([sys.executable,'-c',code,db],capture_output=True,text=True,timeout=20)
c.commit()
w=out.stdout.split()
print(('mode='+m+' reader_saw='+w[0]+' second_writer='+('refused (ok)' if 'refused' in out.stdout else 'ALLOWED')) if w else 'reader-failed:'+out.stderr[-120:])" "$P" 2>&1 | tail -1 | tr '\n' ' ')"
else
  say pwrite "skipped: no python3"; say rename_over "skipped: no python3"; say sqlite_wal "skipped: no python3"
fi
# advisory locks across processes (LayerFS also takes a Linux directory lock)
if command -v flock >/dev/null 2>&1; then
  ( flock -x 9; sleep 3 ) 9>"$P/lock" &
  sleep 1
  if T 5 flock -n -x "$P/lock" true 2>/dev/null; then say flock "NOT exclusive (second lock granted)"; else say flock "exclusive ok"; fi
  wait
else say flock "skipped: no flock"; fi
rm -rf "$P"
if [ "${PROBE_LOOP:-0}" = 1 ] && [ "${PROBE_KEEP:-0}" != 1 ]; then
  T 20 $SUDO umount /tmp/lfsstore-loop && say loop_unmount ok || say loop_unmount failed
fi
say done ok
L1 probe (lfs-sandbox-probe.sh, version 3: timeout -k so it can stop docker run)
#!/bin/sh
# LayerFS sandbox probe: can this environment host LayerFS's FUSE mounts?
# Prints KEY=VALUE lines. Writes only under /tmp/lfsprobe (and, with
# PROBE_INSTALL=1, installs fuse3 + fuse-overlayfs through the package manager).
#
# Levels:
#   L1  inside this environment: /dev/fuse, capabilities, privilege to mount,
#       a real FUSE mount (fuse-overlayfs) as root and as a plain user.
#   L2  nested Docker with LayerFS's exact Sandbox flags: /dev/fuse device,
#       CAP_SYS_ADMIN, apparmor=unconfined, no-new-privileges and LayerFS's
#       seccomp filter (no user namespaces); a FUSE mount inside it; and the
#       filesystem a Docker volume gets (the Store needs ext4, xfs or btrfs).
#
# Every step is one attempt with its own timeout; a failure is reported, not
# retried. Usage: sh lfs-sandbox-probe.sh   (env PROBE_INSTALL=1 to apt/apk
# install fuse3 and fuse-overlayfs if missing; PROBE_DOCKER=0 to skip L2).
set -u
W=/tmp/lfsprobe
mkdir -p "$W/src" "$W/mnt" "$W/mnt2" "$W/up" "$W/wk"
echo "probe-file" > "$W/src/hello.txt"
say() { printf '%s=%s\n' "$1" "$2"; }
T() { if command -v timeout >/dev/null 2>&1; then timeout -k 5 "$@"; else shift; "$@"; fi; }
SUDO=""
if [ "$(id -u)" != 0 ]; then
  if sudo -n true 2>/dev/null; then SUDO="sudo -n"; fi
fi

say probe_version 3
say uid "$(id -u)"
say root_route "$([ "$(id -u)" = 0 ] && echo root || { [ -n "$SUDO" ] && echo sudo || echo none; })"
say kernel "$(uname -srm)"
say os "$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")"
say virt "$(systemd-detect-virt 2>/dev/null || echo unknown)"

# --- L1: device, module, capabilities -------------------------------------
if [ -c /dev/fuse ]; then say dev_fuse "$(ls -l /dev/fuse | awk '{print $1" "$3":"$4}')"; else say dev_fuse absent; fi
say fuse_in_kernel "$(grep -qw fuse /proc/filesystems && echo yes || echo no)"
say dev_kvm "$([ -c /dev/kvm ] && echo present || echo absent)"
capbit() { v=$(printf '%d' "0x$1" 2>/dev/null || echo 0); echo $(( (v >> 21) & 1 )); }
ce=$(awk '/^CapEff/{print $2}' /proc/self/status)
say cap_eff "$ce sys_admin=$(capbit "$ce")"
if [ -n "$SUDO" ]; then
  cr=$($SUDO awk '/^CapEff/{print $2}' /proc/self/status)
  say cap_eff_root "$cr sys_admin=$(capbit "$cr")"
fi
say no_new_privs "$(awk '/^NoNewPrivs/{print $2}' /proc/self/status)"
say seccomp_mode "$(awk '/^Seccomp:/{print $2}' /proc/self/status)"
say userns "$(T 10 unshare -U true 2>/dev/null && echo allowed || echo denied)"
fstype() { awk -v p="$1" 'index(p, $2) == 1 && (p == $2 || substr(p, length($2) + 1, 1) == "/" || $2 == "/") { if (length($2) >= n) { n = length($2); t = $3 } } END { print t }' /proc/mounts; }
say fs_root "$(fstype /)"
say fs_tmp "$(fstype /tmp)"
fm=$(command -v fusermount3 || command -v fusermount || true)
if [ -n "$fm" ]; then say fusermount "$fm mode=$(stat -c %A "$(readlink -f "$fm")")"; else say fusermount absent; fi
say user_allow_other "$(grep -qs '^user_allow_other' /etc/fuse.conf && echo yes || echo no)"

# privilege to mount at all (tmpfs needs CAP_SYS_ADMIN)
if [ "$(id -u)" = 0 ] || [ -n "$SUDO" ]; then
  if T 20 $SUDO mount -t tmpfs lfsprobe "$W/mnt2" 2>"$W/tmpfs.err"; then
    say mount_tmpfs ok; T 20 $SUDO umount "$W/mnt2"
  else say mount_tmpfs "failed: $(head -c 160 "$W/tmpfs.err" | tr '\n' ' ')"; fi
else say mount_tmpfs "skipped: no root"; fi

# optional install of a FUSE test program
if ! command -v fuse-overlayfs >/dev/null 2>&1 && [ "${PROBE_INSTALL:-0}" = 1 ]; then
  if command -v apt-get >/dev/null 2>&1; then
    T 110 $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1
    T 110 $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fuse3 fuse-overlayfs >/dev/null 2>&1
  elif command -v apk >/dev/null 2>&1; then
    T 110 $SUDO apk add --no-cache fuse3 fuse-overlayfs >/dev/null 2>&1
  fi
fi
say fuse_overlayfs "$(command -v fuse-overlayfs || echo absent)"

fuse_try() { # $1 label, $2.. command prefix
  label=$1; shift
  rm -rf "$W/up" "$W/wk"; mkdir -p "$W/up" "$W/wk"
  if T 30 "$@" fuse-overlayfs -o "lowerdir=$W/src,upperdir=$W/up,workdir=$W/wk" "$W/mnt" 2>"$W/$label.err"; then
    got=$(T 10 cat "$W/mnt/hello.txt" 2>/dev/null)
    typ=$(awk -v m="$W/mnt" '$2==m{print $3}' /proc/mounts)
    say "$label" "ok type=$typ read=$got"
    T 20 $SUDO fusermount3 -u "$W/mnt" 2>/dev/null || T 20 $SUDO umount "$W/mnt" 2>/dev/null
  else
    say "$label" "failed: $(head -c 200 "$W/$label.err" | tr '\n' ' ')"
  fi
}
if command -v fuse-overlayfs >/dev/null 2>&1; then
  if [ "$(id -u)" = 0 ] || [ -n "$SUDO" ]; then fuse_try fuse_mount_root $SUDO; else say fuse_mount_root "skipped: no root"; fi
  if [ "$(id -u)" != 0 ]; then fuse_try fuse_mount_user env; fi
else
  say fuse_mount_root "skipped: no fuse-overlayfs"
fi

# --- L2: nested Docker with LayerFS's Sandbox flags ------------------------
if [ "${PROBE_DOCKER:-1}" = 1 ]; then
  if command -v docker >/dev/null 2>&1 && T 20 $SUDO docker info >/dev/null 2>&1; then
    say docker "$($SUDO docker info --format '{{.ServerVersion}} runtime={{.DefaultRuntime}} storage={{.Driver}} cgroup={{.CgroupVersion}}' 2>/dev/null)"
    cat > "$W/seccomp.json" <<'EOF'
{"defaultAction":"SCMP_ACT_ALLOW","syscalls":[{"names":["unshare","clone"],"action":"SCMP_ACT_ERRNO","errnoRet":1,"args":[{"index":0,"value":268435456,"valueTwo":268435456,"op":"SCMP_CMP_MASKED_EQ"}]},{"names":["clone3"],"action":"SCMP_ACT_ERRNO","errnoRet":38}]}
EOF
    if T 110 $SUDO docker run --rm --pull=missing \
        --device /dev/fuse --cap-add SYS_ADMIN \
        --security-opt apparmor=unconfined --security-opt no-new-privileges=true \
        --security-opt seccomp="$W/seccomp.json" \
        alpine:3.20 sh -c 'apk add --no-cache -q fuse3 fuse-overlayfs >/dev/null 2>&1; mkdir -p /s /m /u /w; echo nested > /s/f; fuse-overlayfs -o lowerdir=/s,upperdir=/u,workdir=/w /m && cat /m/f && grep " /m " /proc/mounts | cut -d" " -f3; unshare -U true 2>/dev/null && echo userns=allowed || echo userns=denied' \
        >"$W/l2.out" 2>"$W/l2.err"; then
      say l2_fuse_in_container "ok $(tr '\n' ' ' < "$W/l2.out")"
    else
      say l2_fuse_in_container "failed: $(tail -c 240 "$W/l2.err" | tr '\n' ' ')"
    fi
    if T 60 $SUDO docker run --rm -v lfsprobe-vol:/v alpine:3.20 stat -f -c %T /v >"$W/vol.out" 2>&1; then
      say l2_volume_fs "$(cat "$W/vol.out")"
    else say l2_volume_fs "failed: $(head -c 160 "$W/vol.out")"; fi
    T 30 $SUDO docker volume rm lfsprobe-vol >/dev/null 2>&1
  else
    say docker "absent or not running"
  fi
fi
say done ok

Activity

  1. changed the title [-]Sandbox experiment: can LayerFS mount in agent sandboxes? Local stand-ins first[/-] [+]Sandbox backends: which agent sandboxes can replace LayerFS's Docker container? Local stand-ins first[/+] on Oct 30, 2026
  2. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Microsandbox (libkrun microVM): every level passed, and route A works except for one design change

    msb 0.7.11 (official release tarball, checksum verified), Debian 13 guest, kernel 6.12.113 aarch64. Stands in for E2B, Vercel Sandbox and Modal's VM runtime.

    Level Result
    L1, FUSE in the guest PASS. Commands run as root with every capability; /dev/fuse is present; a real fuse-overlayfs mount works; PID 1 (init.krun) reaps orphans
    L2, the privilege split (no Docker) Same as the reference. Root mounts with allow_other; a uid-1500 command with no capabilities reads and writes but cannot unmount. It can create a user namespace and copy the mount. Root closes that with user.max_user_namespaces=0 (then unshare fails with ENOSPC); the daemon needs no user namespaces
    L3, backend contract B1, B3–B10 yes; B2 partly (below). B7 can also kill one exec, which Docker can't
    L4, real LayerFS PASS, but over route B: calls_real.mjs inner workspace 15/15 with Docker 29.9.2 inside the guest. Route A can't run end to end until a microVM backend exists
    Route B, nested Docker with LayerFS's exact flags PASS: FUSE mount inside, user namespaces denied, ext4 volume

    Backend contract

    Item Verdict
    B1 Create yes, from an image digest with labels. The name is the only identity, so the backend must pick a unique one; create refuses an existing name rather than adopting it. A failed create can leave state behind
    B2 Store volume partly. A disk volume is a guest-local ext4 block device, which suits the Store, but a second VM can't attach it
    B3 Upload yes (--copy-file keeps 0600; msb copy)
    B4 Start yes, with the user-namespace gap above
    B5 Startup log yes (msb logs --job)
    B6 Endpoint yes, private: -p 127.0.0.1:H:G binds host loopback only
    B7 Exec yes: uid:gid, argv with no shell, env, cwd, stdin to EOF, separate stdout/stderr, exit code, and kill one exec
    B8 Reopen yes, by name, with inspect JSON
    B9 Stop/delete yes; a named volume survives and its data reads back
    B10 Discover yes (ls --label)
    S1 Store access SDK inside the VM (as in L4), or the worker over a loopback-only port

    The design change (B2): two VMs never share a kernel or a Store disk. So with route A, a project's Sandboxes become several daemons inside one VM per project (or per Store), not one VM each. L4 showed the SDK and several daemons sharing one guest kernel.

    Open:

    • whether msb exec -u drops capabilities and sets no-new-privileges by itself (the probe used setpriv, so plan on the backend wrapping commands);
    • what happens when the per-VM host process dies;
    • the SDK libraries (only the CLI was exercised).

    Found along the way:

    • Debian's docker.io 26.1.5 (Engine API 1.45) is older than the 1.48 LayerFS needs.
    • Docker in a guest whose root is overlayfs needs /var/lib/docker on a real ext4.
    • The L1 probe's timeout needs -k so it can stop docker run.

    A shared host folder (virtio-fs) shows up as virtiofs, has attribute-cached coherence, and keeps guest ownership in an xattr, so it isn't a Store volume.

    Hosted providers' kernels, policies and volumes are untested; each needs its own run. All test VMs and volumes were removed.

  3. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Daytona open source, run locally: FUSE works, but there's no volume that can hold the Store

    Daytona OSS docker-compose from release v0.190.0. The published images report v0.187.0 (there's no 0.190.0 tag on Docker Hub). It ran locally on Docker Desktop (arm64), with no Daytona cloud account. It's a best case for Daytona's container sandboxes: the cloud may use a different runtime (Sysbox or VMs).

    What a sandbox is here: a runc container with Privileged=true, run by the runner's own Docker. It has only label=disable (no seccomp, no AppArmor, no user namespace), and PID 1 is Daytona's daemon, which reaps children. The OSS runner accepts amd64 images only, so on Apple Silicon the sandbox ran under emulation.

    Level Result
    L1, FUSE in the sandbox PASS. /dev/fuse is present, root (via sudo) has every capability, and fuse-overlayfs mounts as root and as a plain user
    L2, the privilege split (no Docker) Same as the reference. The allow_other mount works; a uid-1500 command reads and writes but can't unmount; it can create a user namespace and copy the mount. Root can write user.max_user_namespaces, but a privileged sandbox shares the runner's kernel, so that would change every sandbox on the runner
    L3, backend contract B2 no, B6 limited, B7 partly; the rest yes (below)
    L4, real LayerFS Not run: it needs an amd64 LayerFS build, and B2/B6 rule out route A as it stands
    Route B, nested Docker Not determined. It failed only because amd64 runc can't run under emulation; this says nothing about Daytona's x86 cloud

    Backend contract

    Item Verdict
    B1 Create yes: UUID identity, labels, @sha256 snapshots (a bare :latest is refused)
    B2 Store volume no. Volumes are S3 buckets mounted with mountpoint-s3 (FUSE), which has no in-place overwrite, rename or locks, so it can't hold SQLite. Only a sandbox's own overlay root filesystem is left, and it isn't shared or ext4/xfs/btrfs
    B3 Upload yes (upload plus chmod/chown)
    B4 Start partly: root, FUSE and reaping work; nothing blocks user namespaces per sandbox
    B5 Logs yes (session command logs)
    B6 Endpoint HTTP/WebSocket preview proxy gated by a token (no token gets a redirect to login; with it, 200). Raw TCP only through SSH port forwarding (seen in source, not tested). Private means token-gated, not unexposed
    B7 Exec partly: commands go through bash (no argv), and there's no uid parameter and no explicit stdin EOF. Env, cwd, exit code and separate stdout/stderr (sessions) work. Killing one exec (delete its session) works, which Docker can't do
    B8 Reopen yes; stop and start keeps the filesystem. Auto-stop after 15 minutes by default
    B9 Stop/delete yes; the volume survives
    B10 Discover yes, by label
    S1 Store access only with the SDK or worker inside one sandbox, reached through B6

    Verdict: a Daytona container sandbox can mount FUSE, but it can't be a route-A backend as it is: there's no local volume for the Store (B2), commands can't be given a separate uid (B7), and nothing blocks user namespaces per sandbox (B4). A Daytona VM sandbox class exists in the API but has no implementation in the open-source runner, so it needs a cloud account to test.

    All of the run's containers, volumes, networks and pulled images were removed.

  4. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Apple Container (one VM per container): FUSE works with --cap-add SYS_ADMIN; no shared Store, and the host endpoint is unconfirmed

    container 1.6.1 (Homebrew; Apple's CLI), Linux 6.18.35 aarch64 guest, Debian 12 image. It stands in for platforms that run one VM per container.

    Level Result
    L1, FUSE in the container Default flags: FAIL. /dev/fuse is present but SYS_ADMIN isn't granted (Operation not permitted). With --cap-add SYS_ADMIN: PASS (fuse.fuse-overlayfs mount and read). No --device option is needed
    L2, the privilege split (no Docker) Same as the reference: the allow_other mount works; uid 1500 can read and write but can't unmount; it can create a user namespace and copy the mount. With --cap-add SYS_RESOURCE, root can mount a fresh procfs and set user.max_user_namespaces=0, after which the command's unshare -U fails. The VM belongs to one sandbox, so that VM-wide setting is acceptable. There's no --security-opt seccomp
    L3, backend contract B1, B4, B5, B8, B9 yes; B2 no; B3, B7, B10 partly; B6 not working here (below)
    L4, real LayerFS Not run: no working host endpoint, and route B failed
    Route B, nested Docker FAIL as tried: dockerd's iptables setup needs CAP_NET_ADMIN (--cap-add NET_ADMIN not tried)

    Backend contract

    Item Verdict
    B1 Create yes, from a digest with labels; a UUID identity, or --name, which refuses duplicates
    B2 Store volume no. A named volume is an ext4 block image, but a second running container can't attach it (storage device attachment is invalid), and separate kernels couldn't share SQLite locks anyway. Several daemons in one VM wasn't tested
    B3 Upload partly: container cp works only on a running container and drops modes (0755 and 0600 both became 0644). A pinned image carrying the daemon avoids it
    B4 Start yes: --init gives a PID 1 that reaps orphans
    B5 Logs yes, but stdout and stderr come back merged
    B6 Endpoint -p 127.0.0.1:H:G binds host loopback, but the forwarder logged No route to host; the host couldn't reach any VM, while VM-to-VM TCP worked. Likely macOS Local Network privacy blocking the runtime helper; not confirmed
    B7 Exec partly: uid:gid, env, workdir, argv with no shell, stdin with EOF, separate stdout/stderr and the exit code work. No exec ID; a nonroot exec keeps its bounding capabilities with NoNewPrivs 0 (needs a setpriv wrapper); no way to kill one exec except by PID from another exec
    B8 Reopen yes, inspect by ID; stop and start keeps files
    B9 Stop/delete yes; the volume's data survives
    B10 Discover only by filtering ls --format json on the client
    S1 Store access only through a worker inside the VM that holds the Store, which needs B6

    Verdict: one VM per sandbox gives each its own kernel and its own Store disk, so route A would mean one container per project running several daemons, like the Microsandbox result. Apple Container is a local tool, not a hosted provider; it would matter only as a macOS backend, and then B6 must work first.

    All the run's containers, volumes and images were removed, and the container service is stopped.

  5. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Apple Container, iteration 2: one VM per project works, and real LayerFS passes inside it

    Iteration 1's blockers came from treating every Sandbox as its own VM. This iteration tested one Apple container (one VM) per project: it holds the project's Store on its named volume and runs several Workspaces, each in its own mount namespace.

    Check Result
    Store on the named volume PASS. ext4 on /dev/vdc, which LayerFS's filesystem guard accepts. In-place writes, rename-over, SQLite WAL with a second process (the second writer is refused), and flock all pass. After the container was deleted and a new one started on the same volume, the earlier marker was still there. A loop-mounted ext4 on the root disk also passes
    Several Workspaces in one VM, kept apart PASS. Two private mount namespaces, each with its own allow_other FUSE mount; neither is visible in the VM's own namespace or in the other. Commands sent in with container exec -u 0 … nsenter -t <pid> -m setpriv --reuid=1500 … --bounding-set=-all --no-new-privs ran at the same time as uid 1500, with no capabilities and no-new-privs. Each read its own mount and saw the other Workspace as empty. Unmount was denied. user.max_user_namespaces=0 (needs --cap-add SYS_RESOURCE) blocks user namespaces VM-wide. This closes iteration 1's B7 capability gap
    B6 endpoint over exec stdio PASS. A small relay over container exec -i carried 4 MiB to an echo server on the guest's 127.0.0.1:7000 and back, with matching sha256, and an HTTP request round-tripped. Opening a bridge took about 0.06 s. A fresh -p 127.0.0.1:H:G also worked this time; iteration 1's failure was transient, cause unknown
    B3 upload with modes PASS. A tar stream into container exec -i … tar -x -p kept 0755, 0600 and 0700 (the container must be running)
    Route B and L4, real LayerFS PASS: calls_real.mjs inner workspace 15 calls in 315 ms, first attempt. Flags --cap-add SYS_ADMIN --cap-add NET_ADMIN --init, Docker's official static 29.9.2. The sealed bundle and the afa1ef9c… image came in over a host bind mount, not the network

    Found along the way:

    • dockerd in the VM needs /proc/sys remounted read-write.
    • With Docker running inside, container stop failed on the nested cgroups (errno 95 writing cgroup.kill). A backend should stop the inner dockerd before stopping the VM.

    Not proven yet:

    • layerfs-daemon itself running in a per-Workspace namespace with no Docker. The isolation test used fuse-overlayfs, and L4 went through Docker. That needs a route-A backend, or a stand-in, to drive the daemon.
    • An SDK on the macOS host. It ran inside the VM, so Store access still needs the SDK in the VM, or the worker reached over the stdio bridge or -p.

    Updated contract for this design:

    • yes: B1, B3, B4, B5, B6, B7, B8, B9;
    • B2 yes, per project;
    • B10 only by filtering the JSON listing on the client;
    • B7 still has no per-exec kill (a second exec can kill by PID).

    All containers, volumes and images were removed, and the container service is stopped.

  6. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Lima VM (EC2-style) and a real Firecracker microVM: both pass

    Two iterations. In the first, Lima 1.0.4 couldn't start a vz VM on this host: its network stack, gvisor-tap-vsock v0.8.2, watches the folder that holds /etc/resolv.conf and fails on a socket symlink there. Lima v2.2.1 (official release, checksum verified, run from a scratch folder) bundles v0.8.9, which drops that watcher. With it, vz with nested virtualization boots, and the VM has /dev/kvm.

    Plain Linux VM: Ubuntu 24.04 arm64, kernel 6.8, stands in for EC2

    Level Result
    L1, FUSE PASS, as root and as a plain user
    L2, privilege split mount with allow_other ok; the uid-1500 command reads and writes; unmount denied. User namespaces are allowed, but copying the mount was blocked by Ubuntu's AppArmor userns restriction. user.max_user_namespaces=0 denies them outright, VM-wide
    L4, real LayerFS (route B) PASS with Ubuntu's docker.io 29.1.3 and the sealed bundle: sandbox_real (16 FUSE Workspaces in one Sandbox, Exec) ok in 1095 ms; calls_real workspace 15 calls in 488 ms; runtime_real ok. The Store volume is ext4

    Firecracker v1.17.0 guest, nested in that VM, stands in for E2B and Vercel

    The guest booted Firecracker's CI kernel 6.18.56 and its CI Ubuntu rootfs, with a second ext4 drive as the Store and vsock (no network device).

    • Kernel config: all three of Firecracker's CI aarch64 kernels (5.10, 6.1, 6.18) build in CONFIG_FUSE_FS=y, plus OVERLAY, EXT4, XFS, USER_NS, SECCOMP_FILTER and VSOCK. They don't set BTRFS, VIRTIO_FS or AppArmor. Providers build their own kernels, so FUSE has to be checked on each one.
    • /dev/fuse: present at boot via devtmpfs, so no mknod is needed. L1 PASS.
    • L2: the same as the reference. Mount, read and write work; unmount is denied; user namespaces are allowed and the mount copy succeeds. There is no AppArmor, so the backend's launcher needs a seccomp filter or the sysctl.
    • Store drive: /dev/vdb, found by its label, ext4, read-write.
    • Route B PASS: Docker 29.1.3 inside the guest with LayerFS's flags mounts FUSE inside a container, and user namespaces are denied there. dockerd ran with --bridge=none --iptables=false (the guest has no network), so Docker networking wasn't tested.
    • B6 over vsock: works both ways as a private byte stream. Host to guest goes through the Unix socket's CONNECT <port>; guest to host connects to CID 2. The daemon's TCP endpoint would need a small vsock relay on one side.
    • B7: Firecracker has no exec API, so a backend needs an agent in the guest over vsock. Providers ship their own agent, which is what their exec API is.

    Contract, for a VM or microVM per project

    • B2: VMs don't share a kernel, so the Store goes on the project VM's own ext4 disk, with several daemons inside. L4 showed several Sandboxes on one Store in one VM.
    • B4: commands need setpriv plus a seccomp filter or the sysctl.
    • S1: needs the SDK or the worker inside the VM.
    • B1, B3, B5, B8, B9, B10: met through the VM's own tooling (image/rootfs pinning, drives, logs, lifecycle).

    Not shown:

    • Providers' own kernels and policies.
    • EC2's kernel and networking.
    • Docker networking in a Firecracker guest.
    • Real LayerFS inside the Firecracker guest. Only the probes and route B ran there.

    Everything was deleted: the VM, the Firecracker files, the downloaded Lima and images. Homebrew's Lima and Colima weren't touched.

  7. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Daytona open source, iteration 2: the Store on a loop ext4 works, and real LayerFS passes in a sandbox

    Iteration 1's blockers were the S3 volumes and the amd64-only runner. This iteration got around both.

    Check Result
    Store on a loop ext4 in the sandbox PASS. The sandbox's overlay root is rejected by LayerFS's filesystem guard. A loop-mounted ext4 image on the sandbox's own disk passes in-place writes, rename-over, SQLite WAL with a second process, and flock. After a stop and start through the API, remounting the same image showed the earlier marker. The loop mount itself doesn't survive a stop, so a backend remounts at start. The image is a file in the sandbox's overlay upper dir on the runner's disk. Loop devices belong to the runner's whole kernel, so every privileged sandbox can see them
    Durability Recorded only, not tested. Daytona's mountpoint-s3 volumes can hold whole-file copies of a checkpointed Store, not a live SQLite file. The same bucket is reachable over the S3 API for LayerFS's own remote Store push and pull. From the source: archive/backup (docker commit, not paused) keeps the image file, but it's only crash-consistent while mounted, and it loses sparseness
    Several Workspaces in one sandbox, kept apart PASS. A FUSE mount made inside a private mount namespace is invisible to the sandbox's own namespace, and two namespaces mounted different content at the same path. A uid-1500 command with no capabilities, entered with nsenter, reads and writes but can't unmount. User namespaces stay open unless a seccomp filter is added: the sysctl would affect the whole runner kernel
    amd64-only limit Removed with a local runner patch (built locally, never pushed). No config allows arm64: the runner forces amd64 in five places in apps/runner/pkg/docker. The patch reads the architecture from an env var. Daytona's own in-sandbox daemon is still shipped amd64 only, so it ran under emulation here; a real arm64 host would need an arm64 build of it
    Route B and L4, real LayerFS PASS: calls_real.mjs inner workspace 15 calls in 394 ms in a native arm64 sandbox, with Docker's official static 29.9.2 (Engine API 1.56), /var/lib/docker on a loop ext4, the sealed bundle and the afa1ef9c… image
    B6 raw TCP PASS through the SSH gateway: -L 127.0.0.1:H:127.0.0.1:P carried raw TCP to a listener in the sandbox, bound to the host's loopback
    B7 uid PASS with a wrapper: sudo setpriv … gives uid 1500, all capabilities 0, NoNewPrivs 1 and the env. Commands still go through bash. No stdin EOF for async execs: Daytona's daemon holds the input open until the command ends, and appends a newline. Workaround: upload input as a file and redirect from it

    Found along the way:

    • The API rewrote a snapshot given by digest into an invalid reference.
    • A stale amd64 copy in the runner had to be removed before the arm64 pull.
    • Resources can't be set together with a named snapshot.

    Verdict: Daytona's container sandboxes can work in a one-sandbox-per-project design:

    • the Store on a loop ext4 inside the sandbox, remounted at start;
    • several Workspaces in their own mount namespaces;
    • commands through setpriv;
    • the daemon endpoint over the SSH gateway.

    Gaps for a backend:

    • per-sandbox user-namespace blocking (needs a seccomp filter in the launcher);
    • stdin EOF on async execs;
    • everything rests on the sandbox being privileged, which the cloud may not allow.

    This is still the local open-source stack only.

    Everything the run created was removed: the sandboxes, loop devices, the compose stack and its volumes, and the pulled and built images.

  8. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Route A end to end: real LayerFS ran with no Docker at all

    Result: the unmodified sealed bundle (build-linux-bb5f636…, source 7ab54bcf1a, hashes match its manifest) passed calls_real.mjs inner workspace, 15 of 15 calls. It ran inside an Apple Container VM (Linux 6.18.35 aarch64) with no Docker in it: docker, dockerd, containerd, runc, crun and tini were all absent.

    • 146 ms with user namespaces open;
    • 122 ms with them blocked by user.max_user_namespaces=0;
    • an earlier pass in a Docker Desktop development container took 151 ms.

    Each run used a fresh ext4 Store and the Disposable profile.

    How: a test-only stand-in (about 450 lines of Node, no dependencies, never committed) answers the Engine API v1.54 calls LayerFS makes on /var/run/docker.sock:

    Call What the stand-in does
    volume read a directory on a loop-mounted ext4 disk, bind-mounted at /layerfs-store
    create, upload an overlay of the unpacked image over a per-container directory; the upload tar is extracted there while stopped
    start new mount, network, PID, IPC and hostname namespaces. A tiny init runs the daemon as root with SYS_ADMIN and reaps orphans. Only /dev/fuse and basic devices are in /dev
    logs, inspect the daemon's stdout (follow and tail). The daemon port is published on the VM's 127.0.0.1 through a relay into its network namespace
    exec create, start, inspect nsenter into the namespaces, then setpriv to the requested uid:gid with no capabilities and no-new-privs; stdin, stdout and stderr go over the hijacked stream
    stop, delete kill the namespace's processes, unmount, remove the root; the Store is kept

    Honest limits of the stand-in: inspect echoes the Config and HostConfig fields LayerFS verifies (including the seccomp JSON) from the create request; it doesn't enforce them all. It does enforce:

    • root with SYS_ADMIN in the bounding set;
    • no-new-privileges;
    • the /dev device set;
    • the reaping init;
    • loopback-only ports.

    It does not enforce seccomp (the VM-wide sysctl stands in for it), a device cgroup, or any cgroup limits.

    Isolation checks (two Sandboxes, real LayerFS Workspace mounts, through workspace.exec):

    • Commands had CapEff, CapPrm and CapBnd all zero, and NoNewPrivs 1.
    • They could read and write the Workspace; written files are owned by the command's uid.
    • Unmount and lazy unmount were denied.
    • Each Sandbox had its own mount, network and PID namespace; the VM's own namespace saw no FUSE mounts.
    • A leftover process was reparented to the Sandbox's PID 1 and reaped.
    • Before the sysctl, unshare -U worked and a command could copy the mount. After it, both failed (No space left on device).

    VM requirements found:

    • --cap-add NET_ADMIN, to bring up loopback in a new network namespace;
    • --cap-add SYS_RESOURCE, for the sysctl;
    • Apple's kernel has no /proc/<pid>/task/<pid>/children, so PID 1 is found by scanning /proc.

    What this proves: LayerFS's SDK and daemon need nothing Docker-specific. Any Linux VM that gives root, /dev/fuse, namespaces and an ext4 disk can host the Sandbox contract. The backend's job reduces to the stand-in's job.

    What a real route-A backend still needs:

    • a provider adapter in place of local nsenter (or this kind of agent inside the VM);
    • the SDK's path to the daemon ports from outside the VM: the worker route, or a forward like the ones Microsandbox (-p on 127.0.0.1), Daytona (SSH gateway), Apple (exec stdio) and Firecracker (vsock) showed;
    • one persistent ext4 Store volume per project VM;
    • per-container seccomp and cgroups;
    • durable backend state, so a Sandbox can be reopened after the backend process restarts.

    Both VMs, the image and all the tars were removed, and the container service is stopped.

  9. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Route A in a Microsandbox microVM: real LayerFS with no Docker, and the Store survives the VM

    This repeats the no-Docker run in a libkrun microVM, which stands in for E2B, Vercel and Modal's VM runtime, using the test-only Engine API stand-in from the route-A comment. It used the unmodified sealed bundle (build-linux-bb5f636…) on msb 0.7.11, with Linux 6.12.113, 4 GiB and 4 vCPUs. Everything went in over virtio-fs shares, with no guest network. In the guest, docker, dockerd, containerd, runc, crun and tini were all absent, and ps showed only /init.krun and the stand-in.

    Check Result
    calls_real.mjs inner workspace, user namespaces open PASS, 15 calls in 498 ms
    the same with user.max_user_namespaces=0 PASS, 15 calls in 164 ms
    sandbox_real.mjs inner: 16 Workspaces in one Sandbox, sysctl set ok in 854 ms
    Isolation, two Sandboxes, real Workspace mounts through workspace.exec Commands run as uid 501:20 with CapPrm, CapEff and CapBnd all 0 and NoNewPrivs 1. They read and write, and their files are owned by 501. umount and umount -l are denied. Each Sandbox has its own mount, network and PID namespace and sees only its own FUSE mount; the VM itself sees none. A leftover process is reaped by the Sandbox's PID 1. Under the sysctl, unshare -U and copying the mount fail; without it they succeed (the known gap)
    Store durability After msb stop and msb rm, a new microVM with only the Store disk attached found the Store unchanged (same sha256) and integrity_check ok. LayerFS.open listed branches a, b and main, and read back a file that an exec in the earlier VM wrote and committed

    Layout: two disk volumes, both ext4 block devices in the guest:

    • the Store disk, holding the Store directory;
    • a work disk for the unpacked Sandbox image and each Sandbox's overlay upper layer. The guest's own root is an overlay, and an overlay upper directory can't sit on overlayfs.

    Compared with Apple Container:

    • Microsandbox gives guest root every capability with no flags; Apple needed --cap-add SYS_ADMIN, NET_ADMIN and SYS_RESOURCE.
    • Microsandbox's kernel has the /proc/<pid>/task/<pid>/children file, which Apple's lacks.
    • A plain sysctl -w works on Microsandbox; Apple needed a fresh procfs mount.

    Timings are single samples, not measurements.

    Everything was removed: both microVMs, both volumes, the image and the share tars.

  10. yifanxuaaa commented on Oct 30, 2026

    @yifanxuaaa
    ContributorAuthor

    Real LayerFS inside a Firecracker microVM: passes with no Docker

    This closes the Firecracker gap from the Lima and Firecracker comment, where only the probes had run. It used the unmodified sealed bundle build-linux-bb5f636…, all 192 hashes matching its manifest, and the test-only Engine API stand-in from the route-A comment.

    Rig: Firecracker v1.17.0 with the CI kernel 6.18.56 (4 vCPUs, 4096 MiB, no network), nested in a Lima 2.2.1 vz VM. The guest root was the Sandbox image's own root filesystem (Debian 12, Node 24), unpacked from sha256:afa1ef9c…. A second virtio-blk ext4 drive held the Store and the Sandbox data. A small guest init ran the stand-in and the test.

    Run Result
    calls_real.mjs inner workspace, user namespaces open PASS, 15 calls in 669 ms
    the same with user.max_user_namespaces=0 PASS, 15 calls in 367 ms; unshare -U as the command identity denied
    container runtime in the guest none: no dockerd, containerd, shim, runc, crun or docker process or binary

    Nested-rig note: with ordinary 4 KiB guest memory, the guest was very slow. Kernel-to-init took 28–55 s and node -e 0 took 20 s, so the first attempt timed out after 3 calls. The cause was first-touch page faults passing through two hypervisor levels. Backing guest memory with 2 MiB huge pages (Firecracker "huge_pages": "2M") cut boot to 1.9 s and node -e 0 to 0.4 s; boot, both runs and poweroff took 8.2 s in all. This is a cost of nesting Firecracker on a Mac, not of LayerFS or Firecracker on bare metal.

    Not done:

    • Route B (Docker in the guest): it needs a network or a large offline package set. The CI kernel has bridge, veth and NAT, but lacks NF_TABLES_INET, DUMMY, MACVLAN and IPVLAN. Port publishing is untested, and no blocker was found.
    • Hosted providers' own kernels and guest agents remain to be checked.

    Provenance caveat: the stand-in source was being revised by the parallel Microsandbox run when this run copied it, so which of its two versions ran here isn't recorded. Both versions passed calls_real elsewhere.

    Timings are single samples, not measurements. Everything was deleted: the VMs, drives, downloads and the short Lima home.

    Where this leaves #512

    Real LayerFS has now passed with no Docker in three VM kinds:

    • an Apple Container VM;
    • a Microsandbox (libkrun) microVM, including the Store surviving VM deletion;
    • a Firecracker microVM.

    It also passed with Docker inside on Microsandbox, Apple Container, local Daytona and a Lima VM. The proposed backend is in draft PR #515.

  11. added a commit that references this issue on Oct 31, 2026
  12. Mola-maker commented on Nov 1, 2026

    @Mola-maker
    Member

    感谢提出这个 issue,也感谢 AI 协作工具在整个排查过程中做的分析 🙏

    对 AI agent 的使用来说,这个问题的解决会直接改善 agent 能否用现成 sandbox 标准替代 Docker,决定 agent 环境的部署成本。

    两点建议:

    1. 动手修改前请先实测:在真实环境(Linux WSL2/ext4 或原生)复现 issue 描述的行为,把复现命令与输出贴出来,确认问题在最新 main 上仍然存在。
    2. 实现等 LayerFS 2.0 发布:2.0 roadmap(LayerFS 2.0 roadmap #446)已在推进,建议把修复/功能实现排进 2.0 的计划里,当前阶段先冻结讨论与实测证据,避免在旧基线上做出之后需要重做的改动。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions