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>
171 lines
9.9 KiB
Markdown
171 lines
9.9 KiB
Markdown
# 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:
|
||
|
||
1. An earlier row for `jg@pertento.ai` used the `officer_`-prefixed naming, so it got Linux account
|
||
`officer_jg` at uid 1001, with its home derived from the **email**.
|
||
2. That row was deleted. `deleteUserHandler` removes the row and never runs `userdel`, so the Linux account,
|
||
its home and its uid survived — your §3, exactly.
|
||
3. The account was re-created as `green`. `ensureOsUser` correctly **refused** to adopt `officer_jg` (the
|
||
username no longer matched) and created a fresh account at uid 1002.
|
||
4. 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 explicit `PGDATA`, mount `/var/lib/postgresql`,
|
||
settle the `18.3-alpine` / `18-alpine` drift) 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 officer` has 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`.
|