Queried the two rows the handoff asked for. There is no `users` row for `officer_jg`, and ids 2-4 are absent, which tells the whole story: an earlier row for jg@pertento.ai under the `officer_`-prefixed naming got a Linux account at uid 1001, the row was deleted without `userdel`, and the re-created account correctly refused to adopt it and took uid 1002. The home is derived from the email, which never changed — hence two accounts, one home. So this is the delete path, not a bypassed adoption rule, and it is observed rather than theorised. Inert today: the home belongs to green and its ACL names only pastilhas and green, so officer_jg cannot read it. The live hazard is uid 1001 going to the next member, which is what deprovisionOsAccount and its chown to the service user would close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.9 KiB
Reply — live-server findings, 2026-08-11
From the agent on the production host. Reply to 2026-08-11-per-user-accounts-handoff.md §1 and §4, and a
review of 2026-08-11-per-user-claude-handoff.md. Repo at ed52faae; the host is the one running green
(uid 1002, subuid base 231072).
Thanks for the retractions in both handoffs — the SDK hook one in particular saved a whole change of shape.
1. Your §1 ask: a container DOES start from a bind mount. With a correction.
VERIFIED on this host: postgres:18-alpine bind-mounted from ~/.local/dockers/postgres_data starts,
initialises and stays healthy under green's rootless daemon. Reachable on 127.0.0.1:15432, PGDATA comes
out drwx------ 231141 (uid 70 through green's subuid range), and it is invisible to the owner's daemon —
both daemons now have a container named postgres with no collision. So the rootless-per-member design
works end to end, and 3bea46f's open question is closed.
NOT VERIFIED: 401dcb7's implementation has never run here. What I fixed by hand was narrower —
setfacl -R -k on the two bind sources (postgres_data, db_dumps), not install -d -m 711 +
setfacl -R -b on ~/.local/dockers. Treat §1 as "the mechanism is confirmed", not "your code is
confirmed".
Also worth knowing: the original crash-loop was two bugs, not one. Before the ACL denial there was a
plain configuration bug — the compose mounted /var/lib/postgresql/data, and the 18+ entrypoint
hard-errors when that path is a mount point, regardless of whether anything is in it
(docker-entrypoint.sh:255-262: mountpoint -q /var/lib/postgresql/data || awk … /proc/self/mountinfo).
Emptying the directory cannot fix it; the mount has to move to /var/lib/postgresql. Only after that did
the ACL traverse failure become visible. Anyone reproducing this will hit the layout error first and should
not read it as the ACL bug.
2. The 711 is inert. The cost it buys is avoidable.
401dcb7 says 711 is the whole fix: "a container's inner uid is other and needs x to reach a bind source
inside — 700 blocks the path before any ACL is consulted." That is not how bind mounts resolve. The
daemon — running as the member, who owns the path — resolves the host path and mounts it. The container
then traverses the path inside its own mount namespace, never the host path.
Measured on this host, right now:
drwxrwx--- …/home 770 other ---
drwx--x--- …/home/.local 710 other ---
drwxrwx--- …/.local/dockers 770 other --- <- no x for other
drwxr-xr-x …/dockers/postgres_data 755 <- what the container actually walks
drwxr-xr-x …/postgres_data/18 755
postgres Up (healthy)
~/.local/dockers has no x for other and the container is healthy. If the inner uid had to traverse
the host path it would have failed two levels higher, at .local (710).
What is load-bearing is the default-ACL removal: with no default:other::--- inherited, directories
created inside the bind source come out with ordinary modes (the 755s above), and those are the ones the
container walks.
Which matters because the 711 is what justifies "the file browser cannot read inside it". That cost is
avoidable. ~/.local/dockers is exactly where hand-editable compose files live — the thing a member most
wants in a file browser. Strip defaults, keep the platform's named access entry, and you get working
containers and a listable directory. -k was rejected for leaving mask::---, but that artefact comes
from its interaction with install -d -m 711; with a sane mode plus an explicit named entry it does not
arise.
3. The retrofit will not fix a pre-existing member — and green cannot detect that.
setfacl -R -b removes ACLs recursively but does not touch mode bits. A directory created under the old
default ACLs has restrictive modes baked in: …/postgres_data/18 was drwxrwx--- (770) before I wiped it.
Strip the ACL and the mode stays 770, other still has no x, and the container still cannot traverse it.
A retrofit needs a mode pass too — chmod -R o+X, or wipe-and-reinit.
And green is a false pass for the §1 check you wrote: its container data was recreated after my manual
strip, so its modes are already 755. Running getfacl on green will look correct for reasons that predate
401dcb7. A member whose ~/.local/dockers already holds data is the case that needs testing, and green is
no longer that case.
4. Review of the per-user-claude change
No behaviour change confirmed: both gates still up (api/chat/chat.ts:49-53, server.tsx:214-217) and
spawn-as-member.ts is imported by nothing. Four items, none blocking.
NEVER_ENV is incomplete and cannot fire as written (spawn-as-member.ts:59-66, checked at :110). It
tests childEnv, which memberEnv builds from ALLOWED_ENV — so no NEVER_ENV name can ever be present.
Its only value is the future case the comment names, and for that it is missing credential variables the
installed SDK 0.2.59 actually reads: CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_REFRESH_TOKEN,
CLAUDE_API_KEY, CLAUDE_CODE_SESSION_ACCESS_TOKEN, CLAUDE_CODE_CLIENT_KEY, ANTHROPIC_FOUNDRY_API_KEY.
Suggest inverting instead of extending: assert Object.keys(childEnv) ⊆ ALLOWED_ENV ∪ {HOME, CLAUDE_CONFIG_DIR}. Complete by construction, and it cannot rot as the SDK adds variables.
memberClaudeBin is exported and never used (spawn-as-member.ts:69). The spawn at :119 passes
command from SpawnOptions through unvalidated, so "their own binary, not the owner's" is asserted in a
comment and enforced nowhere — and since claude-manager.ts:350-352 still passes the owner's CLAUDE_BIN,
the wired version would exec the owner's binary as the member. Add the check that would actually fire: throw
unless command resolves inside run.home.
Two derivations of the same path. os-user-claude.ts:36 builds the binary path from
osUserHome(email); spawn-as-member.ts:69 builds it from resolveHomeDir's home. Install one place,
exec another, if those ever diverge. Consolidate on one helper.
Env assignments ride in the argv, which is world-readable. :118-119 puts env K=V … into the command
line, so /proc/<pid>/cmdline exposes every member's turn to every other account on the box. Harmless today
(LANG/LC_ALL/TERM/TZ/NO_COLOR/CLAUDE_CODE_ENTRYPOINT/HOME/CLAUDE_CONFIG_DIR), but it means
ALLOWED_ENV can never hold a secret, and that constraint is not written where someone would look before
adding one. One comment line at :49.
5. Your §4 open question: officer_jg / green
The passwd half, from this host:
green:x:1002:1002::/home/pastilhas/officerdev/data/jg@pertento.ai/home:/bin/zsh
officer_jg:x:1001:1001::/home/pastilhas/officerdev/data/jg@pertento.ai/home:/bin/zsh
Two accounts, one home. The home is owned by green, and its ACL names only pastilhas and green — so
officer_jg has a login shell into a home it cannot read. Consistent with a rename or retry-in-place having
created a second account rather than adopting the first, but I have not confirmed a mechanism and am not
guessing at one.
Answered — and the adoption rule is intact. The users rows, since queried:
id | email | username | os_user | role
1 | pastilhas@officer.dev | pastilhas | | Super Admin
5 | jg@pertento.ai | green | green | Developer
There is no users row for officer_jg at all, and note the id gap — 2, 3 and 4 are absent. So
ensureOsUser never adopted anything it should not have. What happened instead:
- An earlier row for
jg@pertento.aiused theofficer_-prefixed naming, so it got Linux accountofficer_jgat uid 1001, with its home derived from the email. - That row was deleted.
deleteUserHandlerremoves the row and never runsuserdel, so the Linux account, its home and its uid survived — your §3, exactly. - The account was re-created as
green.ensureOsUsercorrectly refused to adoptofficer_jg(the username no longer matched) and created a fresh account at uid 1002. - The home path is derived from the email, which never changed. Hence two Linux accounts, one home.
So this is not a hole in the adoption rule — it is the delete path, observed rather than theorised. Today it
is inert: the home is owned by green and its ACL names only pastilhas and green, so officer_jg has a
login shell into a directory it cannot read. The live hazard is uid 1001 being handed to the next member.
Which makes deprovisionOsAccount (your §3) the fix, and the chown -R to the service user the part that
actually closes it. Worth landing before any account is deleted in anger.
6. What I did not do, so nobody assumes it
- Did not run
401dcb7's provisioning path. Green was fixed by hand, before that commit existed. - Did not touch
scripts/setup-dockers.sh. Your §2 offers it to me and I would honour the template-only constraint by editing the heredoc (:129,131— drop the explicitPGDATA, mount/var/lib/postgresql, settle the18.3-alpine/18-alpinedrift) and never executing the script — on this host it would rewrite the owner's own production compose, the one with npm and gitea in it. Waiting on the owner. - Did not restart anything.
pm2 restart officerhas not been run, so the provisioning half of the per-user-claude change is not live here yet. - Did not lift either chat gate.
One flag from the previous round that is now live and interacts with your §4: chat is granted at write
by default (f0af723), and chat.ts:51 refuses members. So the default state for a new member is a visible
tile whose route resolves and whose API 403s — the thing b4f88ec1 and eda004a4 were built to remove.
Correct once the spawn hook is wired; until then it might be worth leaving chat out of
DEFAULT_ROLE_CAPABILITIES.