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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions DEV-LOGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
- Minimal markdown markers, no unnecessary formatting, minimal emojis.
- Reference issue numbers in the format `#<issue-number>` 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 <c> chown <uid>:<gid> /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-<stem> | api-key-<last4> | 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`.
Expand Down
7 changes: 7 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the home-directory repair on every entrypoint invocation

On a newly created persistent container, deva.sh starts the detached PID 1 entrypoint, sleeps only 0.3 seconds, and then invokes this entrypoint again with docker exec (deva.sh lines 4233, 4259, and 4287). Since usermod updates passwd before completing its potentially slow home-tree walk, the exec invocation can observe current_uid == DEVA_UID, skip this entire conditional, and reach the agent while PID 1 is still walking and /home/deva remains owned by the build UID. The same intermittent env: 'claude': Permission denied can therefore survive this fix; run the non-recursive home-directory ownership repair on every invocation after any UID adaptation, rather than only when that invocation detects a UID change.

Useful? React with 👍 / 👎.

# 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
Expand Down
79 changes: 79 additions & 0 deletions docs/devlog/20260728-home-dir-chown-race.org
Original file line number Diff line number Diff line change
@@ -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 <c> chown <uid>:<gid> /home/deva=.
Loading