Files
platform/src/servers/os-user-claude.ts
T
pastilhasandClaude Opus 5 ef000aaf51 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>
2026-08-11 23:46:59 +00:00

159 lines
8.8 KiB
TypeScript

import { join } from 'node:path';
import { osUserHome, runAs } from './os-user';
// Claude, per member: their own binary, their own login, in their own home.
//
// ── Why not one shared binary ──
//
// A single `/usr/local/bin/claude` would be less disk and one version to reason about, and the argument for
// it is real: the private part of Claude is the credential in `~/.claude`, not the executable. It is still
// the wrong shape here. `claude` updates itself — that is why the owner's own install goes through
// Anthropic's installer rather than npm (`scripts/setup.sh:853`) — and a root-owned binary is one a member
// cannot update, which turns "my agent is a version behind" into a request to the owner. Per-member also
// means the account's agent keeps working exactly as the tool ships, with no platform-shaped exception to
// explain. Same command the owner ran, run as them, in their home.
//
// ── The credential is theirs, and this is what makes that true ──
//
// A member's `claude` must never see the owner's Anthropic proxy. `sidecar/claude/user-instance.ts:148`
// sets `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY` and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` on the agent
// sidecar's own `process.env`, so anything spawned from that process inherits the owner's credential by
// default — the leak would be the absence of an action, not an action. `runAs` closes it structurally:
// `--reset-env` clears the environment on the way through `setpriv`, so a variable reaches a member only
// because someone wrote it into the command (`os-user.ts:120`). Default deny, and nothing to remember.
//
// ── Login is the member's own, and cannot be done for them ──
//
// `claude` authenticates interactively against an account. The platform therefore cannot log a member in,
// and should not want to: their subscription is theirs. All this file can do is install the binary and
// report whether the credential has appeared, so the UI can render the one-line instruction instead of an
// agent that fails for reasons nobody can see.
/** Anthropic's own installer — the same one `scripts/setup.sh` uses for the owner, chosen for auto-update. */
const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
/**
* Where the installer puts it, given a home. Also the first path `claude-manager.ts` probes after
* `$CLAUDE_BIN`.
*
* Takes a home rather than an email because the spawn side only ever has the home — it comes from
* `resolveHomeDir`, not from a lookup. One derivation for both sides: installing to one path and exec'ing
* another is the kind of divergence that surfaces as "the agent works for some members".
*/
export const claudeBinIn = (home: string): string => join(home, '.local', 'bin', 'claude');
/** The same path, for callers that hold an email. */
export const claudeBinPath = (email: string): string => claudeBinIn(osUserHome(email));
/**
* The file whose existence means "this account has logged in".
*
* `~/.claude.json` is not the marker — it holds settings and history and appears on first run, logged in or
* not. `~/.credentials.json` is written by a completed login and is mode 600, which is also why this is
* checked by running as the member rather than by reading it: we need to know the credential is there, never
* what is in it.
*/
const credentialsPath = (email: string): string => join(osUserHome(email), '.claude', '.credentials.json');
/**
* Run a command as the member.
*
* `out` merges stdout and stderr and exists for logging — a failure is diagnosable only if both are in it.
* `stdout` is kept separate for anything that *decides* on output, because merging the two channels means the
* decision can be moved by anything that writes to stderr: a shell trace, a sudo banner, a wrapper echoing
* argv. Never match on `out`.
*/
async function asMember(osUser: string, command: string[]): Promise<{ ok: boolean; out: string; stdout: string }> {
const proc = runAs(osUser, command);
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim(), stdout: out };
}
/**
* Read the two-character probe below: position 0 is the binary, position 1 is the credential.
*
* Split out as a pure function so the parsing is testable without a subprocess, and — more to the point —
* so it can only ever see stdout. The previous version decided with `out.includes('bin')` and
* `out.includes('cred')` against the merged channel, and both markers are substrings of the paths being
* tested: `bin` ⊂ `…/.local/bin/claude`, `cred` ⊂ `…/.claude/.credentials.json`. One `set -x` and the trace
* of the test command itself set both flags true with neither file present — verified on the live server
* against an account that had never logged in.
*
* No marker spelling fixes that, because a trace echoes the literal along with the path. The channel was the
* bug, so the fix is the channel plus reading by position rather than by substring.
*/
export function parseLoginProbe(stdout: string): ClaudeLoginState {
return { installed: stdout[0] === '1', loggedIn: stdout[1] === '1' };
}
export type ClaudeProvisionResult =
/** `wrote` is false when the binary was already there — a reprovision must not re-download. */
{ ok: true; binPath: string; wrote: boolean } | { ok: false; error: string };
/**
* Install `claude` into the member's home, as the member.
*
* Idempotent by skipping outright when the binary is present, rather than by re-running the installer: the
* retry button reprovisions an account whenever the owner presses it, and re-downloading would both cost a
* network round trip per press and quietly move a member off the version they had chosen by updating.
*
* Never throws. An account with no agent is still a working account — the same posture as SSH keys and
* rootless Docker in `provisionOsAccount`.
*/
export async function provisionClaudeCli(params: { email: string; osUser: string }): Promise<ClaudeProvisionResult> {
const binPath = claudeBinPath(params.email);
const present = await asMember(params.osUser, ['test', '-x', binPath]);
if (present.ok) return { ok: true, binPath, wrote: false };
// 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.
const installed = await asMember(params.osUser, ['test', '-x', binPath]);
if (!installed.ok) {
return { ok: false, error: `claude did not install for ${params.osUser}: ${install.out || 'no output'}` };
}
return { ok: true, binPath, wrote: true };
}
export type ClaudeLoginState = {
/** The binary is present and executable in their home. */
installed: boolean;
/** A completed login has written credentials. False means the member has to run `claude` once themselves. */
loggedIn: boolean;
};
/**
* Whether this account can actually run an agent turn.
*
* Both halves are read as the member, so a `true` here means the member's own process can reach these files
* — which is the thing the answer is used to promise. Checking as root would confirm the file exists while
* saying nothing about whether the account that needs it can see it.
*/
export async function claudeLoginState(params: { email: string; osUser: string }): Promise<ClaudeLoginState> {
// One `runAs` for both answers, not two. Each is a `sudo -n setpriv` fork/exec that writes a line to
// `/var/log/auth.log`, and this is reached from `/agent-status`, which sits on a grant every role has by
// default — so a UI that polls it would otherwise cost two sudo spawns and two auth-log lines per poll, per
// member. Individually cheap, unbounded in aggregate, and the auth log is where a real sudo event has to
// stay visible.
//
// Two characters on stdout, read by position — see `parseLoginProbe` for why not markers. `-x` follows
// symlinks, which is what the installer produces: a link into a versioned directory, not a file.
const probe = await asMember(params.osUser, [
'sh',
'-c',
'if test -x "$1"; then printf 1; else printf 0; fi; if test -s "$2"; then printf 1; else printf 0; fi',
'_',
claudeBinPath(params.email),
credentialsPath(params.email),
]);
return parseLoginProbe(probe.stdout);
}