From 03d7d848b381257e63151fc75552ba6b49e74131 Mon Sep 17 00:00:00 2001 From: dlovell Date: Tue, 4 Aug 2026 16:58:13 +0000 Subject: [PATCH 1/6] fix(devcontainer): repair bind mount points' daemon-created parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `~/.claude/projects` came up root:root 755 inside the container, so the container user could not create anything in it — including the per-project memory directory Claude expects at ~/.claude/projects//memory/. `named_volume_targets` filtered the merged compose config to `type == "volume"`, so the transcript bind at ~/.claude/projects/ never reached the chown driver. Its parent is precisely a directory the DAEMON creates as root to host that mount point — the case lib/volume-perms.sh already documents for volumes, identical for a bind; only the type filter excluded it. The compensating `chown vscode:vscode /home/vscode/.claude` in setup_claude() does not reach it either, and its "Top-level is enough" comment was simply wrong: the contents in question are not created by setup-claude, they are created by the daemon. Binds cannot just be handed to the existing function: it does a guarded `chown -R`, which for a bind walks onto the HOST side and rewrites ownership of real files — the opposite of the invariant the lib states. So the ancestor walk is split out of dev_chown_volume_targets into dev_chown_ancestors, and a dev_chown_mount_points dispatcher routes `volume` -> guarded recursion + ancestors, `bind` -> ancestors only, anything else -> nothing (the recursive branch is the destructive one, so an untaught mount type must not reach it). The ancestor walk additionally refuses to chown any path that is itself a bind mount point. That is not a hypothetical: docker-compose.yml mounts DEV_MAIN_TREE and DEV_MAIN_GIT at their HOST paths and /.git nests inside , so on a host whose checkout lives under the container home prefix (a host user named `vscode`; Codespaces) the walk up from the .git bind would have reached the worktree bind and rewritten ownership of the user's real repo directory. Bind targets are collected in a first pass so the protection cannot depend on the order compose emits mounts in. Loop variables move off the shared `_vp_` prefix: these are POSIX sh globals, so a caller and callee sharing a prefix would clobber the caller's iterator. Scope, stated rather than assumed away: this covers the ancestor walk only. The RECURSIVE branch is rooted at a volume and `chown -R` has no mount awareness, so it still descends through a bind nested under that volume — the claude-home / transcript-bind topology itself. That predates this change and is latent only incidentally (the images pre-create ~/.claude user-owned and setup_claude re-chowns the top every up, so the guard's precondition is not normally met). Filed as #115 with options, recorded as an accepted `unguarded:` invariant, and the suite pins the current unsafe behaviour rather than asserting the safe one — that assertion is #115's red-to-green target. Guards (ADR-0005), all mutation-tested with the mutations recorded in the test header: the bind/volume dispatch, the bind-mount-point protection including the nested-bind case, and the compose query itself — its python snippet is lifted out of dev/devcontainer and run against a synthetic config document, so narrowing the filter back to volumes fails hermetically instead of only in a container. tests/test-volume-chown-guard.sh: 21 -> 41 assertions. Note for existing containers: setup() is cold-start only, so a container already running keeps the root-owned directory until its next recreate. Closes #106 Refs #115 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH --- CLAUDE.md | 38 +++- dev/devcontainer | 36 ++-- lib/host-bridge.sh | 12 +- lib/volume-perms.sh | 200 +++++++++++++++++--- tests/test-volume-chown-guard.sh | 301 +++++++++++++++++++++++++++++-- 5 files changed, 534 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c1de22..58c7706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,13 +231,45 @@ taxonomy and reads as `test:`. prevents is bounded because the glob, not the workflow, enumerates suites). - Container-side root logic lives in `lib/*.sh` and is INJECTED per run via `dc exec ... sh -c "$script" ` — a runtime input: no - rebuild, no fingerprint entry unless it is also COPYed - (`tests/test-volume-chown-guard.sh` pins the volume-perms driver line). + rebuild, no fingerprint entry unless it is also COPYed (test: + `tests/test-volume-chown-guard.sh` pins the volume-perms driver line). - `setup()` runs on EVERY cold start (gated only on `is_running`), so its steps must be idempotent and cheap. The named-volume chown is guarded by one owner+group stat, sound because `chown -R` is post-order: an interrupted walk leaves the mount point root-owned and retries next start - (`tests/test-volume-chown-guard.sh`). + (test: `tests/test-volume-chown-guard.sh`). +- Mount-point ownership repair is dispatched on the compose mount TYPE: a + `volume` gets the guarded recursive chown plus its ancestors, a `bind` gets + ancestors ONLY, anything else gets nothing. The dispatcher never routes a + bind INTO the recursive branch, and the ANCESTOR WALK never chowns a bind + mount point nor anything under one — it can reach both, + which `docker-compose.yml` really does arrange (`DEV_MAIN_GIT` nests inside + `DEV_MAIN_TREE`, both bound at their host paths) — though only when the HOST + checkout sits under the container home prefix, which is not exotic: this + repo's own main checkout is `/home/vscode/repos/github/devcontainer`. + Everything at or below a bind mount point is the host side. Binds cannot + simply be excluded from the walk instead: the daemon creates a bind target's + missing parents as root exactly as it does a volume's, and that is what left + `~/.claude/projects` unwritable (#106) + (test: `tests/test-volume-chown-guard.sh`). +- Those two sentences are about the DISPATCHER and the ANCESTOR WALK. Neither + says a bind is never recursed into, because it is: `chown -R` rooted at a + volume has no mount awareness and descends through any bind nested under + that volume — the transcript bind inside `claude-home`, i.e. the #106 + topology itself (`unguarded`: predates the ancestor-walk work and is latent + only incidentally — both images pre-create `~/.claude` user-owned and + `setup_claude` re-chowns the top every up, so the guard's precondition (a + volume root not already user-owned) is not normally met. Closing it means + replacing `chown -R` with a pruned `find`, which also rewrites the + post-order rationale the cold-start guard rests on — filed as #115 with the + options. `-xdev` is the tempting variant and is worse: it stops at every + filesystem boundary, changing behaviour for reasons unrelated to this + invariant. The suite pins the current, unsafe behaviour rather than + asserting the safe one). +- The chown driver reads mount points from `dc config`, so the compose query in + `mount_point_targets` decides what the lib ever sees; the query itself needs + docker, but the python snippet inside it is lifted out and run against a + synthetic config document (test: `tests/test-volume-chown-guard.sh`). - Lock discipline: per-worktree lock on fd 9, repo-scoped build lock on fd 8; any helper backgrounded inside the locked region must be spawned with `9>&-` or it holds the worktree lock forever (`test: diff --git a/dev/devcontainer b/dev/devcontainer index 97bcfa3..15ff181 100755 --- a/dev/devcontainer +++ b/dev/devcontainer @@ -778,25 +778,35 @@ require_tty() { # this line would silently run without the host mounts. generate_host_mounts_override -# Named-volume mount targets from the merged compose config — these come -# up root-owned and need chowning before user-mode setup runs. Deriving +# Mount targets from the merged compose config, as `:` — these +# come up root-owned and need chowning before user-mode setup runs. Deriving # from compose means projects only declare volumes in compose.override.yml. -named_volume_targets() { +# +# Binds are emitted alongside volumes because only their ANCESTORS are repaired +# (lib/volume-perms.sh never DISPATCHES a bind into its recursive branch, and +# its ancestor walk never chowns a bind mount point or anything under one — but +# see #115 below for what that does not cover). A bind nested under a named +# volume — the +# transcript bind at ~/.claude/projects/, inside claude-home — makes the +# daemon create the intervening directories as root, and nothing else repairs +# them (#106). The recursion rooted at a volume is a separate matter: it still +# descends through a bind nested under that volume (#115). +mount_point_targets() { local cfg cfg="$(dc config --format=json)" || { - echo "error: 'docker compose config' failed — cannot derive named-volume mount targets" >&2 + echo "error: 'docker compose config' failed — cannot derive mount targets" >&2 return 1 } printf '%s' "$cfg" | python3 -c ' import json, sys cfg = json.load(sys.stdin) for v in cfg.get("services", {}).get("app", {}).get("volumes") or []: - if v.get("type") == "volume" and v.get("target"): - print(v["target"]) + if v.get("type") in ("volume", "bind") and v.get("target"): + print(v["type"] + ":" + v["target"]) ' } -# Chown the named-volume mount points to the container user. The logic lives in +# Chown the mount points to the container user. The logic lives in # lib/volume-perms.sh (unit-tested by tests/test-volume-chown-guard.sh) and is # injected into the container as the `sh -c` script with the mount points as # positional args — it runs in the container, but nothing COPYs it into the @@ -805,12 +815,12 @@ for v in cfg.get("services", {}).get("app", {}).get("volumes") or []: # Note this is called on EVERY cold start, not just the first (setup() is gated # only on `is_running`), which is why the recursive chown is guarded inside the # lib rather than run unconditionally. -chown_named_volume_targets() { +chown_mount_points() { local targets_out - # Command substitution, not a pipe: named_volume_targets fails loudly when + # Command substitution, not a pipe: mount_point_targets fails loudly when # `dc config` does, and `|| return 1` propagates that instead of letting the # downstream exit status mask it. - targets_out="$(named_volume_targets)" || return 1 + targets_out="$(mount_point_targets)" || return 1 local targets=() t while IFS= read -r t; do @@ -821,18 +831,18 @@ chown_named_volume_targets() { local script script="$(cat "$DEV_BASE_DIR/lib/volume-perms.sh")" || return 1 script="$script"' -dev_chown_volume_targets vscode vscode /home/vscode "$@"' +dev_chown_mount_points vscode vscode /home/vscode "$@"' # `sh -c