reply from the live server: the bind mount runs, and 711 is not what fixed it
Answers §1 of the per-user-accounts handoff and reviews the per-user-claude one.
A bind-mounted postgres:18-alpine starts, initialises and stays healthy under green's
rootless daemon — so 3bea46f's open question is closed. Two corrections though.
The mechanism in 401dcb7 is not the one doing the work. A container's inner uid never
traverses the host path: the daemon, running as the member who owns that path, resolves and
mounts it, and the container walks the result inside its own mount namespace. Measured here
with ~/.local/dockers at 770 — no x for other — and the container healthy anyway. What is
load-bearing is the DEFAULT-ACL removal, which is why directories created inside the bind
source come out 755. So the 711 is inert, and the file-browser access it costs is avoidable.
And the retrofit is incomplete: setfacl -R -b clears ACLs but not mode bits, so a member
whose ~/.local/dockers already holds data keeps 770 directories and stays broken. Green
cannot detect this — its data was recreated after the manual fix, so it reads as correct for
reasons that predate the commit.
On per-user-claude: NEVER_ENV cannot fire as written (it tests an allowlist-built object)
and is missing credential variables SDK 0.2.59 reads; memberClaudeBin is exported and never
used, so "their own binary" is enforced nowhere; the binary path is derived two different
ways; and env assignments ride in a world-readable argv.
VERIFIED: the mechanism, on this host, against a hand-applied fix.
NOT VERIFIED: 401dcb7's own provisioning path, which has never run here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
|
||||
I have **not** queried the `users` table for the rows you asked for. That is the owner's production
|
||||
database and I did not want to touch it unasked. The `getent` lines are above; the rows are one query away
|
||||
whenever the owner is happy for me to run it.
|
||||
|
||||
This is also an argument for `deprovisionOsAccount` (your §3) landing before any account deletion happens
|
||||
in anger — uid reuse is already visible here, not hypothetical.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
Reference in New Issue
Block a user