Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .devcontainer/claude-code/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ chmod 600 ~/.claude/.claude.json

These files contain sensitive data and should only be readable by you.

### Issue 7: Nested `docker run` Fails to Bind a `~/.claude` File

**Symptoms:**
- Creating a container from *inside* this container (docker-in-docker, `dl <repo>`) fails with:
`error mounting "/home/vscode/.claude/.claude.json" ... no such file or directory`
- The file it names exists and reads fine in this container

**Why?**
The host replaces these files by rename (Claude rewrites `.claude.json` on
nearly every session), which swaps the inode. This container's mount stays
pinned to the old, now-deleted inode — readable in here, but a Docker daemon
refuses a deleted-inode mount as a bind source.

**Solution:**
`init-host.sh` heals this before every container create: a mount whose inode
the kernel marks deleted is detached and replaced with an ordinary file
holding the same bytes and mode. If the error still appears, the repo being
launched carries an older `init-host.sh` without the heal.

## Debugging Commands

### Check Authentication Status
Expand Down
80 changes: 80 additions & 0 deletions .devcontainer/claude-code/init-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,86 @@
# rather than beside the mounts it serves; the consuming devcontainer wires this
# script up as its initializeCommand.
#
# Before anything is created: every file mounted 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 when a key rotates. A rename swaps the inode and a bind mount
# pins the old one, so run from inside a container created before the rename,
# these paths are mounts of **deleted** inodes. The container itself reads them
# fine, which is why nothing notices -- until it creates a container of its
# own: a Docker daemon refuses a deleted-inode mount as a bind source (runc:
# `no such file or directory`), which is `dl <repo>` in here failing for every
# repo that mounts these same files (devlaunch#326). This hook is the one
# thing that runs host-side before every create, so it is where the stale
# mount is caught: detach it and leave an ordinary file holding the same bytes
# and mode, so the nested bind has a real inode to pin. Only a mount whose
# root the kernel marks deleted is touched -- a live mount is the container's
# working connection to the host file, and detaching one would sever every
# container this repo builds from its own configuration. On a real host these
# paths are ordinary files, no mountinfo entry matches, and none of this runs
# -- including the `sudo` that detaching needs when the caller is not root,
# which is why it can be asked for here at all: it is only reached inside a
# container, where this repo's images give the user passwordless root. A stale
# mount that still cannot be detached costs the heal and not the launch: the
# path stays readable, only a *nested* create was ever going to trip on it,
# and aborting `devpod up` here would turn that maybe into a certainty.
as_root() {
if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo -n "$@"; fi
}

heal_stale_file_mount() {
mounted="$1"
# mountinfo is Linux; a macOS host runs devpod too, and five awk
# complaints per create is this heal charging a platform it cannot
# even be needed on.
[ -r /proc/self/mountinfo ] || return 0
case "$(awk -v p="$mounted" '$5 == p { r = $4 } END { print r }' /proc/self/mountinfo)" in
*"//deleted") ;;
*) return 0 ;;
esac
# The agent socket goes stale the same way and blocks a nested create the
# same way, but has no bytes to carry over -- a deleted socket inode is a
# dead endpoint however it is mounted -- so its whole heal is the detach.
# The test matters beyond that economy: *reading* a stale FIFO blocks
# forever, and this runs inside initializeCommand, where hanging is worse
# than any failure being healed.
if [ ! -f "$mounted" ]; then
as_root umount "$mounted" 2>/dev/null || as_root umount -l "$mounted" 2>/dev/null || :
return 0
fi
mode=$(stat -c '%a' "$mounted" 2>/dev/null) || return 0
saved=$(mktemp) || return 0
# Read through the mount before detaching it: the deleted inode's bytes
# are unreachable afterwards, and they are the developer's live state.
if ! cat "$mounted" > "$saved" 2>/dev/null; then
rm -f "$saved"
return 0
fi
if as_root umount "$mounted" 2>/dev/null || as_root umount -l "$mounted" 2>/dev/null; then
# Written into the file the mount was covering rather than renamed
# over it -- a rename is what made the inode stale in the first place,
# and the point here is a plain file at a stable inode.
if as_root cp "$saved" "$mounted" 2>/dev/null; then
as_root chown "$(id -u):$(id -g)" "$mounted" 2>/dev/null || :
as_root chmod "$mode" "$mounted" 2>/dev/null || :
else
# The mount is gone and the write-back failed, so the temporary
# copy is now the only copy: keep it and say where it is.
echo "init-host.sh: detached the stale mount at $mounted but could not write its content back; the bytes are kept at $saved" >&2
return 0
fi
else
echo "init-host.sh: $mounted is a mount of a deleted inode and could not be detached; containers created from here may fail to bind it" >&2
fi
rm -f "$saved"
}

for mounted_file in "$HOME/.claude/CLAUDE.md" "$HOME/.claude/settings.json" \
"$HOME/.claude/.credentials.json" "$HOME/.claude/.claude.json" \
"$HOME/.ssh/known_hosts" "$HOME/.ssh/agent.sock"; do
heal_stale_file_mount "$mounted_file"
done
#
# Every line is guarded on absence, and that is load-bearing rather than tidy.
# Run from *inside* a container this repo built -- which is the point of giving
# it a Docker daemon -- these paths are the read-only mounts, and a write to one
Expand Down
Loading