Skip to content

Make the image's Claude defaults a config layer - #42

Open
CrypticSwarm wants to merge 4 commits into
masterfrom
claude-settings-config-layer
Open

Make the image's Claude defaults a config layer#42
CrypticSwarm wants to merge 4 commits into
masterfrom
claude-settings-config-layer

Conversation

@CrypticSwarm

Copy link
Copy Markdown
Owner

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.py wrote the image's statusLine default into ~/.claude/settings.json after the config layers had merged, using setdefault semantics as a stand-in for precedence. Three problems came out of that:

  • It wrote to a file it did not own. Under CLAUDE_HOME_DIR=$HOME the destination is the user's real ~/.claude. merge_config_layer has a same-inode guard for exactly this case; the seeder had none.
  • The tar overlay clobbered the merge. settings.json was 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 patched statusLine back into the result.
  • The destination was an input to itself. CLAUDE_HOME_DIR is one directory shared by every container, so an org layer's permissions, hooks, and env carried 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.json ships 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.json is 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 reason opencode.json is.

Each container gets its own host file mounted read-write over the path — a file-level mask inside the /home/opencode mount, the same trick the tmpfs masks already use for skills, commands, and agents. Read-write so /config still works inside a session; the edit is simply not an input to the next rebuild. The mount destination follows SWARMFORGE_CONFIG_DEST rather 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=$HOME this stops being a degraded mode: the real ~/.claude/settings.json is 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 stack repo -> user -> org. The generated tong MCP fragment still merges after all three.

Behavior change for OpenCode: opencode/opencode.json used to outrank ~/.config/opencode/opencode.json and no longer does. Merging replaces lists rather than concatenating them, so a user config that sets instructions at 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.json only. Every other file an org or repo layer ships — a CLAUDE.md, a hooks script, anything under plugins/, 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.py covers 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.py holds the entrypoint to the layer order in both directions (inside build_claude_settings and at its call site), checks the tar exclusion, and runs the real --build argv against a staged copy of the package with the checkout off sys.path.
  • tests/test_run_agent_container.py drives make run_claude for real with docker stubbed: 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 lint clean. sh -n anvil/entrypoint.sh clean.

Not verified here: a real make build_claude and 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.

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
CrypticSwarm force-pushed the claude-settings-config-layer branch from 200fa93 to 1bf8d15 Compare August 7, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant