Entrypoints: layering and merge
Override layering & deep-merge
effective = deep_merge(remote-default ⊕ user-override), else synthesize
{package = <name>}. The default layer comes from a registered source's
<pkg>/entrypoint.toml OR a downloaded remote-defaults/<pkg>.toml; the
override layer is the only file users edit (config_dir/entrypoints/<pkg>.toml).
Schema
# Required. The OCI package whose rootfs to mount (resolved as
# descriptors/<package>.cfs). Was `primary`; the alias still parses.
package = "age"
# Optional. Which /usr/bin/<binary> to exec inside the sandbox. Defaults to
# <package> when unset. Set when one package ships multiple binaries that each
# need their own wrapper — e.g. `age-keygen` sets package = "age" +
# binary = "age-keygen" so bubblebox execs /usr/bin/age-keygen from the age
# image (no duplicate build).
binary = "age-keygen"
# Devshell inheritance (Phase 4; see Devshells). Both optional.
base = "rust-dev" # parent entrypoint to extend — resolved recursively and
# folded with stack_merge (APPEND semantics), distinct from
# the default⊕override deep_merge below (which replaces).
init = "devshell.sh" # script auto-sourced on entering a shell entrypoint, via
# the fixed bubblebox-enter-shell helper (constrained hook).
compose = ["nvim", "rg", "fd"] # optional; defaults to [package]
[sandbox] # all optional
writable_home = true
[sandbox.env]
KEY = "val"
A root (profile.packages entry) names an entrypoint; the entrypoint's package
field names the underlying image. The two are usually identical. When they
differ, bubblebox build <root> skips cleanly (no Containerfile to build), and
bubblebox apply resolves the package's digest instead of the root's.
Merge rules
There are two merge axes. deep_merge folds the default⊕override layers of
one entrypoint (replaces lists). stack_merge folds a child onto its base
parent (appends lists) — the devshell stacking axis. See Devshells.
deep_merge (default ⊕ override):
| field | merge rule |
|---|---|
package, sandbox.writable_* |
scalar — override always wins |
binary, base, init |
Option — override wins if set, else inherits base |
sandbox.env |
map — union, override wins on key conflict |
compose, sandbox.bind/bind_try/dirs |
list — override replaces if non-empty |
stack_merge (resolved base parent ⊕ child) — used by bubblebox shell and
any entrypoint with a base:
| field | merge rule |
|---|---|
package, binary, sandbox.writable_* |
scalar — child wins, else inherits |
base |
cleared (the chain is resolved by load_effective) |
init |
child wins if set, else inherits |
compose |
union, deduped, child's package forced first (topmost overlay) |
bind/bind_try |
append, keyed on dst (child replaces a same-dst base bind) |
dirs |
append, deduped |
sandbox.env |
union, child wins on key conflict |
bubblebox config cat <pkg> prints the effective merged entrypoint; config edit <pkg> seeds an override from the default and opens $EDITOR.
A bind may set create = true — its (expanded) src is mkdir -p'd before
bwrap, so a required state-dir bind ($BUBBLEBOX_DATA_DIR/<pkg> → /persist)
materializes itself instead of failing. (There is no hook/pre_run field
anymore — the earlier shell escape hatch was removed; auto-create binds and
vendored/host-bound resources cover every prior use case declaratively. The
one narrow exception is the devshell init — see Devshells §6.)
Composed entrypoints — sandbox spec
When an entrypoint composes multiple packages (compose = ["nvim", "rg", "fd"]), only the primary package's [sandbox] spec is honored at run
time; deps contribute files to the overlay rootfs but their own sandbox
binds/env are NOT merged in. (The rootfs merge is main-wins via overlay
lowerdir order; the sandbox spec is taken from the entrypoint alone.)
Sandbox environment
The sandbox process inherits the INVOKING HOST's environment (bwrap passes it through), with two modifications from the entrypoint:
[sandbox.env]KEY = "value" entries become--setenv KEY value; values expand$VARs from the host environment at run time ($BUBBLEBOX_DATA_DIR,$XDG_RUNTIME_DIR, ...). An UNSET variable expands to an empty string — which matters for binds, see below.PATHdefaults to a deterministic FHS list (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin) whenever the entrypoint doesn't set its own. Without this, the sandbox PATH was whatever the invoking host had — on NixOS that's/nix/store/...paths which don't exist inside an FHS rootfs, so env-shebang binaries (#!/usr/bin/env python3) resolved nothing (yt-dlp failed its first gate exactly this way). Set an explicit[sandbox.env] PATHonly to add non-default directories; otherwise the default is what you want. (Engine PR #2, 2026-09-24.)
Two adjacent bind facts (see also Gotchas #11/#12): a required
[[sandbox.bind]] source is mkdir -p'd host-side when create = true;
and every bind DESTINATION must exist where the sandbox can see it — in the
image (pre-create with RUN mkdir -p), host-side for host-overlaid dirs
(/run /var /etc /home /mnt /media /opt /srv), or under /tmp//dev//proc —
run validates this before bwrap and errors early with the offending bind.
A bind_try whose source expands to an empty string is skipped with a
warning rather than reaching bwrap.