Skip to content

A stale ~/.claude file mount no longer stops the devcontainer from nesting workspaces - #335

Merged
blooop merged 6 commits into
mainfrom
wayfinder/devlaunch-326
Aug 22, 2026
Merged

A stale ~/.claude file mount no longer stops the devcontainer from nesting workspaces#335
blooop merged 6 commits into
mainfrom
wayfinder/devlaunch-326

Conversation

@blooop

@blooop blooop commented Aug 22, 2026

Copy link
Copy Markdown
Owner

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 ~/.claude files individually — devlaunch itself included:

docker: Error response from daemon: ... error mounting "/home/vscode/.claude/.claude.json"
to rootfs ...: no such file or directory

Why

Every file the claude-code feature mounts one at a time is a file its owner replaces by rename — Claude rewrites .claude.json on nearly every host session, a token refresh rewrites .credentials.json, ssh rewrites known_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 ~/.claude as 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 mountinfo showed /home/ags/.claude/.claude.json//deleted, and a minimal docker run --mount type=bind,src=<that file> failed identically.

The fix

init-host.sh is 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:

  • Only a mount rooted on a deleted inode is touched. A live mount is the container's working connection to the host file; a test holds the hook to leaving those connected.
  • On a real host these paths are plain files, nothing matches, and none of this runs — including the sudo the 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.
  • A stale mount that cannot be detached costs the heal, not the launch: a warning on stderr, exit status untouched (the e2e config-protection test runs the hook under sh -e inside a real container and stays green).

Tests

  • test/unit/test_init_host_heals_stale_mounts.py stages a real deleted-inode mount in a private mount namespace (unshare, unprivileged or via sudo -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.
  • Full gates: pixi run test (242 passed), pixi run pytest -m e2e test/e2e/test_claude_config_protection.py (passed), cargo test --workspace (exit 0).
  • Acceptance, in the environment the ticket names: a fresh devlaunch devcontainer whose .claude.json mount had gone stale (verified //deleted in 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:

  • Prevent nested workspace creation from failing when Claude or SSH configuration file mounts become stale after their host files are replaced.
  • Handle stale SSH agent socket mounts without blocking or aborting container creation.

Enhancements:

  • Preserve the content and permissions of stale file mounts while leaving live mounts untouched and allowing launches to continue when healing cannot complete.

Documentation:

  • Document the cause and resolution of nested Docker bind-mount failures involving stale ~/.claude files.

Tests:

  • Add mount-namespace tests covering stale Claude and SSH file mounts, content and mode preservation, write-back failures, agent sockets, and live-mount protection.

blooop added 2 commits August 22, 2026 10:41
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

@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

Ensures 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 recovery

flowchart 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
Loading

File-Level Changes

Change Details Files
Heal stale per-file mounts of Claude/SSH config before every container create so nested docker runs see real inodes instead of deleted ones.
  • Introduce as_root helper to run privileged operations via sudo when not root.
  • Add heal_stale_file_mount function that detects mounts whose root is marked "//deleted" in /proc/self/mountinfo, snapshots their contents and mode, unmounts them, and replaces them with a regular file containing the same bytes and permissions.
  • Invoke healing for each individually-mounted config file under $HOME/.claude plus $HOME/.ssh/known_hosts before the rest of init-host.sh executes, logging a warning but not failing if a stale mount cannot be detached.
.devcontainer/claude-code/init-host.sh
Document the nested docker bind-mount failure mode and its fix for users of the claude-code devcontainer.
  • Add a new troubleshooting section describing the symptom where nested docker run fails to bind ~/.claude files despite them being readable in the container.
  • Explain inode replacement-by-rename as the root cause and note that updated init-host.sh heals stale mounts before container creation.
  • Advise that continued failures likely mean the launched repo carries an older init-host.sh without the heal.
.devcontainer/claude-code/TROUBLESHOOTING.md
Add unit tests that exercise the stale-mount healing logic against real kernel mounts in an isolated namespace.
  • Create helper shell scripts that, inside a private mount namespace, set up both deleted-inode and live bind mounts, run init-host.sh as HOME-scoped hook, and record mountinfo after execution.
  • Probe for how to obtain a private mount namespace (unprivileged unshare vs sudo -n unshare) and skip tests entirely if neither is available.
  • Derive the list of mounted config files from the feature README (via documented_paths) and parameterize a test that asserts stale mounts are detached and rewritten with preserved bytes and mode for each file plus ~/.ssh/known_hosts.
  • Add a guard test ensuring that live mounts remain attached and still surface their original source content through the mount after the hook runs.
test/unit/test_init_host_heals_stale_mounts.py

Assessment against linked issues

Issue Objective Addressed Explanation
#326 Allow pixi run aid <repo> or pixi run dl <repo> from inside the devlaunch devcontainer to create nested workspaces without Docker bind-mount errors caused by stale individually mounted ~/.claude files.
#326 Provide a clear explanation and documented handling for the nested-workspace failure scenario.

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 4 commits August 22, 2026 11:10
…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
blooop merged commit 2f18d53 into main Aug 22, 2026
14 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-326 branch August 22, 2026 10:16

@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)

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:

  1. #362 halved this PR's test coverage as a side effect of editing a README. test/unit/test_init_host_heals_stale_mounts.py:208 derives its parametrization from the current README's mount headings, but the heal exists for the mounts legacy containers still hold. --collect-only on main gives [.claude/.claude.json] [.claude/.credentials.json] [.ssh/known_hosts]; at 6f96370^1 it gave those plus [.claude/CLAUDE.md] [.claude/settings.json]. init-host.sh:123-125 still heals all six. The two paths that lost coverage are two of the four ~/.claude files #326 names. Both review axes reached this independently. Fix: pin the legacy list literally in the test module, commented as legacy.

  2. Three of the heal's exit paths are silent. init-host.sh:96, :97, :100-103 — if stat, mktemp, or the read-back through the mount fails, the function return 0s with no output and the stale mount survives, while :114 and :118 both warn. Against #326's "the failure is understood and documented … with a clear refusal", route those three through the same echo "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.

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.

Run devlaunch from inside the devlaunch devcontainer

1 participant