Structural review of the whole tree at 97b387d, looking for accidental coupling rather than
bugs. Findings are ordered by leverage, not by size. Every one has a concrete alternative; the
prioritized checklist is at the bottom.
The first finding is the only one where the code makes a promise it does not keep.
1. One mount plan, four representations — and the macOS backend drops three of them
Four things in main() all mean "bind a host path into the sandbox at some mode", and all four
have a different shape:
| Field |
Shape |
Mode |
Missing path |
homeBindMounts (src/bubblebox.js:560) |
{src, dst} |
always rw |
created on the host |
parentMount (:604) |
bare string |
always ro |
n/a |
profileMounts (:621) |
{path, mode} |
either |
skipped (--*-try) |
extraMounts (:628) |
{path, mode} |
either |
hard exit |
Unenforced invariants holding them together:
- every backend must consume all four fields
- emission order matters (
:293-300, later bind wins), so the four must be rendered in sequence
homeBindMounts.src === .dst always — :562-563 computes them identically, so the two-field
shape is misleading
The consumption site is where this shows. SeatbeltSandbox.wrap destructures exactly three keys
(:314):
const { repoRoot, profileMounts, extraMounts } = this.config;
It never reads sandboxHome, homeBindMounts, parentMount, or any of the four allow* flags.
Reading the resulting policy, on darwin:
$HOME is not isolated. The dynamic policy emits a blanket (allow file-read*) (:346) and
nothing redirects $HOME to sandboxHome — sandboxHome is created in main() and then unused.
The isolated-$HOME claim in README.md:16 and the HOME_LEAKED assertion in
tests/mounts.test.js:154 are Linux-only facts.
~/.claude is not writable. Only PROJECT_DIR, TMPDIR and rw mounts get file-write*, so
homeBindings has no effect at all.
parentMounts is inert. The ancestry is readable regardless, so "none" and "tree" describe
the same policy.
- The four
allow* flags are accepted and ignored. The sockets they gate on Linux appear to be
reachable anyway under the blanket (allow file-read*) plus (allow network-outbound) (:353).
Nothing catches it: nix/tests.nix:14 gates the whole suite on isLinux. A backend could omit three
of four mount sources precisely because there is no single field it would have been forced to handle.
Fix. Collapse to one ordered list, built once:
// resolvePlan() returns, among other things:
mounts: [
{ path: sandboxHome, at: home, mode: "rw", required: true },
...homeBindings.map(p => ({ path: p, at: p, mode: "rw", required: true })),
...(parent ? [{ path: parent, at: parent, mode: "ro", required: true }] : []),
{ path: repoRoot, at: repoRoot, mode: "rw", required: true },
...profileMounts.map(m => ({ ...m, at: m.path, required: false })),
...extraMounts.map(m => ({ ...m, at: m.path, required: true })),
]
Each backend then renders one list. Seatbelt's inability to express at !== path, or a read
denial, becomes an explicit and testable capability gap — a throw or a one-time warning — instead
of three omitted destructuring keys. Either way README.md needs to stop claiming darwin gets the
same isolation.
2. Four allow* booleans encoding one state machine, spread over nine sites
allowSshAgent, allowGpgAgent, allowXdgRuntime, allowDbus are peers in the type but not in
the domain. :259-291 reads:
if (allowXdgRuntime) { /* bind the whole runtime dir */ }
else { /* ssh, gpg, dbus individually */ }
allowXdgRuntime subsumes the other three, so {allowXdgRuntime: true, allowSshAgent: true} is a
representable state in which the second setting silently does nothing. Four independent booleans,
five reachable states.
Each flag also has to be threaded through nine places. Not an estimate — 56a9313, which added the
single boolean --allow-dbus, touched exactly nine hunks of src/bubblebox.js: CONFIG_DEFAULTS
(:120), the wrap destructure (:187), the wrap body (:280), mergeOptions (:393),
cliOverrides init (:408), the parseArgs case (:429), the help options list (:496), the help
config example (:519), and the Sandbox.create argument (:646) — plus the README.
Fix. One enum plus a table:
const HOLES = {
ssh: { flag: "--allow-ssh-agent", key: "allowSshAgent", help: "…", bind: (xdg) => … },
gpg: { flag: "--allow-gpg-agent", key: "allowGpgAgent", help: "…", bind: (xdg) => … },
dbus: { flag: "--allow-dbus", key: "allowDbus", help: "…", bind: (xdg) => … },
};
// plus a single `runtimeAccess: "selective" | "all"` replacing allowXdgRuntime as a peer boolean
CONFIG_DEFAULTS, the parseArgs cases, the help text and the bwrap args all derive from HOLES.
Adding a hole becomes one table entry, and the whole-runtime-dir case stops pretending to be a
sibling of the three narrow ones.
3. main() braids plan resolution with host mutation, so the dry-run seam leaks
:527-674 does eight things: arg parsing, repo-root discovery, session id, sandboxHome mkdir,
three signal handlers, mutation of the real $HOME (:568-571, mkdirSync/writeFileSync),
profile resolution with exit, parentMounts computation, tilde expansion, mount validation with
exit, backend construction, script assembly, the dry-run branch, and spawn.
The seam sits downstream of the effects. By the time BUBBLEBOX_DRYRUN is checked at :666, the run
has already created sandboxHome on disk and possibly created ~/.claude on the host.
tests/mounts.test.js:33 sets a throwaway HOME specifically to contain that — the test suite works
around the braid rather than the code separating it.
Fix. Four stages instead of a knot:
const plan = resolvePlan(argv, env); // pure: no fs writes, no process.exit; returns errors
if (dryRun) return print(plan); // seam moves above every effect
materialize(plan); // mkdtemp, create homeBindings, register cleanup
run(render(plan));
resolvePlan then unit-tests without bwrap, without a scratch $HOME, and without a Nix-built
testbox — which also unblocks finding 6.
4. mergeOptions is a hand-unrolled spread that silently eats typos
:379-400 is 22 lines computing {...fileConfig, ...definedKeys(cliOverrides)}. Two consequences of
unrolling it:
- Unknown keys in the user's
config.json are dropped without a word. loadUserConfig (:137)
preserves them via spread, then mergeOptions projects exactly six. allowSSHAgent — wrong case —
is a silent no-op.
profile and extraMounts come from the CLI only (:397-398), so neither can be set in the config
file. That asymmetry has no stated reason and is visible only by reading the unrolled body.
Meanwhile the Nix side validates rigorously through evalModules (nix/bubblebox.nix:138, whose
header comment advertises exactly that). Two config surfaces for one box, two validation regimes,
opposite strictness.
Fix. Derive the merge from one schema — the CONFIG_DEFAULTS keys, or the HOLES table from
finding 2 — and reject unknown keys the way the Nix module system would. The help text's config
example (:514-520) becomes JSON.stringify(CONFIG_DEFAULTS, null, 2) instead of a fourth
hand-maintained copy of the same key list.
5. The Sandbox hierarchy has no shared code
:152-179 contributes a constructor that stores config, a wrap() that throws, a three-line
spawn(), and a static factory switching on process.platform. The two subclasses share zero
code — no common helper, no template-method step. Both wrap() bodies are pure
config -> {cmd, args, env}, and both return the identical env: process.env (:304, :371), so
that third field is noise.
This is also why finding 1 went unnoticed: a base class with no shared code gave the appearance of
a contract that nothing actually enforced.
Fix.
const BACKENDS = { linux: bwrapInvocation, darwin: seatbeltInvocation };
const backend = BACKENDS[process.platform] ?? die(`Unsupported platform: ${process.platform}`);
spawn(...backend(plan), { stdio: "inherit", cwd: plan.projectDir });
The dry-run branch becomes print(plan) — and per finding 1 the plan is what the tests want to
assert on, not a rendered argv.
6. The mount-test fixture is split across a .nix file and a .js file
nix/tests.nix:24-43 bakes seven profiles (default, ro, rw, tilde, parent, tree, e2e)
whose names and paths mean nothing except to tests/mounts.test.js, and scratch()
(tests/mounts.test.js:28-32) must create exactly d/default, d/roA, d/rwA, ~/tthome. String
literals matched by hand across two languages; adding a case means editing both files and rebuilding
to change a fixture path. BUBBLEBOX_TESTBOX (nix/tests.nix:52 → test :18) is the hole punched to
rejoin the halves at runtime.
Fix. The launcher already reads its config from a path in BUBBLEBOX_CONFIG (:37), so the test
can write its own config JSON per case and invoke node src/bubblebox.js directly. nix/tests.nix
shrinks to "run node --test with nodejs and bubblewrap on PATH", and the profile matrix becomes data
in the file that asserts on it.
7. homeBindings is a second mount language, with the missing half guessed
nix/bubblebox.nix:25-44 establishes the pattern for "a path with a mode": coercedTo str into a
submodule, so both "~/.config/gh" and {path; mode;} are accepted. homeBindings (:83) is
listOf str, and the JS then infers file-vs-directory from the filename (:565-572):
if (path.extname(rel)) { /* create empty file */ } else { /* mkdir */ }
Same kind of declaration, second representation, missing information recovered by heuristic. It
happens to hold for .claude / .claude.json / .local/share/opencode; a binding like
~/.config/foo.d would be created as an empty file.
Fix. Give homeBindings the same coercedTo treatment mountType already models, carrying
kind = "dir" | "file" with "dir" as the default, and delete the heuristic. Feeds straight into
finding 1's unified list.
8. A dead env field, and tool meaning two things one hop apart
Nix toolEnv becomes JSON env (nix/bubblebox.nix:190) becomes BOX.env (:55) — which is never
read. The env is actually applied by the second makeWrapper (nix/bubblebox.nix:232-240), so one
concern is expressed in two places and one copy is vestigial.
The same boundary renames inconsistently: Nix tool is a package, JSON tool is a binary name
(nix/bubblebox.nix:189 assigns toolBinary to it). One word, two meanings.
Fix. Drop env from the JSON and from loadBoxConfig; rename the JSON key to toolBinary to
match its source.
9. nix/packages.nix mixes the mechanism with three of the instances
The file declares the boxes option (:6-11), populates three specific boxes (:31-65), and maps
boxes onto packages/apps (:28, :67-68). Meanwhile nix/agents/pi.nix and
nix/agents/hermes.nix contribute to boxes from their own files.
So "where does a new box go?" has two answers, and the rule lives in prose — README.md:162 tells
contributors to edit packages.nix. In a tree that otherwise follows one-file-one-aspect, that is a
convention maintained by documentation.
Fix. Keep only the option and the packages/apps mapping in one file; move claudebox, codexbox
and opencodebox to nix/agents/*.nix, each matching hermes.nix's shape. Adding a CLI becomes
"add a file", uniformly, and the README section collapses to a pointer at nix/agents/hermes.nix as
the template.
10. shellQuote plus bash -c for what cwd already does
The concern is "run this argv with cwd = projectDir". The implementation serializes argv into a shell
string (:656-662) and hands it to bash -c, so both backends terminate in "bash", "-c", script
(:302, :369) and wrap()'s parameter is an opaque string the dry-run seam cannot inspect
structurally.
Fix. spawn(cmd, args, { cwd: projectDir }) gives the child its directory directly; bwrap and
sandbox-exec both preserve cwd. shellQuote (:112-114), script, the extra bash process and
the quoting surface all delete.
11. Two config loaders, three failure policies
loadBoxConfig (:36-59): missing env var gives a clean exit(2); malformed JSON gives an
uncaught throw and a stack trace; a missing required field gives throw new Error and a stack
trace. Inconsistent with itself.
loadUserConfig (:133-146): ENOENT gives silent defaults; anything else, malformed JSON
included, warns and continues.
A typo in the baked config prints a Node stack trace; a typo in the user's config prints a friendly
warning.
Fix. One readJsonConfig(path, { required, defaults }) with one error path, and a die(msg)
helper instead of bare throws. Related: parseArgs calls loadUserConfig() at :403, so argument
parsing cannot run without touching the filesystem — which is why there is no unit test for it. Pass
fileConfig in as a parameter.
12. parentMounts computed imperatively, then corrected
:604-617 opens let parentMount = null, branches three ways, walks up in a while, then applies a
corrective if that nulls out results the branches should not have produced. Correctness depends on
that trailing if running: the tree branch at ~/foo genuinely does produce realHome and relies
on being retracted afterwards.
Fix.
const PARENT_MOUNT = {
none: () => null,
parent: (root) => path.dirname(root),
tree: (root, home) => topChildOf(root.startsWith(home + "/") ? home : "/", root),
};
const NEVER_MOUNT = (p, home, root) => p === home || p === "/" || p === root;
One lookup, one predicate, no reassignment.
13. randomHex and sessionId
:78-85 is eight lines of Math.random() used only to build sandboxHome (:536) before
mkdirSync (:555). fs.mkdtempSync(path.join(tmp, BOX.name + "-")) does both atomically and
collision-free; randomHex and the sessionId concept both disappear.
14. claudebox-latest reimplements the name option
nix/devshell.nix:15-23 builds claudebox-dev via .override, then adds a second derivation
(writeShellScriptBin) whose only job is to exec it under a different name. mkBubblebox already
takes name, and toolBinary is set explicitly in nix/packages.nix:34 so it survives an override:
config.packages.claudebox.override {
name = "claudebox-latest";
profiles.gh.mounts = [ "~/.config/gh" "~/.ssh" ];
}
Two derivations and a shell hop become one .override.
Separately: which of a developer's dotfiles get mounted (~/.config/gh, ~/.ssh) is a per-person
choice currently living in the shared devshell.
15. Minor
nix/tests.nix:2 and :12 state the same fact ("Linux-only: the tests drive bwrap directly") ten
lines apart. Keep one.
realpath (:87-89) is a pure alias for fs.realpathSync, and the guard-then-realpath pairs at
:625 and :634 stat twice. try { return fs.realpathSync(p) } catch { return null } states the
one concern once.
16. Optional: -- strictness
parseArgs errors on any unrecognized token (:467-470), so the box's flags and the tool's flags
share one argv namespace disambiguated solely by a sentinel. claudebox --continue fails, and
README.md:151-158 documents needing -- -- under nix run. The passthrough sentinel is defensible,
but the strictness is a choice: git's convention — the first unrecognized token ends the box's own
options — would accept claudebox --continue while keeping -- for genuinely ambiguous cases.
Already tracked
nix/agents/pi.nix puts an upstream buildNpmPackage (pinned npmDepsHash, a sed rewriting
tsgo -p, a dist-save dance around npmInstallHook) in the same file as the box declaration — two
unrelated axes of change, and the package list tui ai agent coding-agent mom web-ui pods written out
three times at :45, :55 and :61. hermes.nix is 19 lines of pure declaration by comparison.
The fix/pibox-npm-lock branch already reduces pi.nix to that same shape via llm-agents.nix, so
this needs no separate entry:
Checklist
Roughly in priority order. 1–3 are where the structure pays.
Structural review of the whole tree at
97b387d, looking for accidental coupling rather thanbugs. Findings are ordered by leverage, not by size. Every one has a concrete alternative; the
prioritized checklist is at the bottom.
The first finding is the only one where the code makes a promise it does not keep.
1. One mount plan, four representations — and the macOS backend drops three of them
Four things in
main()all mean "bind a host path into the sandbox at some mode", and all fourhave a different shape:
homeBindMounts(src/bubblebox.js:560){src, dst}parentMount(:604)profileMounts(:621){path, mode}--*-try)extraMounts(:628){path, mode}Unenforced invariants holding them together:
:293-300, later bind wins), so the four must be rendered in sequencehomeBindMounts.src === .dstalways —:562-563computes them identically, so the two-fieldshape is misleading
The consumption site is where this shows.
SeatbeltSandbox.wrapdestructures exactly three keys(
:314):It never reads
sandboxHome,homeBindMounts,parentMount, or any of the fourallow*flags.Reading the resulting policy, on darwin:
$HOMEis not isolated. The dynamic policy emits a blanket(allow file-read*)(:346) andnothing redirects
$HOMEtosandboxHome—sandboxHomeis created inmain()and then unused.The isolated-
$HOMEclaim inREADME.md:16and theHOME_LEAKEDassertion intests/mounts.test.js:154are Linux-only facts.~/.claudeis not writable. OnlyPROJECT_DIR,TMPDIRand rw mounts getfile-write*, sohomeBindingshas no effect at all.parentMountsis inert. The ancestry is readable regardless, so"none"and"tree"describethe same policy.
allow*flags are accepted and ignored. The sockets they gate on Linux appear to bereachable anyway under the blanket
(allow file-read*)plus(allow network-outbound)(:353).Nothing catches it:
nix/tests.nix:14gates the whole suite onisLinux. A backend could omit threeof four mount sources precisely because there is no single field it would have been forced to handle.
Fix. Collapse to one ordered list, built once:
Each backend then renders one list. Seatbelt's inability to express
at !== path, or a readdenial, becomes an explicit and testable capability gap — a throw or a one-time warning — instead
of three omitted destructuring keys. Either way
README.mdneeds to stop claiming darwin gets thesame isolation.
2. Four
allow*booleans encoding one state machine, spread over nine sitesallowSshAgent,allowGpgAgent,allowXdgRuntime,allowDbusare peers in the type but not inthe domain.
:259-291reads:allowXdgRuntimesubsumes the other three, so{allowXdgRuntime: true, allowSshAgent: true}is arepresentable state in which the second setting silently does nothing. Four independent booleans,
five reachable states.
Each flag also has to be threaded through nine places. Not an estimate —
56a9313, which added thesingle boolean
--allow-dbus, touched exactly nine hunks ofsrc/bubblebox.js:CONFIG_DEFAULTS(
:120), thewrapdestructure (:187), thewrapbody (:280),mergeOptions(:393),cliOverridesinit (:408), theparseArgscase (:429), the help options list (:496), the helpconfig example (
:519), and theSandbox.createargument (:646) — plus the README.Fix. One enum plus a table:
CONFIG_DEFAULTS, theparseArgscases, the help text and the bwrap args all derive fromHOLES.Adding a hole becomes one table entry, and the whole-runtime-dir case stops pretending to be a
sibling of the three narrow ones.
3.
main()braids plan resolution with host mutation, so the dry-run seam leaks:527-674does eight things: arg parsing, repo-root discovery, session id,sandboxHomemkdir,three signal handlers, mutation of the real
$HOME(:568-571,mkdirSync/writeFileSync),profile resolution with
exit,parentMountscomputation, tilde expansion, mount validation withexit, backend construction, script assembly, the dry-run branch, and spawn.The seam sits downstream of the effects. By the time
BUBBLEBOX_DRYRUNis checked at:666, the runhas already created
sandboxHomeon disk and possibly created~/.claudeon the host.tests/mounts.test.js:33sets a throwawayHOMEspecifically to contain that — the test suite worksaround the braid rather than the code separating it.
Fix. Four stages instead of a knot:
resolvePlanthen unit-tests without bwrap, without a scratch$HOME, and without a Nix-builttestbox — which also unblocks finding 6.
4.
mergeOptionsis a hand-unrolled spread that silently eats typos:379-400is 22 lines computing{...fileConfig, ...definedKeys(cliOverrides)}. Two consequences ofunrolling it:
config.jsonare dropped without a word.loadUserConfig(:137)preserves them via spread, then
mergeOptionsprojects exactly six.allowSSHAgent— wrong case —is a silent no-op.
profileandextraMountscome from the CLI only (:397-398), so neither can be set in the configfile. That asymmetry has no stated reason and is visible only by reading the unrolled body.
Meanwhile the Nix side validates rigorously through
evalModules(nix/bubblebox.nix:138, whoseheader comment advertises exactly that). Two config surfaces for one box, two validation regimes,
opposite strictness.
Fix. Derive the merge from one schema — the
CONFIG_DEFAULTSkeys, or theHOLEStable fromfinding 2 — and reject unknown keys the way the Nix module system would. The help text's config
example (
:514-520) becomesJSON.stringify(CONFIG_DEFAULTS, null, 2)instead of a fourthhand-maintained copy of the same key list.
5. The
Sandboxhierarchy has no shared code:152-179contributes a constructor that storesconfig, awrap()that throws, a three-linespawn(), and a static factory switching onprocess.platform. The two subclasses share zerocode — no common helper, no template-method step. Both
wrap()bodies are pureconfig -> {cmd, args, env}, and both return the identicalenv: process.env(:304,:371), sothat third field is noise.
This is also why finding 1 went unnoticed: a base class with no shared code gave the appearance of
a contract that nothing actually enforced.
Fix.
The dry-run branch becomes
print(plan)— and per finding 1 the plan is what the tests want toassert on, not a rendered argv.
6. The mount-test fixture is split across a
.nixfile and a.jsfilenix/tests.nix:24-43bakes seven profiles (default,ro,rw,tilde,parent,tree,e2e)whose names and paths mean nothing except to
tests/mounts.test.js, andscratch()(
tests/mounts.test.js:28-32) must create exactlyd/default,d/roA,d/rwA,~/tthome. Stringliterals matched by hand across two languages; adding a case means editing both files and rebuilding
to change a fixture path.
BUBBLEBOX_TESTBOX(nix/tests.nix:52→ test:18) is the hole punched torejoin the halves at runtime.
Fix. The launcher already reads its config from a path in
BUBBLEBOX_CONFIG(:37), so the testcan write its own config JSON per case and invoke
node src/bubblebox.jsdirectly.nix/tests.nixshrinks to "run
node --testwith nodejs and bubblewrap on PATH", and the profile matrix becomes datain the file that asserts on it.
7.
homeBindingsis a second mount language, with the missing half guessednix/bubblebox.nix:25-44establishes the pattern for "a path with a mode":coercedTo strinto asubmodule, so both
"~/.config/gh"and{path; mode;}are accepted.homeBindings(:83) islistOf str, and the JS then infers file-vs-directory from the filename (:565-572):Same kind of declaration, second representation, missing information recovered by heuristic. It
happens to hold for
.claude/.claude.json/.local/share/opencode; a binding like~/.config/foo.dwould be created as an empty file.Fix. Give
homeBindingsthe samecoercedTotreatmentmountTypealready models, carryingkind = "dir" | "file"with"dir"as the default, and delete the heuristic. Feeds straight intofinding 1's unified list.
8. A dead
envfield, andtoolmeaning two things one hop apartNix
toolEnvbecomes JSONenv(nix/bubblebox.nix:190) becomesBOX.env(:55) — which is neverread. The env is actually applied by the second
makeWrapper(nix/bubblebox.nix:232-240), so oneconcern is expressed in two places and one copy is vestigial.
The same boundary renames inconsistently: Nix
toolis a package, JSONtoolis a binary name(
nix/bubblebox.nix:189assignstoolBinaryto it). One word, two meanings.Fix. Drop
envfrom the JSON and fromloadBoxConfig; rename the JSON key totoolBinarytomatch its source.
9.
nix/packages.nixmixes the mechanism with three of the instancesThe file declares the
boxesoption (:6-11), populates three specific boxes (:31-65), and mapsboxesontopackages/apps(:28,:67-68). Meanwhilenix/agents/pi.nixandnix/agents/hermes.nixcontribute toboxesfrom their own files.So "where does a new box go?" has two answers, and the rule lives in prose —
README.md:162tellscontributors to edit
packages.nix. In a tree that otherwise follows one-file-one-aspect, that is aconvention maintained by documentation.
Fix. Keep only the option and the
packages/appsmapping in one file; move claudebox, codexboxand opencodebox to
nix/agents/*.nix, each matchinghermes.nix's shape. Adding a CLI becomes"add a file", uniformly, and the README section collapses to a pointer at
nix/agents/hermes.nixasthe template.
10.
shellQuoteplusbash -cfor whatcwdalready doesThe concern is "run this argv with cwd = projectDir". The implementation serializes argv into a shell
string (
:656-662) and hands it tobash -c, so both backends terminate in"bash", "-c", script(
:302,:369) andwrap()'s parameter is an opaque string the dry-run seam cannot inspectstructurally.
Fix.
spawn(cmd, args, { cwd: projectDir })gives the child its directory directly; bwrap andsandbox-execboth preserve cwd.shellQuote(:112-114),script, the extrabashprocess andthe quoting surface all delete.
11. Two config loaders, three failure policies
loadBoxConfig(:36-59): missing env var gives a cleanexit(2); malformed JSON gives anuncaught throw and a stack trace; a missing required field gives
throw new Errorand a stacktrace. Inconsistent with itself.
loadUserConfig(:133-146): ENOENT gives silent defaults; anything else, malformed JSONincluded, warns and continues.
A typo in the baked config prints a Node stack trace; a typo in the user's config prints a friendly
warning.
Fix. One
readJsonConfig(path, { required, defaults })with one error path, and adie(msg)helper instead of bare throws. Related:
parseArgscallsloadUserConfig()at:403, so argumentparsing cannot run without touching the filesystem — which is why there is no unit test for it. Pass
fileConfigin as a parameter.12.
parentMountscomputed imperatively, then corrected:604-617openslet parentMount = null, branches three ways, walks up in awhile, then applies acorrective
ifthat nulls out results the branches should not have produced. Correctness depends onthat trailing
ifrunning: thetreebranch at~/foogenuinely does producerealHomeand relieson being retracted afterwards.
Fix.
One lookup, one predicate, no reassignment.
13.
randomHexandsessionId:78-85is eight lines ofMath.random()used only to buildsandboxHome(:536) beforemkdirSync(:555).fs.mkdtempSync(path.join(tmp, BOX.name + "-"))does both atomically andcollision-free;
randomHexand thesessionIdconcept both disappear.14.
claudebox-latestreimplements thenameoptionnix/devshell.nix:15-23buildsclaudebox-devvia.override, then adds a second derivation(
writeShellScriptBin) whose only job is toexecit under a different name.mkBubbleboxalreadytakes
name, andtoolBinaryis set explicitly innix/packages.nix:34so it survives an override:Two derivations and a shell hop become one
.override.Separately: which of a developer's dotfiles get mounted (
~/.config/gh,~/.ssh) is a per-personchoice currently living in the shared devshell.
15. Minor
nix/tests.nix:2and:12state the same fact ("Linux-only: the tests drive bwrap directly") tenlines apart. Keep one.
realpath(:87-89) is a pure alias forfs.realpathSync, and the guard-then-realpath pairs at:625and:634stat twice.try { return fs.realpathSync(p) } catch { return null }states theone concern once.
16. Optional:
--strictnessparseArgserrors on any unrecognized token (:467-470), so the box's flags and the tool's flagsshare one argv namespace disambiguated solely by a sentinel.
claudebox --continuefails, andREADME.md:151-158documents needing-- --undernix run. The passthrough sentinel is defensible,but the strictness is a choice: git's convention — the first unrecognized token ends the box's own
options — would accept
claudebox --continuewhile keeping--for genuinely ambiguous cases.Already tracked
nix/agents/pi.nixputs an upstreambuildNpmPackage(pinnednpmDepsHash, asedrewritingtsgo -p, adist-savedance aroundnpmInstallHook) in the same file as the box declaration — twounrelated axes of change, and the package list
tui ai agent coding-agent mom web-ui podswritten outthree times at
:45,:55and:61.hermes.nixis 19 lines of pure declaration by comparison.The
fix/pibox-npm-lockbranch already reducespi.nixto that same shape viallm-agents.nix, sothis needs no separate entry:
Checklist
Roughly in priority order. 1–3 are where the structure pays.
loudly on what it cannot express. Correct the README's darwin claims either way.
allow*booleans with one enum plus aHOLEStable driving defaults,parsing, help and binds.
main()intoresolvePlan/materialize/render/run; move the dry-run seamabove all effects.
mergeOptionsfrom one schema; reject unknown config keys; generate the helpexample from
CONFIG_DEFAULTS.Sandboxhierarchy to two functions plus aBACKENDSlookup; drop thealways-identical
envfrom the return.BUBBLEBOX_CONFIG; reducenix/tests.nixto a runner.homeBindingsacoercedTotype carryingkind; delete the extension heuristic.envfield; rename the JSONtoolkey totoolBinary.boxesmechanism from the box instances; one file per box undernix/agents/.cwdtospawnand argv directly; deleteshellQuoteand thebash -chop.readJsonConfigwith one error path; passfileConfigintoparseArgs.parentMountsbranches with a lookup table and aNEVER_MOUNTpredicate.fs.mkdtempSync; deleterandomHexandsessionId.claudebox-latestshim with.override { name = …; }; move the personaldotfile mounts out of the shared devshell.
nix/tests.nixcomment; collapse therealpathguard pairs.