14: claude installs and runs for a member, first time anywhere

Verified against a real reprovision of green at 00:02. Both fixes in ef000aaf work.

provisionClaudeCli succeeded: ~/.local/bin/claude is a symlink into
.local/share/claude/versions/2.1.228, owned by the member, and `claude --version` run as them
returns 2.1.228. The largest untested assumption in the feature is no longer untested. It also
retroactively justifies realpathSync in spawn-as-member.ts — a member's install has the same
symlink shape as the owner's, so the string compare that shipped first would have thrown on
every turn.

The file browser is fixed: mask::rwx and the platform can list the home. The clamp was in that
block, as predicted. Rootless Docker runs, server 29.2.1, and isolation holds — green sees 0
containers while the owner has 8.

Three things still open. provisionRootlessDocker's ACL strip no-opped, because its existsSync
guard was false when the daemon had not yet created the directory — the gap flagged in my
first review tonight, and worse than "retry cures it" since the retry is what creates the
directory. Suggested fix mirrors the .local one: create the storage dir explicitly, clean,
before starting the daemon.

.local is unreadable by the platform, as predicted in 12 — created after the ACL pass, and its
explicit -m 700 clamps the inherited mask.

And a container with an explicit non-root uid AND a bind mount fails, which I could not
root-cause: both my hypotheses were wrong and I restored what I changed. Reported as an
observation, not a defect — it may be an artefact of forcing uid 70 on an image that has no
such user. The meaningful test is a real postgres with a bind mount, which I have not run here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 00:07:02 +00:00
co-authored by Claude Opus 5
parent ef000aaf51
commit e99949b1ac
@@ -0,0 +1,116 @@
# 14 — `provisionClaudeCli` works, the mask is fixed, and one strip is still no-opping
Commit read: `ef000aaf` (`52b021bb..ef000aaf`). Verified against a real reprovision of green on the production
host, uid 1001, at 00:02 UTC.
**Both your fixes work. Two of the three failures from `12` are closed, and the third closed as a side effect.**
---
## Working, measured
**1. `provisionClaudeCli` — the first successful execution anywhere.**
```
~/.local/bin/claude -> …/.local/share/claude/versions/2.1.228 owned by green
run as the member: 2.1.228 (Claude Code)
```
The largest untested assumption in the feature is no longer untested. Note the shape: it is a **symlink into a
versions directory**, on a member's install as on the owner's — which retroactively justifies `sameFile` and
`realpathSync` in `spawn-as-member.ts`. The string compare that shipped first would have thrown on every
member turn.
**2. The file browser works.** `mask::rwx` on the home, and the platform can list it. So the clamp **was** in
that block — your `.local` fix moved it, exactly as you predicted in `13`. Nothing further to bisect.
**3. Rootless Docker runs.** Server 29.2.1 as the member, and the isolation holds: green sees **0** containers
while the owner has 8. A container starts and writes into a bind mount under `~/.local/dockers`.
`~/.claude/.credentials.json` is correctly absent — nobody has signed in, and now there is finally a binary to
sign in with.
---
## Still open
### A. `provisionRootlessDocker`'s ACL strip no-opped — and this is the first-review gap
`~/.local/share/docker` came out of provisioning carrying the home's inherited defaults:
```
mask::--x
default:user:pastilhas:rwx
default:group::---
default:other::--- ← should have been stripped
```
`os-user-docker.ts` guards the strip with `existsSync(dockerData)`. On this account the daemon **failed to
start on the first provision** — `.local` was root-owned then — so Docker never created that directory, the
guard was false, and the strip did nothing. The retry started the daemon, which created the directory fresh
with the defaults inherited, and by then the strip had already run or raced it.
This is the gap flagged in my very first review of the night:
> *"`existsSync(dockerData)` is false on a first run where the daemon hasn't yet created the dir, so the strip
> can no-op, with retry the only cure."*
It is worse than "retry cures it", because the retry is what *creates* the directory. **Suggested fix, mirroring
what you just did for `.local`:** create `~/.local/share/docker` explicitly with the member's ownership and no
default ACLs **before** starting the daemon, so Docker inherits a clean directory rather than being chased
afterwards. A guard that depends on another process having got there first is a race however it is written.
### B. `.local` is unreadable by the platform — the prediction in `12` held
```
user:pastilhas:rwx #effective:---
mask::---
```
The ACL block runs at `os-user.ts:362`; `.local` is created at `:398`, after it, and the explicit `-m 700`
clamps the mask on the entries inherited from the home's defaults. So the file browser can list a member's home
and cannot open `.local`.
Not urgent and arguably not wrong — `.local` holds binaries and container storage rather than a member's
documents. But it is the same clamping mechanism as the home bug, one level down, and it should be a decision
rather than a leftover. If the platform should see it, the ACL pass needs to run after the directories are
created, not before.
### C. A container with an explicit non-root uid **and** a bind mount fails — not root-caused
```
docker run -u 70 alpine id → uid=70 OK
docker run -v …/probe:/data alpine (root) → BIND_WRITE_OK
docker run -u 70 -v …/probe:/data alpine → sh: id: Permission denied
```
Only the combination fails. I tried two hypotheses and **both were wrong**: stripping the default ACLs from
`~/.local/share/docker` did not fix it, and opening `.local` to 711 did not either. I restored both.
I am reporting this as an observation rather than a defect, because it may be an artefact of my probe: alpine
has no uid 70 in `/etc/passwd`, and forcing one may break the container for reasons unrelated to permissions.
**The meaningful test is a real `postgres:18-alpine` with a bind mount**, which is the case that failed and
then worked on the previous green earlier tonight. I have not run it on this account. Worth doing before anyone
concludes bind mounts are healthy.
### D. Minor: every file in a member's home looks executable
```
-rw-rwx--- …/.config/systemd/user/docker.service
systemd: "Configuration file … is marked executable. Please remove executable permission bits. Proceeding anyway."
```
The `x` in the group triad is the **ACL mask**, not a real group permission — but anything that inspects mode
bits reads it as executable. systemd only warns. Something stricter would refuse: `ssh` rejecting a key file
on loose permissions is the obvious candidate, and `.ssh` only escapes it because `os-user-ssh.ts` installs
those files with explicit modes. Worth knowing before the next tool starts complaining.
---
## State
Green: account, ACLs, SSH, shell, Claude and rootless Docker all provisioned. Both gates unchanged, `member`
populated by nothing, the six bare-`sessionKey` commands from `11` still unguarded.
The next thing that would move the feature is a member signing in with their own Anthropic account — which is
now possible for the first time, and which is also the untested OAuth-in-a-web-terminal flow from `10`.