A stale ~/.claude file mount no longer stops the devcontainer from nesting workspaces - #335
Conversation
Every file the claude-code feature bind-mounts one at a time is replaced by rename on the host - Claude rewrites .claude.json on nearly every session - and the rename leaves the container's mount pinned to a deleted inode. The container reads it fine; a Docker daemon inside the container refuses it as a bind source, so 'dl <repo>' from within the devlaunch devcontainer failed to create any workspace that mounts the same files (runc: 'no such file or directory'). init-host.sh is the one host-side step before every create, including the nested one, so it now detaches a mount whose root the kernel marks deleted and leaves an ordinary file with the same bytes and mode for the nested bind to pin. Live mounts are left connected, a real host has no such mounts and runs none of this, and a mount that cannot be detached costs the heal and not the launch. Closes #326
Reviewer's GuideEnsures nested devcontainers can bind-mount Claude config files even when the outer container’s mounts point at deleted inodes by adding a pre-create heal step in init-host.sh, documenting the failure mode, and adding unit tests that exercise real deleted-inode mounts in a private mount namespace. Flow diagram for stale Claude mount detection and recoveryflowchart TD
A[init-host.sh runs before container create] --> B[Inspect configured file mount in /proc/self/mountinfo]
B --> C{Root marked //deleted?}
C -- No --> D[Leave live mount untouched]
C -- Yes --> E[Save mounted bytes and mode]
E --> F{Detach succeeds?}
F -- Yes --> G[Restore ordinary file at mount path]
F -- No --> H[Warn on stderr and continue]
D --> I[Nested container creation]
G --> I
H --> I
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…aled The consumer bind-mounts the ssh agent socket as a file, the host's agent restart replaces its inode, and the stale mount fails a nested docker create identically to the config files - but the copy-the-bytes heal can never serve it: a deleted socket is a dead endpoint, and merely reading a stale FIFO/socket from initializeCommand can block the launch forever. Non-regular files are now detached without the copy, and agent.sock joins the heal list. Staged with a FIFO in the test because it is the member of the class that hangs a wrong implementation into the timeout.
Once the stale mount is detached its bytes exist only in the heal's temporary file, and the cp that puts them back can fail - the path underneath can be read-only where the mount was not. That failure was swallowed and the temporary deleted: the developer's readable state a second earlier, gone with no witness. The heal now keeps the copy and names it on stderr, still exiting zero so the launch it runs inside is never the casualty.
A macOS host runs devpod and this hook too; /proc/self/mountinfo is Linux, and each heal probe printed awk's can't-open error to the create log. Guarded on readability instead. Not covered by a test: the absent /proc cannot be staged honestly from inside a Linux CI runner, and a shimmed awk would test the shim.
blooop
left a comment
There was a problem hiding this comment.
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)
Verdict for #335 on its own: your fix survives and still meets #326, with its scope reduced. #362 made every claude-code mount source a directory, so the four ~/.claude paths in init-host.sh:123-124 can now only be mounts inside pre-#362 containers; the live file mounts the heal still earns its keep on are .devcontainer/devcontainer.json:126-127 (agent.sock, known_hosts). TROUBLESHOOTING.md:205-229, which this PR added, still documents the failure correctly.
Two things specific to #335, both non-blocking:
-
#362 halved this PR's test coverage as a side effect of editing a README.
test/unit/test_init_host_heals_stale_mounts.py:208derives its parametrization from the current README's mount headings, but the heal exists for the mounts legacy containers still hold.--collect-onlyonmaingives[.claude/.claude.json] [.claude/.credentials.json] [.ssh/known_hosts]; at6f96370^1it gave those plus[.claude/CLAUDE.md] [.claude/settings.json].init-host.sh:123-125still heals all six. The two paths that lost coverage are two of the four~/.claudefiles #326 names. Both review axes reached this independently. Fix: pin the legacy list literally in the test module, commented as legacy. -
Three of the heal's exit paths are silent.
init-host.sh:96,:97,:100-103— ifstat,mktemp, or the read-back through the mount fails, the functionreturn 0s with no output and the stale mount survives, while:114and:118both warn. Against #326's "the failure is understood and documented … with a clear refusal", route those three through the sameecho "init-host.sh: …" >&2.
One caveat worth your attention: all six of this PR's tests skip wherever no private mount namespace is available (unprivileged userns denied and no passwordless sudo). Worth confirming that guard does not also fire on CI runners — if it does, this fix has never been executed by a test.
Closes #326
What failed
pixi run dl <repo>/pixi run aid <repo>from inside the devlaunch devcontainer failed to create the nested workspace whenever the target's devcontainer bind-mounts one of the~/.claudefiles individually — devlaunch itself included:Why
Every file the claude-code feature mounts one at a time is a file its owner replaces by rename — Claude rewrites
.claude.jsonon nearly every host session, a token refresh rewrites.credentials.json, ssh rewritesknown_hosts. The rename swaps the inode, and the container's bind mount stays pinned to the old, now-deleted one. The container itself keeps reading it, which is why nothing notices — but a Docker daemon inside that container refuses a deleted-inode mount as a bind source, so the nested create dies minutes after the host has run Claude. Repos that mount~/.claudeas one directory (python_template) nest fine; the granular file mounts — which are there deliberately, for read-only protection — are what trip it.Reproduced on a scratch workspace: outer
mountinfoshowed/home/ags/.claude/.claude.json//deleted, and a minimaldocker run --mount type=bind,src=<that file>failed identically.The fix
init-host.shis the one host-side step before every container create — including the nested one, where "host" is the outer container. It now detaches any of its file mounts whose root the kernel marks deleted and leaves an ordinary file with the same bytes and mode, so the nested bind has a real inode to pin. Guard rails:sudothe detach needs when the caller is not root, which is only reachable inside a container where this repo's images give the user passwordless root.sh -einside a real container and stays green).Tests
test/unit/test_init_host_heals_stale_mounts.pystages a real deleted-inode mount in a private mount namespace (unshare, unprivileged or viasudo -n; skips where neither exists) and holds the hook to healing each of the five file mounts — the list derived from the feature README the way the config-protection tests derive theirs — plus the live-mount hands-off guard. Red before the fix (5 failed / 1 passed), green after.pixi run test(242 passed),pixi run pytest -m e2e test/e2e/test_claude_config_protection.py(passed),cargo test --workspace(exit 0)..claude.jsonmount had gone stale (verified//deletedin mountinfo) nested-launched this branch to a running workspace — where main fails with the mount error — and the outer container's stale mount count dropped to zero.Note for review
This touches
.devcontainer/, so the prebuild tag moves;devcontainer-prebuild.yml's path filter covers it and republishes on merge to main. Until then that commit builds locally, by design.🤖 Generated with Claude Code
Summary by Sourcery
Heal deleted-inode configuration mounts before container creation so nested workspaces can bind Claude and SSH files reliably.
Bug Fixes:
Enhancements:
Documentation:
Tests: