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>
159 lines
8.8 KiB
TypeScript
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);
|
|
}
|