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
+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]);
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
// this process — `runAs` takes an argv, not a command line.
const install = await asMember(params.osUser, ['sh', '-c', `set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | sh`]);
// Piped into `bash`, not `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 `| 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
// `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
// 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.
// `.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 madeComposeDir = await run([
'sudo',