# 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`.
