1 Devshells
crussell edited this page 2026-09-09 19:32:21 -04:00

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 own base chain (a named entrypoint is re-derivable by name inside the userns).
  • bubblebox shell (a project devshell) resolves the base via load_effective, then folds the bubblebox.toml overlay with stack_merge. The folded result has no on-disk name to re-resolve inside the userns, so shell persists it to a temp file and points compose-inner at it via the BUBBLEBOX_DEVSHELL_EP env var (see run.rs::load_entrypoint). The file lives in run_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-shell does two things only:
    1. . /tmp/.bb-devshell-init.sh (the project script, if present);
    2. exec "$ENTER_SHELL" -li (a login interactive shell → reads your host RC via writable_home).
  • ENTER_SHELL is set by run.rs to /usr/bin/<binary>; the helper defaults to /bin/bash.
  • The script is sourced under /bin/sh (the base image's sh — bash on Fedora). Keep devshell.sh POSIX, 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 — /tmp is always bound rw in every bwrap. /usr/bin and /bb are 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.toml upwards from cwd: resolve its base, 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):

  1. 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 like pull.
  2. A pin that can't be honored warns loudly and proceeds on the local copy if one exists (shells are interactive tools, not verifiers); --refresh ignores pins entirely and re-resolves + re-pins. --no-pull restores the v1 error-on-missing behavior.
  3. bubblebox.lock (next to bubblebox.toml): [package] name = digest map, 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-root only while active); no new rooting machinery needed.
  • Reproducibility. A reusable base shell that's a profile.toml root is pinned in profile.lock like 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
transient pull-on-entry / per-project lockfiles landed (Phase 4.5) — see §7

12. Status

Phase 4 (v1) — implemented + verified.

  • config.rs: base/init fields; stack_merge + union_strings/union_binds; recursive load_effective with 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).