2 Entrypoints
Glen edited this page 2026-09-24 17:52:20 -04:00

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.
  • PATH defaults 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] PATH only 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.