Table of Contents
Gotchas (hard-won)
-
Never
readdir/lsa FUSE mount to test liveness — on a stale FUSE mount (server died) it blocks waiting for the dead server. Staleness is detected by the recorded daemon PID's liveness (/proc/<pid>/comm == bubblebox-fuse) inmount.rs::ensure_mounted.is_mountpointuses themountpointcommand (stat-based, never blocks, detects stale mounts). -
mount()must unmount beforecreate_dir_all—create_dir_allon a stale FUSE mount errorsEEXIST. mount() best-effortfusermount3 -u(stderr silenced — it prints "entry not found" when nothing's mounted) then waits for clear, before any dir creation. -
Composefs object names ≠ sha256(content) (empirically a different digest, likely fs-verity-style). So per-blob content verification on pull is not done yet — pull verifies the descriptor via
composefs-info measure-fileand relies on HTTPS + authenticated blob names for the rest. Settling the digest algorithm is a deferred task. -
overlay
lowerdirorder = priority, leftmost wins.run_composedbuildslowerdir=primary:dep1:dep2so the primary package's files shadow deps on conflict (main-wins). Read-only overlay (no upperdir) — the sandbox rootfs is read-only; writes go to bind-mounted/home,/tmp,/persist. -
zsh does not word-split unquoted
$VAR— when callingbubblebox export $TOOLSfrom a zsh recipe/script, use a real array (tools=(a b c); … $tools) or${=TOOLS}, else it's passed as one giant arg. -
run/compose-innertake trailing args (trailing_var_arg+allow_hyphen_values), sobubblebox run rg --versionworks without--(the wrapper form).-- --fooalso works. Don't switch back tolast = true. -
The release binary is large (~22 MB) because of
aws-sdk-s3+reqwest+tokio. That's the accepted cost of a self-contained tool (no rclone/aws-cli host dep). A lighter S3 crate could shrink it later. -
Descriptor digests are not reproducible across extractions — re-building or re-importing the SAME image yields a different descriptor digest (podman export carries non-normalized metadata; mkcomposefs folds it in). Store blobs still dedupe by content, so the cost is one orphaned descriptor (+ any unshared blobs) per rebuild until
gc --apply. Don't rely on digest-equality to detect "same image". -
NixOS setuid helpers live in
/run/wrappers/bin, which non-login environments omit from PATH — a barefusermount3there resolves to the unprivileged store copy and every FUSE mount dies withfusermount3: mount failed: Operation not permitted. This silently ran the bee nightly CI red for two weeks (2026-09-05→19). The engine now prepends the wrappers dir to the FUSE server's PATH and tries the wrapper binary first on unmount (8d59009);tools/verify.sh/nightly.shharden their own PATH the same way. Same PATH class as NixOSsudo(see homelab-ops). -
Non-login/agent shells differ from login shells in more than PATH —
--userns=keep-idbreaks in them (crunreadlink ''; use rootless podman's default mapping, it already maps container-root to you), ssh needs-F /dev/nullon this host (bad-perms systemd ssh_config include), and DSH agent sandboxes give/run/userread-only plus a private/tmp.bubblebox doctorfirst, then suspect the environment, not the code. -
bwrap
--bind-trytolerates a missing SOURCE but not a missing DEST — an entrypoint that top-level-binds a host dir (e.g./sysfor btop's sensors) still needs that directory pre-created in the image rootfs, or the run dies "Can't mkdir /sys: Read-only file system" (composefs roots are read-only; bwrap only tolerates EEXIST). Same class as #9/#10: the standard-dir set in archetype-C finals exists for exactly this reason — extend it whenever an entrypoint binds a new top-level path (btop added /sys, 2026-09-23). -
Bind destinations cannot be created at run time — the engine now fails fast and tells you — a sandbox bind (or
bind_try, or even--dir) whose destination is missing from the image hits bwrap's dst mkdir on the read-only rootfs and dies "Can't mkdir : Read-only file system" LATE and cryptic (--diris no escape: same mkdir, same EROFS, bwrap 0.11.2).runnow validates every bind before exec (single-package AND compose-inner) and errors early, naming the bind and the fix: image-sideRUN mkdir -p <dst>(#11's tmux/nvim/dirs/pattern). dst existence is judged against the sandbox VIEW — host-overlaid dirs (/run /var /etc /home /mnt /media /opt /srv) stat host-side; /tmp /dev /proc are engine-mounted writable pseudo-fs. Two adjacent fixes in the same change: an UNSET$VARin an entrypoint expands to an empty string, and bwrap does NOT treat an empty bind_try source as missing (it falls into the dst mkdir → EROFS) —push_bindnow skips empty-source try binds with a warning (this is what makes the openssh-clients agent-socket design safe on hosts with no runtime dir), and required binds with empty/missing sources error loudly. Engine PR #1, 2026-09-24.