bubblebox devshells — Design
Status: Phase 4 (v1) implemented. Composable, stackable development
environments — bubblebox's answer to nix's nix develop / devShells. This
page is the design record; the wiki's Status page tracks what's built
across the whole engine.
Hub: bubblebox docs form a wiki. The Architecture + Decisions pages are the hub; this page and GC-and-Generations are feature pages (one area each).
1. The reframe
bubblebox already composed in two axes. Devshells add exactly one new axis — entrypoint inheritance — and everything else falls out for free.
| Composition axis | Mechanism | What it merges |
|---|---|---|
| package → package | compose = [...] → runtime overlay (lowerdir=primary:dep:…) |
files on the rootfs |
| entrypoint layers | deep_merge(default ⊕ override) |
config (binds/env) — replaces lists |
| entrypoint → entrypoint (inherit) ⬅ new | base = "X" → stack_merge |
stacks dev environments — appends lists |
So: a devshell is just an entrypoint whose binary is a shell, that
composes a set of tool packages and (optionally) extends a base entrypoint.
No new build/store model; the devshell "tool set" is a compose list, resolved,
pinned, and GC-rooted by the existing machinery.
2. Goals & non-goals
Goals. Reusable language shells (a rust dev environment, a python one);
per-project devshells checked into a repo (nix develop analog); composition
by stacking; an opt-in init hook; maximum reuse of apply/lock/run/GC.
Non-goals (this phase). Transient pull-on-entry (resolve + fetch missing
packages from caches when you enter — v1 requires the closure present);
multi-base inheritance (bases = [...]); per-project lockfiles; a devshell
daemon or scheduler.
Key asymmetry vs nix. nix devShells are derivations composed of build
inputs from one nixpkgs; bubblebox devshells compose whole OCI packages via
the runtime overlay. Inputs don't share a single store-level lib set — each
package is self-contained (decision #2/#6), so conflicts resolve at overlay
time (main-package-wins), not in a dependency closure.
3. The two forms
3.1 Reusable base shell — a normal entrypoint
A "language shell" lives in a source repo as a virtual entrypoint
(entrypoint.toml with no Containerfile, package pointing at an image that
provides a shell + userspace), composing tool packages on top:
# bubblebox-pkgs/rust-dev/entrypoint.toml
# `bash` here is whatever package ships /usr/bin/bash + a Fedora userspace
# (e.g. reuse the nvim image, or a dedicated base-shell image). rustc/cargo/rg
# are composed from their own packages — the overlay merges them in.
package = "bash"
binary = "bash"
compose = ["bash", "rustc", "cargo", "rg", "fd", "rust-analyzer"]
[sandbox]
writable_home = true # reads ~/.zshrc — your prompt/aliases work
[sandbox.env]
PATH = "/usr/local/bin:/usr/bin:/bin"
Enter it two ways:
bubblebox run rust-dev # via the normal run path
bubblebox shell --base rust-dev # via the shell command (convenience)
A base shell can be a profile.toml root (so apply builds/pins it and it's
always present + GC-rooted). It can also declare its own base to chain onto
a lower-level shell (base-shell).
3.2 Project devshell — bubblebox.toml
A project checks a bubblebox.toml into its root — the
flake.nix devShells.default analog. It is an overlay: every field is
optional and it extends a reusable base:
# ./bubblebox.toml (checked into the project, git-tracked)
base = "rust-dev" # the reusable shell to extend
init = "devshell.sh" # auto-sourced on entry (see §6)
compose = ["sqlx-cli", "watchexec"] # APPENDED onto rust-dev's toolchain
[[sandbox.bind]]
src = "$BUBBLEBOX_PROJECT_DIR" # the project root (set by `shell`)
dst = "/work" # must exist in the base image (mkdir it)
mode = "rw"
[sandbox.env]
DATABASE_URL = "sqlite:///work/db.sqlite"
cd ~/Code/my-project
bubblebox shell # finds ./bubblebox.toml, stacks onto rust-dev
4. Schema
Two new optional fields on the entrypoint (cli/src/config.rs):
base = "rust-dev" # parent entrypoint to extend (resolved recursively)
init = "devshell.sh" # script auto-sourced on entry (project-relative)
Both default to None; every existing entrypoint is unchanged. A project
overlay (bubblebox.toml) parses as a DevshellOverlay — identical fields but
package is optional (it's an overlay, not a standalone entrypoint).
Two merge functions coexist
| function | folds | list rule | used for |
|---|---|---|---|
deep_merge(base, over) |
default ⊕ override layers of one entrypoint | replace | existing behavior, unchanged |
stack_merge(parent, child) |
a child onto its resolved base parent |
append (dedup) | the new inheritance axis |
stack_merge field rules:
| field | rule |
|---|---|
package, binary, writable_* |
scalar — child wins, else inherit |
base |
cleared (the chain is resolved by load_effective) |
init |
child wins if set, else inherit |
compose |
union, deduped, child's package forced first (topmost overlay) |
bind / bind_try |
append, keyed on dst (a child bind to a dst the base already claims replaces it — bwrap rejects two binds to one path) |
dirs |
append, deduped |
env |
union, child wins on key conflict |
The compose-dedup + package-first ordering preserves overlay priority: the
child's primary stays the topmost lowerdir (main-wins — Gotcha #4 on the
wiki), and appended tools only ever contribute new files.
5. Resolution
load_effective is now recursive with a cycle guard:
load_effective(name):
self = deep_merge(default(name) ⊕ override(name)) # existing 2-layer merge
if self.base is None: return self
if name in visited: ERROR "cycle: … -> name"
return stack_merge(load_effective(self.base), self) # append semantics
bubblebox run <base-shell>folds the base's ownbasechain (a named entrypoint is re-derivable by name inside the userns).bubblebox shell(a project devshell) resolves the base viaload_effective, then folds thebubblebox.tomloverlay withstack_merge. The folded result has no on-disk name to re-resolve inside the userns, soshellpersists it to a temp file and pointscompose-innerat it via theBUBBLEBOX_DEVSHELL_EPenv var (seerun.rs::load_entrypoint). The file lives inrun_dir(tmpfs).
$BUBBLEBOX_PROJECT_DIR is exported by shell before bind/env expansion, so
$VAR refs in the overlay resolve against the project root (reuses the
existing expand_env — no new expansion code).
6. The constrained init hook
nix has shellHook (run code on entry). bubblebox deliberately has no
code-executing escape hatch (locked decision #3 — bind.create covered prior
uses). Devshells bend that narrowly and opt-in: an entrypoint may set
init = "<script>"; on entry bubblebox bind-mounts that script and a fixed
helper sources it before execing the shell.
- The script is a project file in git — never inline user code. bubblebox itself runs no arbitrary user strings.
- The fixed helper
bin/bubblebox-enter-shelldoes two things only:. /tmp/.bb-devshell-init.sh(the project script, if present);exec "$ENTER_SHELL" -li(a login interactive shell → reads your host RC viawritable_home).
ENTER_SHELLis set byrun.rsto/usr/bin/<binary>; the helper defaults to/bin/bash.- The script is sourced under
/bin/sh(the base image's sh — bash on Fedora). Keepdevshell.shPOSIX, or move bashisms into your host RC. - The helper + script are bound under
/tmp(.bb-enter-shell,.bb-devshell-init.sh) because the sandbox rootfs is read-only —/tmpis always bound rw in every bwrap./usr/binand/bbare not writable.
No init set → bubblebox run/shell exec the shell directly (unchanged).
7. CLI
bubblebox shell [--base <name>] [--no-pull] [--refresh] [-- args...]
- With a project
bubblebox.tomlupwards from cwd: resolve itsbase, fold the overlay, ensure the closure, enter the shell. - With
--base <name>and no project: drop into that reusable base shell in cwd (no lockfile — there's no project dir to pin against). - Neither: error ("no bubblebox.toml … pass
--base <name>").
Closure resolution — Phase 4.5 (implemented)
Missing packages are resolved + pulled on entry (nix develop semantics):
- Per closure member, in order of preference: already at its pinned digest →
done; pinned digest exists as a local
<digest>.cfs→ repoint the binding (zero network); caches configured → fetch by exact digest (cache::pull_digest, measure-verified per cache); nothing pinned and missing → index-resolve + pull likepull. - A pin that can't be honored warns loudly and proceeds on the local copy if
one exists (shells are interactive tools, not verifiers);
--refreshignores pins entirely and re-resolves + re-pins.--no-pullrestores the v1 error-on-missing behavior. bubblebox.lock(next tobubblebox.toml):[package] name = digestmap, rewritten after every successful entry with each member's current digest — teammates and post-gc hosts converge on byte-identical tools. BTreeMap serialization keeps diffs reviewable; a malformed lock warns and is ignored rather than blocking entry. Gitignore it for floating deps, commit it for reproducible ones.
8. GC & reproducibility
- A running devshell is already GC-safe. The GC roots live FUSE mounts
(
GC-and-Generations §3), and a devshell's packages are mounted while it runs. Between sessions, an exited devshell's tools are ordinary orphans — eligible for GC, re-resolvable on next entry. This is exactly nix's behavior (nix-store --add-rootonly while active); no new rooting machinery needed. - Reproducibility. A reusable base shell that's a
profile.tomlroot is pinned inprofile.locklike any package — bit-for-bit reproducible across hosts. A project devshell overlay (bubblebox.toml) resolves live against whatever base/tools are in the store; per-project lockfiles are deferred.
9. Worked example — dogfooding bubblebox itself
The natural first use: build bubblebox in a bubblebox devshell, retiring the
imperative fedora-toolbox dev container.
# ~/Code/bubblebox/bubblebox.toml
base = "rust-dev" # rustc + cargo + rg + fd …
init = "devshell.sh"
compose = ["gcc"] # Rust needs a C linker
# ~/Code/bubblebox/devshell.sh
export CARGO_HOME="$BUBBLEBOX_PROJECT_DIR/.cargo"
cd ~/Code/bubblebox
bubblebox shell
cargo build # full rust toolchain + gcc, isolated to the project
10. Decision stack
| # | Decision |
|---|---|
| 1 | A devshell is an entrypoint whose binary is a shell — no new top-level concept |
| 2 | Composition reuses the runtime compose overlay (main-package-wins) |
| 3 | Stacking = one new axis: base inheritance via stack_merge (append); deep_merge (replace) unchanged |
| 4 | Single base, chainable (rust-dev → base-shell); multi-base deferred |
| 5 | Project devshells = bubblebox.toml (git-tracked overlay); discovered by walking up from cwd |
| 6 | Init hook = constrained auto-source of a project script via a fixed helper; the only documented bend of decision #3, opt-in |
| 7 | v1 requires the closure present (no transient pull); error + hint otherwise |
| 8 | GC needs no new machinery — running devshells are live-mount roots |
11. Deferred (with triggers)
| Item | Trigger |
|---|---|
multi-base (bases = [...]) + diamond composition |
when a project genuinely needs two unrelated bases |
init sourced in the target shell's dialect (not /bin/sh) |
if POSIX-only init proves too restrictive |
bubblebox shell cwd defaulting into /work |
once a base-shell mkdir /work convention settles |
| landed (Phase 4.5) — see §7 |
12. Status
Phase 4 (v1) — implemented + verified.
config.rs:base/initfields;stack_merge+union_strings/union_binds; recursiveload_effectivewith cycle guard;DevshellOverlay+parse_overlay/load_overlay/overlay_to_entrypoint. 10 new unit tests (append vs replace, compose dedup/ordering, scalar win/inherit, bind same-dst, overlay parse, overlay→entrypoint fallback).run.rs:shell();resolve_init(); init binds under/tmp+ENTER_SHELL; devshell-ep hand-off into the userns (BUBBLEBOX_DEVSHELL_EP).common.rs:find_project()(walk-up discovery).main.rs:Cmd::Shell { base, args }.bin/bubblebox-enter-shell: the fixed init helper.
Verified: cargo build + cargo clippy --all-targets (clean, clippy::all
deny) + 64 tests (was 54). e2e (real store, isolated config dir): reusable
base shell via run (composed nvim+rg+fd+bat, bash 5.3.9); project devshell via
shell (base inheritance + htop appended + init auto-sourced + project bound
writable + overlay env); bare --base convenience; and the three error paths
(missing closure, no project/--base, base cycle).