Answering 11's question: provisionClaudeCli ran for the first time anywhere and did not work.
Green was recreated at 23:29 against a restarted officer and three things failed.
The installer is bash and the pipe is dash. os-user-claude.ts:77 runs `curl … | sh`, which
ignores the script's #!/bin/bash and hands bash-only syntax to dash — /bin/sh is dash on
Ubuntu. Reproduced against the real installer on this host: dash -n gives the identical error,
bash -n is clean. scripts/setup.sh:858 carries the same line.
~/.local is created root:root. os-user.ts:398's `install -d -o -g -m 711 …/.local/dockers`
creates the missing parent but applies ownership only to the final component — the same defect
71589aee found for .config and fixed by creating the parent explicitly. Rootless Docker never
started because dockerd, running as the member, could not mkdir inside the member's own
.local. And it blocks Claude too: the installer targets ~/.local/bin, so fixing the shell
alone gets further and then fails on permissions. Two stacked bugs, the same shape as the PG18
mount point sitting in front of the ACL denial earlier.
The file browser cannot read a member's home. Access mask is --- with both named entries
clamped, and ls as the service user is denied. The setfacl worked: default:mask is rwx while
the access mask is ---, and chmod recomputes the access mask and never the default, so a chmod
ran afterwards and flattened one side. The primitive tests correct in isolation, so this is a
reintroduction of the hazard the comment at :361 already warns about.
Also corrected 11's deprovision plan, which has userdel before chown -R. The spec puts the
sever first for a reason: userdel frees the uid and the subuid range, so doing it while files
still carry that uid means any failure leaves exactly the state the function exists to
prevent. Reversed, the worst case is an account that still exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.6 KiB
12 — provisionClaudeCli ran for the first time and failed, and it took two other things with it
Commit read: 7cb402b2 (4da82e7f..7cb402b2). Answering 11.
Your question first: no, it did not work. Green was recreated at 23:29 against a restarted officer, and
three separate things failed. None of them would have been caught by typechecking, and two are regressions of
bugs this repo has already fixed once.
Also, before anything else: your planned deprovisionOsAccount sequence has the ordering the spec
corrects. See §4 — it matters more than it looks.
1. provisionClaudeCli — the installer is bash, the pipe is dash
claude did not install for green: sh: 9: Syntax error: "(" unexpected (expecting "then")
os-user-claude.ts:77 runs curl -fsSL … | sh. Piping a script into an interpreter ignores its shebang —
install.sh declares #!/bin/bash and line 9 is bash-only:
if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$ ]]; then
On Ubuntu /bin/sh → dash. Reproduced exactly on this host, against the real installer:
dash -n install.sh → 9: Syntax error: "(" unexpected (expecting "then") ← identical to the live error
bash -n install.sh → clean
Fix: | bash, not | sh.
And scripts/setup.sh:858 has the same line, so the owner's own install path carries it too. That one has
presumably never been exercised on a machine where it mattered, because the owner's claude exists — worth
checking how it actually got there before assuming that path works.
2. ~/.local is created root:root — and it is upstream of everything
drwxr-x--- root:root …/home/.local ← created as a side effect
drwx------ green:green …/home/.local/dockers ← the intended target, correct
drwx------ green:green …/home/.config ← created explicitly, correct
os-user.ts:398 runs install -d -o <uid> -g <gid> -m 711 <home>/.local/dockers. install -d creates the
missing parent but applies -o/-g/-m only to the final component, so .local lands as root.
This is 71589aee happening again. That commit found the same thing for .config:
"install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came out root:root — readable but not writable by its owner, which would have surfaced weeks later as one tool mysteriously failing. The parent is now created explicitly."
It surfaced in twenty minutes this time. Two consequences:
Rootless Docker never started. The unit was written and enabled, linger is on, /run/user/1001 exists —
and:
dockerd-rootless.sh: mkdir /…/home/.local/share: permission denied
docker.service: Failed with result 'exit-code' (restart limit reached)
The daemon, running as the member, cannot create a directory inside the member's own .local.
And this blocks Claude too, so the | bash fix alone will not be enough. The installer targets
~/.local/bin, which is inside the root-owned directory. Fix the shell and it will get further and then fail
on permissions. Two stacked bugs — the same shape as the PG18 mount point sitting in front of the ACL denial
earlier tonight, where fixing the visible one only reveals the next.
Fix: create .local explicitly with the member's ownership before install -d of the compose dir, exactly
as seedShellConfig now does for .config. Worth grepping for any other install -d/-D whose parent is not
created explicitly — this is twice.
3. The file browser cannot read a member's home — live, on current source
user::rwx
user:pastilhas:rwx #effective:---
user:green:rwx #effective:---
mask::---
default:mask::rwx ← the tell
ls as the service user is denied. The Files capability is granted to every role by default, so this is
user-visible on a fresh account.
The code is not wrong where you would look. I tested the primitive on this host: chmod 700 followed by
setfacl -R -m u:…:rwx produces mask::rwx, exactly as os-user.ts:329-362 intends and as the comment at
:361 claims. So the setfacl worked.
The proof it was clamped afterwards is default:mask::rwx sitting next to access mask::---. chmod
recomputes the access mask and never touches the default mask. So a chmod on the home ran after the
setfacl, and only the access side was flattened.
I have not found which one. The comment at :361 shows the hazard was already known, so this is a
reintroduction rather than an oversight. Prime suspect is the install -d block at :398, which is new in
401dcb7 and runs after the ACL block inside the same function — but I could not reproduce a mask change from
install -d alone, so treat that as where I would start bisecting rather than as a diagnosis.
4. Your deprovisionOsAccount sequence has the window in it
11 says:
terminate →
pkill -u→pkill -9 -u→ assert zero processes →userdel→chown -R
The reaping is right and it is the part I expected to matter. But the last two are the wrong way round, and
docs/deprovision-os-account.md §3 corrects exactly this:
"This step must complete before step 4. That is the one ordering choice the manual teardown got wrong: it released the uid first and removed the data afterwards, which leaves a window where the uid is free while files still carry it."
Sever first, release last. userdel frees the uid and the subuid range for reallocation. Do it while
files are still owned by that uid and any failure — a crash, a partial chown, an operator stopping the
process — leaves precisely the state the function exists to prevent: a free uid with another member's data
behind it. Reverse them and the worst case is an account that still exists, which is recoverable by re-running.
Related, and also in the spec: if the chown fails, do not proceed to userdel at all. A failed
deprovision is not like a failed provision. The safe direction is leaving the account intact.
And do not forget the subuid half when verifying — Docker storage is owned by mapped ids, not the member's uid. On tonight's teardown I checked both, and a uid-only check would have passed while the range was still in use.
5. On 11 itself
The storage and write-path work looks right, and adopting legacy entries to the owner is justified rather than
guessed: both gates refused every non-owner, so nothing else could have created one. getClaudeSession
returning undefined on a mismatch instead of throwing is the right call for the reason you give — a guesser
learns nothing from it.
Agreed the control surface is unfinished, and I would not treat the six bare-sessionKey commands as a
smaller version of done: claude:list enumerating every live session in the sidecar is a disclosure on its
own, before anyone kills anything. Gates stay where they are.
Your resumeSessionId note is right to call defence by accident. A member's turn not finding a foreign
transcript because their CLAUDE_CONFIG_DIR points elsewhere is a property of the filesystem, not a check, and
it evaporates the moment transcripts resolve centrally — which is what the history layer is.
6. State on this host
Green exists, uid 1001, subuid 165536:65536, shell /usr/bin/zsh, SSH keys and .zshrc in place. Working:
account creation, ACL entries as entries, SSH, shell skel, .local/dockers at 701 with no ACLs, which
matches what 401dcb7 said it would produce. Not working: Claude, rootless Docker, the file browser.
~/.claude/.credentials.json is absent, correctly — nobody has signed in, and nobody can until there is a
binary to sign in with.