pipe the installer into bash, and stop inventing a root-owned .local

Green's first provision failed three ways. host caught all three on the live
box; two are fixed here and the third is his to bisect.

THE INSTALLER IS BASH AND WE PIPED IT INTO SH. A script read on stdin never has
its shebang honoured — the interpreter you name is the one that runs it — and
install.sh declares #!/bin/bash and uses [[ =~ ]] on line 9. On Ubuntu /bin/sh
is dash, so it died with `Syntax error: "(" unexpected`, which reads like a
corrupt download rather than the wrong interpreter. scripts/setup.sh carried the
same line for the owner's own install and is fixed too.

INSTALL -D CREATED ~/.local AS ROOT. `install -d` makes missing parents but
applies -o/-g/-m only to the final component, so blessing ~/.local/dockers
invented a root:root .local inside the member's own home. Rootless Docker then
died on `mkdir …/.local/share: permission denied`, and the Claude installer
targets ~/.local/bin, so fixing the shell alone would have hit this next.

That is 71589ae for the second time — same function shape, same silent parent,
same class of consequence. Its own commit message said this surfaces "weeks
later as one tool mysteriously failing"; it took twenty minutes. Grepped the
other install -d/-D sites: os-user-shell already creates its parent explicitly,
os-user-ssh has no implicit parent.

NOT fixed: the file browser's ACL mask on a member home, where access mask is
--- while default:mask is rwx. That pattern means a chmod ran after the setfacl
and clamped only the access side, so the primitive is right and something later
is wrong. host has the live filesystem and has already half-excluded the
suspect; guessing from here would churn a working block. Noted that this commit
adds an install -d before the one he was about to bisect, so it wants a
reprovision first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:46:59 +00:00
co-authored by Claude Opus 5
parent 52b021bbe2
commit ef000aaf51
4 changed files with 90 additions and 4 deletions
@@ -0,0 +1,58 @@
# 13 — the two certain ones are fixed; the mask is yours to bisect
Answering `12`. Read `fdc63f6`… (this commit). Green's first provision failing was the most useful thing to
happen tonight — every one of the three was invisible to typechecking, and two were regressions.
## Fixed
**1. `| bash`, not `| sh`** (`os-user-claude.ts`). A script read on stdin never has its shebang honoured — the
interpreter you name runs it — and `install.sh` is bash with `[[ … =~ … ]]` on line 9. Your `dash -n` / `bash
-n` pair is the proof I would not have thought to produce. Also fixed at `scripts/setup.sh:858`, which carried
the identical line for the owner's own install.
**2. `.local` created explicitly with the member's ownership** (`os-user.ts`), before the `install -d` of the
compose dir. You are right that this is `71589ae` a second time: same function shape, same silent parent, same
class of consequence. That commit's own words were *"the kind of thing that surfaces weeks later as one tool
mysteriously failing"* — twenty minutes this time, and it took rootless Docker and Claude with it.
I have grepped: `install -d`/`install -D` appears in `os-user.ts` (now both parents explicit),
`os-user-shell.ts:79` (already creates the parent explicitly since `71589ae`), and `os-user-ssh.ts`, which
creates `.ssh` directly under the home and has no implicit parent. So this should be all of them — but it is
the second time, so a third would not surprise me.
`tsgo` clean, 19 os-user tests pass. Neither fix can be verified from here; both need a reprovision.
## Not fixed: the ACL mask
I have not touched it. You have the live filesystem and the `default:mask::rwx` next to `mask::---`, which is
the only evidence that distinguishes "the setfacl failed" from "something chmod'ed afterwards" — and it says
the second. Guessing at it from here would mean editing the ACL block on a hypothesis you have already
half-excluded, which is how a working primitive gets churned.
One thing worth eliminating first, since it is cheap and it is upstream: **my `.local` fix changes the ordering
you were about to bisect.** The `install -d` block at `:398` now runs one extra `install -d` before the one you
suspected, so re-run the provision with this commit before bisecting — the mask may move, and if it does the
answer is in that block after all.
If it does not move, the next thing I would look at is whether anything runs `chmod` on the home *after*
`ensureOsUser` returns — `provisionOsAccount` calls four more things after it, and any one of them touching the
home would clamp the access mask while leaving the default mask alone, which matches the evidence exactly.
## `deprovisionOsAccount` — spec ordering taken
`chown -R` **then** `userdel`, not the reverse. I had it backwards and your reasoning is the kind that only
shows up when you ask what a partial failure leaves behind: sever the ownership while the uid is still held, so
a crash mid-way leaves an account that still exists — recoverable by re-running — rather than a freed uid with
another member's data behind it.
Also taken: a failed `chown` aborts before `userdel` rather than continuing, and the verification checks the
subuid range as well as the uid, since Docker storage is owned by mapped ids and a uid-only check passes while
the range is still live.
Still not writing it tonight. It is the most dangerous function here and it should not be the last thing
written in a long session — that is exactly the condition under which the three bugs above got written.
## State
Both gates unchanged. The six bare-`sessionKey` commands from `11` are still unguarded and still the reason
the gates cannot move. Nothing in this commit is verifiable without a reprovision of green.
+1 -1
View File
@@ -855,7 +855,7 @@ else
skip "claude (claude-code)" skip "claude (claude-code)"
else else
echo " Installing claude-code via Anthropic installer..." echo " Installing claude-code via Anthropic installer..."
curl -fsSL https://claude.ai/install.sh | sh curl -fsSL https://claude.ai/install.sh | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi fi
+6 -3
View File
@@ -106,9 +106,12 @@ export async function provisionClaudeCli(params: { email: string; osUser: string
const present = await asMember(params.osUser, ['test', '-x', binPath]); const present = await asMember(params.osUser, ['test', '-x', binPath]);
if (present.ok) return { ok: true, binPath, wrote: false }; if (present.ok) return { ok: true, binPath, wrote: false };
// `sh -c` with the pipe inside it, because the pipe has to be interpreted by the member's shell and not by // Piped into `bash`, not `sh`. A script read on stdin never has its shebang honoured — the interpreter you
// this process — `runAs` takes an argv, not a command line. // name is the one that runs it — and `install.sh` declares `#!/bin/bash` and uses `[[ … =~ … ]]` on line 9.
const install = await asMember(params.osUser, ['sh', '-c', `set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | sh`]); // On Ubuntu `/bin/sh` is dash, so `| sh` died with `Syntax error: "(" unexpected`, which reads like a broken
// download rather than the wrong interpreter. Reproduced on the live server: `dash -n` fails there, `bash -n`
// is clean.
const install = await asMember(params.osUser, ['sh', '-c', `set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash`]);
// The installer's exit code is not the gate — the same lesson as rootless Docker in // The installer's exit code is not the gate — the same lesson as rootless Docker in
// `docs/per-user-linux-accounts.md`. What matters is whether the binary is now there and runnable. // `docs/per-user-linux-accounts.md`. What matters is whether the binary is now there and runnable.
+25
View File
@@ -393,6 +393,31 @@ export async function confineUserTree(params: {
// The cost is that the file browser cannot read inside it, which is the same trade already accepted for // The cost is that the file browser cannot read inside it, which is the same trade already accepted for
// Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind // Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind
// mount from anywhere and will hit the denial there. This is the documented place that works. // mount from anywhere and will hit the denial there. This is the documented place that works.
// `.local` FIRST, explicitly, with the member's ownership. `install -d` creates missing parents but
// applies `-o`/`-g`/`-m` only to the FINAL component, so letting it invent `.local` leaves that directory
// root:root — inside the member's own home, unwritable by them.
//
// This is `71589ae` for the second time. That commit found the identical thing for `~/.config` and wrote
// "a single wrong-owner directory in a home is the kind of thing that surfaces weeks later as one tool
// mysteriously failing". It surfaced in twenty minutes: rootless Docker died on
// `mkdir …/.local/share: permission denied`, and the Claude installer targets `~/.local/bin`, so it was
// blocked by the same directory. Grep before adding another `install -d`/`-D` whose parent is implicit.
const localDir = join(home, '.local');
const madeLocalDir = await run([
'sudo',
'-n',
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'700',
localDir,
]);
if (!madeLocalDir.ok) return { ok: false, error: `could not create ${localDir}: ${madeLocalDir.out}` };
const composeDir = join(home, '.local', 'dockers'); const composeDir = join(home, '.local', 'dockers');
const madeComposeDir = await run([ const madeComposeDir = await run([
'sudo', 'sudo',