Skip to content

Mount the Claude config directory, so an account switch reaches the container - #362

Merged
blooop merged 6 commits into
mainfrom
fix/claude-mount-inode-pinning
Aug 22, 2026
Merged

Mount the Claude config directory, so an account switch reaches the container#362
blooop merged 6 commits into
mainfrom
fix/claude-mount-inode-pinning

Conversation

@blooop

@blooop blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner

The bug

Changing your Claude account on the host stopped reaching running devlaunch
containers. Measured on the reporter's machine, three running containers held
three different .credentials.json files and none of them was the host's:

inode md5
host 12333597 217cb6d6…
container created 16:07 12333812 bd0627d8…
container created 10:55 12334335 94394dc5…

Why

A bind mount of a file is attached to the dentry. When the host replaces it
by rename — which is what Claude does on every token refresh and on an account
switch — the mount is left pointing at an inode with no name. The container
reads it happily and forever, so nothing reports it as broken; the workspace
just goes on authenticating as the account you left.

Four of the feature's nine mounts were files, two of them the credentials and
account state. Note that the wayfinder devcontainer, which mounts ~/.claude as
a directory, was never affected — this was specific to the granular layout
introduced in 19d966e for #108.

The fix

Mount ~/.claude as the directory, read-write. A directory mount resolves names
per access, so it follows the rename.

The read-only mounts over CLAUDE.md and settings.json go with it, because
they were not read-only
. The same rename drops a nested file mount from the
namespace and the path falls through to the parent. Measured on Docker, both
ways round, because the direction of the failure follows the parent and neither
direction is safe:

arrangement after the host replaces the file
rw parent, ro file mounts file is writable — protection silently gone from the first host edit
ro parent, rw file mounts file is read-only — token refresh fails
either parent, ro directory mounts unchanged: still live, still read-only

So the read-only list is now exactly the five instruction directories, and every
source in the manifest is a directory. The test asserts that rather than the
paths, because the tempting change — protecting the two files by naming them
again — passes review, appears in docker inspect, and stops being true on the
next edit.

The cost, stated plainly

CLAUDE.md and settings.json are writable from the container, and
settings.json can name hook commands inline, so this is a real hole. It is the
same hole the previous layout had after one edit, minus the claim that it was
closed. #108's protection is kept for the five directories, where it is
enforceable.

Verification

  • test_every_mount_source_is_a_directory — the new invariant, red against the
    old manifest (8 of 9 unit tests fail there), green here.
  • test_the_container_follows_the_host_replacing_a_file_by_rename — new e2e
    test, driven through real devpod. On the old layout it fails with
    assert 'after' in '{"account": "before"}'; on this one it passes.
  • Full suite: 244 passed, and both e2e claude tests pass against real containers.

🤖 Generated with Claude Code

Summary by Sourcery

Ensure running Claude containers follow host account and configuration changes by switching to a live directory mount and retaining enforceable protection for instruction directories.

Bug Fixes:

  • Make host Claude account and credential changes propagate to running containers by mounting the configuration directory instead of individual files.
  • Remove unreliable read-only protection from CLAUDE.md and settings.json while preserving read-only mounts for the five instruction directories.

Enhancements:

  • Require all Claude configuration mount sources to be directories and keep writable state files accessible through the live parent mount.
  • Update host initialization and migration handling for the directory-based mount layout, including retaining stale-mount healing for older containers.

Documentation:

  • Update Claude feature documentation and troubleshooting guidance to describe live directory mounting, revised permissions, prerequisites, and migration behavior.

Tests:

  • Add unit and end-to-end coverage verifying directory-only mounts, mount permissions, host file replacement behavior, and continued protection of instruction directories.

…ontainer

A bind mount of a file is attached to the dentry. When the host replaces that
file by rename -- which is what Claude does on every token refresh and on an
account switch -- the mount is left pointing at an inode with no name, and the
container reads it happily and forever.

Four of the feature's nine mounts were files, two of them the credentials and
account state. So a workspace created before an account switch went on
authenticating as the account the developer had left, and each froze at a
different moment: three running containers held three different credentials
files and none held the host's.

Mount ~/.claude as the directory instead. A directory mount resolves names per
access, so it follows the rename.

The read-only mounts over CLAUDE.md and settings.json go with it, because they
were not read-only. The same rename drops a nested file mount from the namespace
and the path falls through to the parent, so -- measured on Docker, both ways
round, since the direction follows the parent and neither is safe -- a
read-write parent leaves the file writable and a read-only parent breaks the
token refresh. Directory mounts survive intact, so the read-only list is now
exactly the five instruction directories and every source is a directory. The
test asserts that rather than the paths: naming the two files again passes
review, appears in docker inspect, and stops being true on the next edit.

The cost is real and now stated where it is met: CLAUDE.md and settings.json are
writable from the container, and settings.json can name hook commands inline.
That is the same hole the previous layout had after one edit, minus the claim
that it was closed.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR changes the claude-code devcontainer feature to mount the host ~/.claude directory read-write and only mount its instruction subdirectories read-only, fixes stale credential/account state caused by file bind mounts across renames, simplifies the init-host pre-create hook to only create directories, and updates documentation and tests (unit + e2e) to assert the new "directory-only" mount invariant and behavior.

Sequence diagram for live Claude account switching

sequenceDiagram
    participant Host as Host Claude config
    participant Container as Running devlaunch container
    participant Claude as Claude CLI
    Host->>Host: Replace .credentials.json by rename
    Claude->>Container: Read .credentials.json
    Container->>Host: Resolve path through ~/.claude directory mount
    Host-->>Container: Return current credentials
    Container-->>Claude: Authenticate with current account
Loading

Flow diagram for simplified Claude host initialization

flowchart TD
    Start([Initialize host config]) --> Create[mkdir -p Claude config directories]
    Create --> Mount[Mount ~/.claude as rw directory]
    Mount --> Overlay[Mount five instruction directories as ro]
    Overlay --> Ready([Container starts])
Loading

File-Level Changes

Change Details Files
Switch claude configuration mounting from per-file binds to a single read-write ~/.claude directory bind with read-only instruction subdirectory overlays, eliminating stale file mounts across renames.
  • Update feature README to describe directory bind of ~/.claude and read-only mounts for agents/, commands/, hooks/, skills/, wf-skills/ instead of granular file mounts.
  • Clarify security model: only instruction directories are protected read-only; CLAUDE.md and settings.json remain writable but live, reached through the directory bind.
  • Change TROUBLESHOOTING docs to show expected mount output for a directory bind plus read-only subdirectories, and to explain that .credentials.json and .claude.json no longer have individual mounts.
.devcontainer/claude-code/README.md
.devcontainer/claude-code/TROUBLESHOOTING.md
.devcontainer/claude-code/devcontainer-feature.json
Adjust the init-host pre-create hook to align with directory-only mounts, creating only required instruction directories and retaining stale-mount healing for legacy containers without seeding placeholder files.
  • Rewrite init-host.sh comments to explain why only directories are mounted, the rename behavior of file mounts, and legacy stale-mount healing semantics.
  • Stop creating CLAUDE.md, settings.json, .credentials.json, and .claude.json in the hook; rely on Claude to create them on first use since they are no longer individual bind sources.
  • Ensure the hook only mkdirs ~/.claude and its instruction subdirectories and keeps healing deleted-inode mounts for older layouts.
/.devcontainer/claude-code/init-host.sh
Add and update tests to enforce the new directory-only mount invariant, verify that the config directory is mounted read-write with nested mounts read-only, and add an e2e test that exercises host-side file replacement by rename and container behavior.
  • Extend unit tests to assert that every mount source under ~/.claude is a directory, that ~/.claude itself is mounted read-write, and that all nested mounts are read-only overlays; verify init-host creates all mount sources and no unmounted files.
  • Introduce host_config fixture and helpers to resolve mount sources against a scratch HOME, using the pre-create hook to build the host-side layout under test.
  • Modify e2e helper in_container to accept a workspace ID and add a new e2e test that writes a state file on the host, replaces it via rename, and asserts the container sees the updated content while protected directories remain read-only.
  • Update CHANGELOG with detailed description of the bug (file bind mounts pinned to stale inodes), the directory-mount fix, removal of unreliable read-only file mounts over CLAUDE.md/settings.json, and simplification of the pre-create hook.
test/unit/test_claude_code_feature_mounts.py
test/e2e/test_claude_config_protection.py
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

blooop added 5 commits August 22, 2026 18:55
Restoring the bind-type check dropped when this file was rewritten. Without it a
volume at ~/.claude/agents passes: the source still names a directory that
exists, the read-only flags still match the README, and the container gets an
empty configuration with no connection to the host.
resolve() matched the configuration directory without requiring a path
separator, so any sibling whose name merely extends .claude sliced to a
zero-length relative path and resolved back to the configuration directory. The
directory check, and the hook-creates-every-source check that exists to stop
'bind mount source path does not exist' at create time, then both ran against
the wrong path and passed.
devpod writes .devpod-internal into the folder it mounts, owned by the
container's root, and the fixture that removes it named one workspace id. The
new test creates a second, so its copy survived the container and then failed
pytest's tear-down of its own temporary directory, once per run and for every
later run too. Cleaning every workspace the tracker recorded covers both, and
any third.

The state file the rename is performed on also comes from a set, so which of the
two it picked moved with the hash seed; all of them, sorted, is both
deterministic and a wider test.
Both claims were falsified by the mount change and left behind. 'None of them is
optional' listed CLAUDE.md, settings.json, .credentials.json and .claude.json,
none of which has to exist any more for the create to succeed -- and the list
omitted skills/ and wf-skills/, which do. 'You cannot modify Claude settings from
within the container' is now the opposite of true for settings.json, which is the
one sentence a reader would rely on before trusting the container with a
repository.
CHANGELOG only: main released 0.7.1 while this branch held an entry under
[Unreleased]. Both kept, the entry above the new release section.
@blooop
blooop merged commit 6f96370 into main Aug 22, 2026
14 checks passed
@blooop
blooop deleted the fix/claude-mount-inode-pinning branch August 22, 2026 18:08

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Retrospective review. #335, #362 and #374 were reviewed together as one surface — container identity and the ~/.claude mount — judged against current main (a5f7ed8). The consolidated report is on #374: #374 (review)

Both blocking findings in that report are #362's, and both are on the same thing: the layout change is right and well argued, the disclosure did not follow it everywhere.

  1. BLOCKING — .devcontainer/claude-code/README.md:562-581 still advertises the protection this PR removed. ### What's Protected (Read-Only Mounts) lists CLAUDE.md ("Prevents prompt injection attacks") and settings.json ("Prevents config tampering"), and ### Security Mitigations says "Writable files are limited to authentication/state only" and "All configuration and code execution files remain read-only". Your own :98-101 says the opposite, and your PR body says it plainly ("this is a real hole"). Reproduced against the exact manifest on main, from a non-privileged container: read the host's OAuth token, read every host project's transcripts under projects/, write a SessionStart hook into the host's settings.json (arbitrary command execution on the host), and clobber the host's .credentials.json. ### Known Risks at :578-581 names only the two old risks. CHANGELOG.md:397-441 has the same gap — read forward, the standing statement is still the #108 entry at :1521-1523 saying transcripts and projects/ are container-local and die with it.

  2. BLOCKING — README.md:113 and :380 state the five-directory guarantee with no qualification. Both are new lines in this PR. mount -o remount,rw defeats a read-only bind in any container with CAP_SYS_ADMIN, which is every container this repo builds — .devcontainer/devcontainer.json:76 notes the DinD feature "brings "privileged": true". Measured: unprivileged, the write is refused and mount says permission denied; privileged, remount,rw succeeds and the host's ~/.claude/skills/SKILL.md is rewritten from inside the container. The weakness predates you (#108); the unqualified claim does not.

Non-blocking, also yours: install.sh:172-224 was never updated (stale banner, three-of-five directories, still seeds {} credentials — harmless only because it runs at build time under the mount); TROUBLESHOOTING.md:166-185 Issue 5 answers a symptom that can no longer occur and contradicts itself nine lines later; README.md:551-555 and :424-432 are two drifted copies of the host-prerequisite list that omit skills/+wf-skills/, so the documented recovery still fails the create; README.md:63 still says transcripts are not shared; and init-host.sh:143 creates the OAuth-token directory with the ambient umask (measured 775) two lines above mkdir -m 700 -p "$HOME/.ssh".

And one that cost #335: test/unit/test_init_host_heals_stale_mounts.py:208 derives the heal's parametrization from this README, so removing CLAUDE.md/settings.json from the read-only bullets dropped them from a test whose subject is what legacy containers still mount. Coverage went from 5 paths to 3 while init-host.sh:123-125 still heals 6.

Credit where it is due: the tests you added are strong. Four mutations of devcontainer-feature.json are all caught, including the exact tempting change your PR body predicts — re-adding a readonly file mount over settings.json fails 4 tests.

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