Make the image's Claude defaults a config layer - #42
Open
CrypticSwarm wants to merge 4 commits into
Open
Conversation
The merger is a plain recursive deep-merge over JSON objects; the only OpenCode-specific thing in it is an opt-in flag that nothing but the generated tong MCP fragment passes. Claude's settings.json is layered the same way and is about to use the same code, so the opencode name would mislead every reader after that. Module, CLI entry, test module, and the entrypoint invocation rename together; no behavior changes.
The merger has only ever folded one layer into a destination that is also an input. That works while the destination is scratch, but not for a file that outlives the run which wrote it: keys accumulate there, and a layer that stops setting one can never take it back. build_file derives the destination from an ordered list of layer files and never reads it, so the result is a function of the layers alone. It truncates the destination in place rather than renaming a temporary over it, because the caller mounts that path -- and serialises the whole text first, so the window where the path holds partial JSON is one write. A layer that ships no file contributes nothing quietly -- most layers ship none. One that ships a file which is unreadable, unparseable, or not a JSON object is reported on stderr and dropped, so a single bad layer costs its own keys rather than the whole build. A layer path is never an option, so an argument that looks like one is rejected instead of being read as a layer that happens not to exist.
Config layers stacked user -> org -> repo, which put the checkout on top. That is the specificity ordering the asset pipelines use, and it is the wrong one here: settings.json and opencode.json carry permissions, hooks, and env, and a checkout is whatever repo you happened to clone. Stack them repo -> user -> org instead. The checkout ships toolchain defaults and a person can now override them from their own config; the org layer, which is installed deliberately, keeps the last word. The generated tong MCP fragment still merges after all three. Live change for OpenCode: opencode/opencode.json used to outrank ~/.config/opencode/opencode.json and no longer does. Note that merging replaces lists rather than concatenating them, so a user config that sets `instructions` at all now replaces the checkout's list. The order lives in a shell function no test can call outside a container, so it is read back off the entrypoint source instead.
The status line default was written into ~/.claude/settings.json after the
layers had merged, with setdefault semantics standing in for precedence.
That worked, but it wrote to a file it did not own: under
CLAUDE_HOME_DIR=$HOME the destination is the user's real config directory,
and the seeder had no same-inode guard.
The defaults are now an ordinary layer -- the lowest one -- shipped in the
Claude image because they name a path only the image has. settings.json is
built from image defaults, repo, user, org on every run instead of merged
into whatever was there, so a key no layer sets is gone rather than
inherited from the run that set it. It is also excluded from the tar
overlay now, which until now wrote the top layer's copy over the merged
result and lost every lower layer's keys.
The result lands in a per-container host file mounted over the path. The
persistent Claude home is one directory shared by every container, so
rebuilding into it would reach a session already running under a different
org's layer -- and org layers are where permissions, hooks, and env live.
The mount destination follows SWARMFORGE_CONFIG_DEST rather than naming
the path a second time, so the two cannot drift apart and leave the
container reading a file nothing wrote.
That host file outlives its container and nothing reaps it, so the recipe
writes an empty object over it before every run and the entrypoint does
the same if the build fails. Either way a session starts on this run's
layers or on nothing -- never on the last run's policy. It is seeded with
`{}` rather than touched empty because the paths that skip the rebuild
should leave Claude with no settings, not with a file it cannot parse.
The mount is read-write, so editing settings inside a session still works;
the edit is simply not an input to the next rebuild.
Under CLAUDE_HOME_DIR=$HOME this stops being a degraded mode: the real
~/.claude/settings.json is read as the user layer through the read-only
layer mount, and the merged output lands on the masked file.
This closes the leak for settings.json alone. Every other file an org or
repo layer ships still lands in the shared home and accumulates there.
With the seeder gone, no file outside bin/ loads python from a path, so
the standing exemption goes with it.
CrypticSwarm
force-pushed
the
claude-settings-config-layer
branch
from
August 7, 2026 00:19
200fa93 to
1bf8d15
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the Claude settings seeder with an ordinary lowest-precedence config layer, and stops the merged result from living in the shared persistent home.
What was wrong
anvil/seed_claude_settings.pywrote the image'sstatusLinedefault into~/.claude/settings.jsonafter the config layers had merged, usingsetdefaultsemantics as a stand-in for precedence. Three problems came out of that:CLAUDE_HOME_DIR=$HOMEthe destination is the user's real~/.claude.merge_config_layerhas a same-inode guard for exactly this case; the seeder had none.settings.jsonwas not in the overlay's exclude list, so the highest layer that shipped one was written over the destination wholesale, taking every lower layer's keys with it — and the seeder then patchedstatusLineback into the result.CLAUDE_HOME_DIRis one directory shared by every container, so an org layer'spermissions,hooks, andenvcarried forward into later runs that no longer mounted that layer, and a container started under a second org rewrote the file while the first was still reading it.What changed
The image defaults are now a layer.
anvil/claude-settings.jsonships in the Claude image and sits below everything else. It stays in the image rather than the checkout because it names/usr/local/bin/swarmforge-statusline, a path only the image has.settings.jsonis rebuilt from the layers on every run, never merged into what was already there, so a key no layer sets is gone rather than inherited. It is excluded from the tar overlay for the same reasonopencode.jsonis.Each container gets its own host file mounted read-write over the path — a file-level mask inside the
/home/opencodemount, the same trick the tmpfs masks already use forskills,commands, andagents. Read-write so/configstill works inside a session; the edit is simply not an input to the next rebuild. The mount destination followsSWARMFORGE_CONFIG_DESTrather than naming the path a second time, so the recipe and the entrypoint cannot drift apart and leave the container reading a file nothing wrote.That host file outlives its container and nothing reaps it, so the recipe writes
{}over it before every run and the entrypoint does the same if the build fails. Either way a session starts on this run's layers or on nothing — never on the last run's policy. It is seeded with{}rather than touched empty because the paths that skip the rebuild should leave Claude with no settings, not with a file it cannot parse.Under
CLAUDE_HOME_DIR=$HOMEthis stops being a degraded mode: the real~/.claude/settings.jsonis read as the user layer through the read-only layer mount, while the merged output lands on the masked file.Config layers now order by trust
Layers stacked
user -> org -> repo, which put the checkout on top. That is the specificity ordering the asset pipelines use, and it is the wrong one for config: these files carry permissions, hooks, and env, and a checkout is whatever repo you happened to clone. They now stackrepo -> user -> org. The generated tong MCP fragment still merges after all three.Behavior change for OpenCode:
opencode/opencode.jsonused to outrank~/.config/opencode/opencode.jsonand no longer does. Merging replaces lists rather than concatenating them, so a user config that setsinstructionsat all now replaces the checkout's list. This is its own commit so it can be reverted on its own.Scope
This closes the leak for
settings.jsononly. Every other file an org or repo layer ships — aCLAUDE.md, a hooks script, anything underplugins/,settings.local.json— still lands in the shared persistent home, accumulates there, and is visible to concurrent containers. Masking one known file is cheap; masking an open set means either resetting the Claude home (which would take credentials,projects/, and history with it) or keeping an inventory of what each layer wrote. Worth its own issue.Testing
Unit suite by discovery: 503 -> 527, and green at every commit on the branch.
tests/test_merge_json.pycovers the build: later layers win key by key, nested objects merge, the destination is not one of its own inputs, a missing layer contributes nothing quietly, and an unreadable / unparseable / non-object layer is reported on stderr and dropped without emptying the result or aborting.tests/test_image_layout.pyholds the entrypoint to the layer order in both directions (insidebuild_claude_settingsand at its call site), checks the tar exclusion, and runs the real--buildargv against a staged copy of the package with the checkout offsys.path.tests/test_run_agent_container.pydrivesmake run_claudefor real withdockerstubbed: the mount lands where the entrypoint writes, the file handed to docker is valid JSON, two projects do not share one, and a previous run's content does not reach the next container.tests/test_package_layering.py's path-loading exemption list is now empty — the seeder was the only entry.The container-side behavior was additionally exercised by staging the image layout and sourcing the entrypoint's real function definitions against it, covering each acceptance case: image status line with no layers, a user layer keeping its own keys and getting the status line,
org > user > repo > image, malformed layers skipped, a stale key from a prior run gone, and the build-failure path resetting to{}without aborting the run.make lintclean.sh -n anvil/entrypoint.shclean.Not verified here: a real
make build_claudeand a live container run — no docker was available in the environment this was developed in. Worth confirming the image comes up with the status line before merging.