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 { 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 { // 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); }