|
| 1 | +* [2026-07-28] Home dir chown race: usermod aborts, $DEVA_HOME stays untraversable :FIX:ENTRYPOINT:RELIABILITY: |
| 2 | + |
| 3 | +** Tension |
| 4 | +Fresh persistent container, credentials-file auth, latest image: |
| 5 | + |
| 6 | +#+BEGIN_EXAMPLE |
| 7 | +[deva] Starting Claude Code v2.1.220 |
| 8 | +env: 'claude': Permission denied |
| 9 | +#+END_EXAMPLE |
| 10 | + |
| 11 | +Same launcher, same image family, eleven sibling containers running fine. |
| 12 | +The 2026-01-08 devlog claims this exact symptom was fixed by the whitelist |
| 13 | +chown (=7511464=). It wasn't — the whitelist fixed the subdirs and left a |
| 14 | +hole one level up. |
| 15 | + |
| 16 | +** Observation |
| 17 | +Inside the broken container: |
| 18 | + |
| 19 | +- =claude.exe= is =-rwxr-xr-x= and every dir on the way down is 755. Not the binary. |
| 20 | +- =namei -l= shows the one bad component: =/home/deva= itself is |
| 21 | + =drwxr-x--- 1001:1001= while the remapped user is 501:20. Mode 750 comes |
| 22 | + from Ubuntu noble's =useradd= default (=HOME_MODE 0750=); owner 1001 is the |
| 23 | + build-time UID. The user cannot traverse into its own home, so =env= |
| 24 | + reports EACCES on everything under it. |
| 25 | +- Whitelist subdirs (=.npm-global=, =.local=, ...) ARE 501:20 — so |
| 26 | + =setup_nonroot_user= ran and its chowns worked. Only the home dir itself |
| 27 | + was left behind. |
| 28 | +- Working siblings show =/home/deva= at 501:20. Same entrypoint bytes |
| 29 | + (diffed baked scripts), same shadow-utils version, same home mounts. |
| 30 | + |
| 31 | +Mechanism: =usermod -u= implicitly chowns the home tree, and shadow's |
| 32 | +=chown_tree= recurses first, chowning the top-level dir LAST. The walk |
| 33 | +crosses live host mounts inside home (=~/.claude= and friends, actively |
| 34 | +written by concurrent sessions). Any transient mid-walk error aborts the |
| 35 | +tree chown — usermod exits 12, passwd IS already updated — and the top |
| 36 | +dir never gets chowned. Our code suppresses stderr and only reacts when |
| 37 | +the UID itself failed to change. Reproduced the exact broken state by |
| 38 | +recreating a container with the same home-mount set; one-shot runs with |
| 39 | +idle mounts come out clean, which is why this stayed latent for months. |
| 40 | + |
| 41 | +There is a second, benign variant: usermod fails outright (rc != 0, UID |
| 42 | +unchanged). The entrypoint adapts =DEVA_UID= to reality and everything |
| 43 | +stays self-consistent at 1001 — launches work. Only the half-success |
| 44 | +(passwd updated, chown aborted) bricks the container. |
| 45 | + |
| 46 | +** Decision |
| 47 | +Add one explicit non-recursive chown of =$DEVA_HOME= itself after the |
| 48 | +usermod block, before the whitelist loop. It runs with the (possibly |
| 49 | +adapted) =DEVA_UID=, so both usermod outcomes stay consistent. |
| 50 | + |
| 51 | +Rejected: |
| 52 | +- Restoring =chown -R "$DEVA_HOME"= (pre-=5807889= behavior): recursive |
| 53 | + chown across host mounts is the thing we deliberately stopped doing. |
| 54 | +- Adding =$DEVA_HOME= to the whitelist loop: the loop is =chown -R= per |
| 55 | + entry; home needs exactly depth-0. |
| 56 | +- Replacing usermod with passwd-file editing + targeted chowns: right |
| 57 | + long-term answer (usermod's implicit tree walk over host mounts is both |
| 58 | + the race and a hazard), but a bigger change than this fix needs. |
| 59 | + |
| 60 | +** Tradeoff |
| 61 | +usermod still walks the whole home tree on every UID remap — wasted IO |
| 62 | +and a lingering hazard of chowning through bind mounts. The fix makes the |
| 63 | +outcome correct, not the mechanism. If launches ever get slow on big |
| 64 | +mounted homes, kill the implicit walk (=usermod= → passwd edit) instead |
| 65 | +of tuning around it. |
| 66 | + |
| 67 | +** Next |
| 68 | +The fault-injection trick (stub =usermod= that updates passwd, skips the |
| 69 | +chown, exits 12) is exactly a regression test waiting for a harness — |
| 70 | +tests/ has no entrypoint coverage at all, which is why two "Permission |
| 71 | +denied" regressions in this same function (=5807889=, now this) shipped. |
| 72 | + |
| 73 | +** Artifacts |
| 74 | +- =docker-entrypoint.sh=: explicit =chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"= |
| 75 | + in =setup_nonroot_user=. |
| 76 | +- Verified: patched entrypoint + stubbed usermod (passwd updated, chown |
| 77 | + skipped, rc=12) → home 501:20, claude launches. Unpatched + same stub → |
| 78 | + reproduces the failure. |
| 79 | +- Bricked-container first aid: =docker exec <c> chown <uid>:<gid> /home/deva=. |
0 commit comments