a member's claude is their own binary and their own login
First half of per-user Claude. Provisioning and the privilege drop, not yet
wired to a turn — the chat gates stay up and behaviour is unchanged for
everyone. Committed unfinished on purpose so the reasoning is on the record
before the server agent runs any of it; the state is written up in
COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md.
THE CLAIM THAT CHANGED. docs/per-user-linux-accounts.md:226-229 says the Agent
SDK "has nowhere to put a uid", so a member's turn has to become its own
process — a change of shape rather than a flag. It is a flag:
sdk.d.ts:951 exposes spawnClaudeCodeProcess, documented for exactly this ("run
Claude Code in VMs, containers, or remote environments"), and node's spawn
already satisfies the SpawnedProcess shape it wants. So no second sidecar, no
PM2 entry, no inverted transport, and none of the registry rework a second
instance would have forced (registration is name-keyed and evicts its
namesake; the nine claude verbs resolve by capability with no selector).
THE PLATFORM NEVER RUNS AS A MEMBER. The tempting reading of "each member runs
their own Claude" is a second officer-agent under their uid, and it is wrong:
that sidecar needs POSTGRES_URL and the JWT signing secret, so a member-uid
process holding them could read every account and sign a token as the owner —
strictly more than their shell can do, and already forbidden by the .env boot
check. The harness stays the service user's; the thing that runs the member's
code and holds the member's credential is theirs. That is the pty sidecar's
shape, not a new one.
PER-MEMBER BINARY, deliberately, over one shared /usr/local/bin/claude. The
private part is the credential, not the executable — but claude updates itself,
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. Same installer the owner's own
install uses, run as them, in their home. Idempotent by skipping when present
rather than re-running: the retry button reprovisions on every press.
ALLOWLIST, NOT A FILTER, for the child's environment. At the moment of the call
the calling process holds POSTGRES_URL, the JWT secret and the owner's
ANTHROPIC_API_KEY; setpriv --reset-env means nothing crosses unless written
into the argv, so an allowlist is the complete answer to what a turn can see,
and a denylist would have to be right about every variable added later.
NEVER_ENV throws rather than leaks if someone widens it.
Login is the member's own act against their own account. The platform cannot do
it for them and must not try — the alternative is lending them the owner's
credential. claudeLoginState only reports whether the credential has appeared,
and reads it as the member, so a true answer means their process can reach it.
NOT VERIFIED: any of it at runtime. tsgo passes; nothing has been provisioned
and the spawn hook has never been called. If it turns out setpriv breaks how
the SDK reaches the process, this approach is wrong and the fallback is the
earlier plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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. Also the first path `claude-manager.ts` probes after `$CLAUDE_BIN`. */
|
||||
export const claudeBinPath = (email: string): string => join(osUserHome(email), '.local', 'bin', 'claude');
|
||||
|
||||
/**
|
||||
* 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 and report only whether it succeeded, with its output for the log. */
|
||||
async function asMember(osUser: string, command: string[]): Promise<{ ok: boolean; out: 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() };
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
// `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`]);
|
||||
|
||||
// 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> {
|
||||
const [installed, loggedIn] = await Promise.all([
|
||||
asMember(params.osUser, ['test', '-x', claudeBinPath(params.email)]),
|
||||
asMember(params.osUser, ['test', '-s', credentialsPath(params.email)]),
|
||||
]);
|
||||
return { installed: installed.ok, loggedIn: loggedIn.ok };
|
||||
}
|
||||
Reference in New Issue
Block a user