diff --git a/CHANGELOG.md b/CHANGELOG.md index 557b573..da7587a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Intermittent `env: 'claude': Permission denied` at container start + (#506): `usermod -u` chowns the home tree with the top-level dir last, + so a transient error while walking live host mounts inside home left + `/home/deva` at the build UID with mode 750 — untraversable for the + remapped user. The entrypoint now chowns `$DEVA_HOME` itself explicitly + (non-recursive) after the UID remap. Latent since 5807889; the 7511464 + whitelist fixed subdirs but never the home dir itself. + ## [0.18.0] - 2026-07-27 ### Added diff --git a/DEV-LOGS.md b/DEV-LOGS.md index e013e49..d90710c 100644 --- a/DEV-LOGS.md +++ b/DEV-LOGS.md @@ -13,6 +13,11 @@ - Minimal markdown markers, no unnecessary formatting, minimal emojis. - Reference issue numbers in the format `#` for easy linking. +# [2026-07-28] Dev Log: home dir chown race bricks containers #506 +- Why: intermittent `env: 'claude': Permission denied` on fresh containers. /home/deva stuck at build UID 1001 mode 750 (noble HOME_MODE) after remap to host UID — user can't traverse its own home. usermod's implicit home-tree chown walks live host mounts (~/.claude churning under concurrent sessions), aborts mid-walk with rc=12 AFTER updating passwd; shadow chowns the top dir last, so it never gets fixed. The 7511464 whitelist chowns subdirs, never $DEVA_HOME itself. Latent since 5807889 dropped the recursive home chown; only bites when the walk races live mounts, which is why sibling containers were fine. +- What: explicit non-recursive `chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"` in setup_nonroot_user, after the usermod block, using the adapted DEVA_UID so the usermod-failed-entirely variant stays consistent. Devlog with full forensics in docs/devlog/20260728-home-dir-chown-race.org. Verified by fault injection: stub usermod (passwd updated, chown skipped, exit 12) reproduces the brick unpatched, comes out clean patched. +- Result: half-succeeded UID remaps no longer brick containers. Bricked-container first aid: `docker exec chown : /home/deva`. Open: entrypoint has zero test coverage — two "Permission denied" regressions shipped from the same function; the usermod stub is a ready-made regression test. + # [2026-07-27] Dev Log: DEVA_AUTH_TAG for in-container account identity #496 - Why: two claude containers on `--auth-with a.credentials.json` / `b.credentials.json` are indistinguishable from inside — /status shows the shared `.claude.json`'s oauthAccount (whoever logged in last), and claude-code-statusline's "account-scoped" caches under the shared `~/.claude/statusline` actively bleed across accounts (B renders A's 5h/7d bars whenever A fetched last; profile.cache sticks to the first account for 24h). Credentials files carry no identity — tokens rotate, no email/uuid — so the runner is the only party that knows which account a container talks as. - What: hoist generate_auth_tag() out of the container-name rewrite branch (it already produced `auth-default | auth-file- | api-key- | env` for naming and the `deva.auth_tag` label) and export it as `DEVA_AUTH_TAG` on every run. No naming/label change. Verified all three modes via scratch-HOME `--dry-run`. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5f68460..b36e099 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -258,6 +258,13 @@ setup_nonroot_user() { DEVA_UID="$actual_uid" fi fi + # usermod's implicit home chown walks the whole tree (including live + # host mounts) and shadow chowns $DEVA_HOME itself LAST -- any + # transient error mid-walk (rc=12) leaves the home dir at the + # build-time UID with mode 750, so the remapped user cannot even + # traverse into its own home ("env: 'claude': Permission denied"). + # Chown the home dir itself explicitly, non-recursively. + chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME" 2>/dev/null || true # Fix container-managed directories (whitelist approach - safe for mounted volumes) # These directories are created at image build time and must be chowned to match host UID for dir in .npm-global .local .oh-my-zsh .skills .config .cache go; do diff --git a/docs/devlog/20260728-home-dir-chown-race.org b/docs/devlog/20260728-home-dir-chown-race.org new file mode 100644 index 0000000..e9a4a25 --- /dev/null +++ b/docs/devlog/20260728-home-dir-chown-race.org @@ -0,0 +1,79 @@ +* [2026-07-28] Home dir chown race: usermod aborts, $DEVA_HOME stays untraversable :FIX:ENTRYPOINT:RELIABILITY: + +** Tension +Fresh persistent container, credentials-file auth, latest image: + +#+BEGIN_EXAMPLE +[deva] Starting Claude Code v2.1.220 +env: 'claude': Permission denied +#+END_EXAMPLE + +Same launcher, same image family, eleven sibling containers running fine. +The 2026-01-08 devlog claims this exact symptom was fixed by the whitelist +chown (=7511464=). It wasn't — the whitelist fixed the subdirs and left a +hole one level up. + +** Observation +Inside the broken container: + +- =claude.exe= is =-rwxr-xr-x= and every dir on the way down is 755. Not the binary. +- =namei -l= shows the one bad component: =/home/deva= itself is + =drwxr-x--- 1001:1001= while the remapped user is 501:20. Mode 750 comes + from Ubuntu noble's =useradd= default (=HOME_MODE 0750=); owner 1001 is the + build-time UID. The user cannot traverse into its own home, so =env= + reports EACCES on everything under it. +- Whitelist subdirs (=.npm-global=, =.local=, ...) ARE 501:20 — so + =setup_nonroot_user= ran and its chowns worked. Only the home dir itself + was left behind. +- Working siblings show =/home/deva= at 501:20. Same entrypoint bytes + (diffed baked scripts), same shadow-utils version, same home mounts. + +Mechanism: =usermod -u= implicitly chowns the home tree, and shadow's +=chown_tree= recurses first, chowning the top-level dir LAST. The walk +crosses live host mounts inside home (=~/.claude= and friends, actively +written by concurrent sessions). Any transient mid-walk error aborts the +tree chown — usermod exits 12, passwd IS already updated — and the top +dir never gets chowned. Our code suppresses stderr and only reacts when +the UID itself failed to change. Reproduced the exact broken state by +recreating a container with the same home-mount set; one-shot runs with +idle mounts come out clean, which is why this stayed latent for months. + +There is a second, benign variant: usermod fails outright (rc != 0, UID +unchanged). The entrypoint adapts =DEVA_UID= to reality and everything +stays self-consistent at 1001 — launches work. Only the half-success +(passwd updated, chown aborted) bricks the container. + +** Decision +Add one explicit non-recursive chown of =$DEVA_HOME= itself after the +usermod block, before the whitelist loop. It runs with the (possibly +adapted) =DEVA_UID=, so both usermod outcomes stay consistent. + +Rejected: +- Restoring =chown -R "$DEVA_HOME"= (pre-=5807889= behavior): recursive + chown across host mounts is the thing we deliberately stopped doing. +- Adding =$DEVA_HOME= to the whitelist loop: the loop is =chown -R= per + entry; home needs exactly depth-0. +- Replacing usermod with passwd-file editing + targeted chowns: right + long-term answer (usermod's implicit tree walk over host mounts is both + the race and a hazard), but a bigger change than this fix needs. + +** Tradeoff +usermod still walks the whole home tree on every UID remap — wasted IO +and a lingering hazard of chowning through bind mounts. The fix makes the +outcome correct, not the mechanism. If launches ever get slow on big +mounted homes, kill the implicit walk (=usermod= → passwd edit) instead +of tuning around it. + +** Next +The fault-injection trick (stub =usermod= that updates passwd, skips the +chown, exits 12) is exactly a regression test waiting for a harness — +tests/ has no entrypoint coverage at all, which is why two "Permission +denied" regressions in this same function (=5807889=, now this) shipped. + +** Artifacts +- =docker-entrypoint.sh=: explicit =chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"= + in =setup_nonroot_user=. +- Verified: patched entrypoint + stubbed usermod (passwd updated, chown + skipped, rc=12) → home 501:20, claude launches. Unpatched + same stub → + reproduces the failure. +- Bricked-container first aid: =docker exec chown : /home/deva=.