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,138 @@
|
||||
import type { SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { runAsArgv } from '@@/os-user';
|
||||
|
||||
// Running a member's agent turn as the member, without a second sidecar.
|
||||
//
|
||||
// ── Why the sidecar stays as the service user and only the CLI drops privileges ──
|
||||
//
|
||||
// The obvious reading of "each member runs their own Claude" is a second `officer-agent` running under their
|
||||
// uid. That does not work, and the reason is worth writing down because it looks like an implementation
|
||||
// detail and is actually a boundary. This sidecar needs `POSTGRES_URL` (it imports `officerdb`) and the JWT
|
||||
// signing secret — it mints a 30-day owner token for the MCP tools. A process running as a member with those
|
||||
// two values in its environment can read every account's data and sign a token as the owner, which is
|
||||
// strictly more than a member's shell can do. `docs/per-user-linux-accounts.md` already forbids exactly this:
|
||||
// `.env` is 600 and a boot check refuses to start with OS users enabled while it is readable.
|
||||
//
|
||||
// So the split is: the platform glue stays the service user's, and the thing that runs the member's code and
|
||||
// holds the member's credential — `claude` itself — is theirs. The member's agent process genuinely is their
|
||||
// own, in their home, with their login and their binary. Only the harness around it is shared, and the
|
||||
// harness is the part that must not be.
|
||||
//
|
||||
// This is the pty sidecar's shape, which is the established one here: one process, per-request identity,
|
||||
// privileges dropped at the point where the member's code starts (`sidecar/pty/sessions.mjs:85-93`).
|
||||
//
|
||||
// ── Why an allowlist and not a filter ──
|
||||
//
|
||||
// `runAsArgv` uses `setpriv --reset-env`, so nothing crosses into the member's process unless it is written
|
||||
// into the argv (`os-user.ts:120-124`). That is the whole safety property here, and it points one way: build
|
||||
// the child's environment from an allowlist rather than by subtracting the dangerous names from this
|
||||
// process's. A denylist has to be right about every variable that exists now and every one added later —
|
||||
// including `POSTGRES_URL`, the JWT secret, and the owner's `ANTHROPIC_API_KEY`, all of which are sitting in
|
||||
// this process's environment as it makes this call. An allowlist is wrong in the direction that fails safe.
|
||||
|
||||
/** Everything needed to run a turn as one member. Resolved by the platform, never taken from a client. */
|
||||
export type MemberRun = {
|
||||
/** Their Linux account, from `users.osUser`. */
|
||||
osUser: string;
|
||||
/** Their home — `DATA_PATH/<email>/home`, per `resolveHomeDir`. */
|
||||
home: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The variables a member's `claude` is allowed to inherit.
|
||||
*
|
||||
* Deliberately short. `setpriv --init-groups --reset-env` already supplies HOME, USER, LOGNAME, SHELL and
|
||||
* PATH from their passwd entry, so this list is only what the harness itself needs on top of that.
|
||||
*/
|
||||
const ALLOWED_ENV = ['LANG', 'LC_ALL', 'TERM', 'TZ', 'NO_COLOR', 'CLAUDE_CODE_ENTRYPOINT'] as const;
|
||||
|
||||
/**
|
||||
* Names that must never reach a member's process, asserted rather than assumed.
|
||||
*
|
||||
* Redundant with the allowlist by construction — which is the point. If someone later widens the allowlist,
|
||||
* or the SDK starts merging its own environment into `SpawnOptions.env`, this is what turns a silent
|
||||
* credential leak into a thrown error at the spawn site. The Anthropic three are the owner's proxy
|
||||
* (`user-instance.ts:148-171`); the other two are what make this process more privileged than a shell.
|
||||
*/
|
||||
const NEVER_ENV = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
|
||||
'POSTGRES_URL',
|
||||
'JWT_SECRET',
|
||||
];
|
||||
|
||||
/** Their own install, in their own home. Not this process's `CLAUDE_BIN`, which is the owner's. */
|
||||
export const memberClaudeBin = (home: string): string => join(home, '.local', 'bin', 'claude');
|
||||
|
||||
/**
|
||||
* The `claude` config directory for a member.
|
||||
*
|
||||
* `--reset-env` already sets HOME to their home, so `~/.claude` would resolve correctly on its own. This is
|
||||
* set explicitly anyway because "which account's credential did this turn use" is the single most important
|
||||
* question in this file, and it should be answerable by reading one line rather than by reasoning about what
|
||||
* `setpriv` does to HOME.
|
||||
*/
|
||||
export const memberClaudeConfigDir = (home: string): string => join(home, '.claude');
|
||||
|
||||
/** Build the child environment for a member's turn: allowlist in, everything else absent. */
|
||||
function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const name of ALLOWED_ENV) {
|
||||
const value = inherited[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
env.HOME = run.home;
|
||||
env.CLAUDE_CONFIG_DIR = memberClaudeConfigDir(run.home);
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's spawn hook, bound to one member.
|
||||
*
|
||||
* `spawnClaudeCodeProcess` (`sdk.d.ts:951`, "use this to run Claude Code in VMs, containers, or remote
|
||||
* environments") is what makes this possible at all. The plan of record assumed the SDK had nowhere to put a
|
||||
* uid and that a member's turn therefore had to become its own process — a change of shape rather than a
|
||||
* flag. It is a flag.
|
||||
*
|
||||
* `node:child_process` rather than `Bun.spawn`, for two reasons: its return value already satisfies
|
||||
* `SpawnedProcess` (a Writable stdin, a Readable stdout, `kill`, `on('exit')`), which Bun's does not — Bun
|
||||
* gives web streams and no emitter — and `Bun.spawn` silently ignores `uid`/`gid` anyway, which is why
|
||||
* `runAs` exists and why `os-user.test.ts` pins that behaviour.
|
||||
*/
|
||||
export function spawnClaudeAsMember(run: MemberRun): (options: SpawnOptions) => SpawnedProcess {
|
||||
return ({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess => {
|
||||
const childEnv = memberEnv(run, env);
|
||||
|
||||
const leaked = NEVER_ENV.filter((name) => name in childEnv);
|
||||
if (leaked.length) {
|
||||
throw new Error(`refusing to run ${run.osUser}'s agent with owner credentials in env: ${leaked.join(', ')}`);
|
||||
}
|
||||
|
||||
// `env K=V …` inside the argv, because `--reset-env` clears anything handed to `setpriv` itself. This is
|
||||
// the only channel through which a variable can reach the member's process, which is what makes the
|
||||
// allowlist above the complete answer to "what can this turn see".
|
||||
const assignments = Object.entries(childEnv).map(([name, value]) => `${name}=${value}`);
|
||||
const [launcher, ...launcherArgs] = runAsArgv(run.osUser, ['env', ...assignments, command, ...args]);
|
||||
// Unreachable — `runAsArgv` always returns `sudo` first and throws on an empty command. Written rather
|
||||
// than asserted away because the alternative under `noUncheckedIndexedAccess` is a default that would
|
||||
// silently pick a launcher, and a wrong launcher here means a turn running as the wrong user.
|
||||
if (!launcher) throw new Error('runAsArgv returned an empty argv');
|
||||
|
||||
const child = spawn(launcher, launcherArgs, {
|
||||
// Their home, not this process's. A cwd outside it would be readable by the turn only if the member
|
||||
// could read it anyway — the kernel is the check here, not this line — but defaulting to their home is
|
||||
// what makes an unqualified turn behave like their shell.
|
||||
cwd: cwd ?? run.home,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
signal,
|
||||
});
|
||||
|
||||
// Non-null by construction: 'pipe' on all three above. The SDK's interface wants them non-nullable and
|
||||
// node types them as possibly-null because other stdio modes exist.
|
||||
return child as unknown as SpawnedProcess;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user