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,235 @@
|
||||
# Handoff — per-user Claude, 2026-08-11
|
||||
|
||||
Branch `sidecar-app-store`, based on `48ed171`. **Three files, uncommitted at the time of writing.** This is
|
||||
the start of item 4 in `2026-08-11-per-user-accounts-handoff.md` §6 ("Per-user Claude"), and it revises two
|
||||
claims that handoff and `docs/per-user-linux-accounts.md` both make.
|
||||
|
||||
Written for the agent on the production host, to read **before** running any of it. Nothing here has been
|
||||
executed against a real member account. Items 1–3 of the earlier handoff (bind mount, terminal replay,
|
||||
`deprovisionOsAccount`) were deliberately skipped on the owner's instruction and are still open.
|
||||
|
||||
---
|
||||
|
||||
## 1. The claim that changed, and why it matters
|
||||
|
||||
`docs/per-user-linux-accounts.md:226-229` says `query()` from `@anthropic-ai/claude-agent-sdk` "spawns
|
||||
`claude` itself and takes `env`/`cwd` but has nowhere to put a uid", concluding that dropping privileges must
|
||||
happen *outside* the SDK, which makes a member's turn "its own process — a change of shape rather than a
|
||||
flag."
|
||||
|
||||
**That is stale.** The installed SDK exposes exactly that hook:
|
||||
|
||||
```
|
||||
node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:951
|
||||
spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess;
|
||||
sdk.d.ts:936 "Custom function to spawn the Claude Code process.
|
||||
Use this to run Claude Code in VMs, containers, or remote environments."
|
||||
```
|
||||
|
||||
`SpawnOptions` (`sdk.d.ts:2006`) supplies `{command, args, cwd, env, signal}`; `SpawnedProcess`
|
||||
(`sdk.d.ts:1965`) wants `{stdin: Writable, stdout: Readable, killed, exitCode, kill(sig), on('exit'|'error')}`
|
||||
— which is what `node:child_process.spawn` returns natively. So the existing sidecar can wrap the CLI spawn in
|
||||
`sudo setpriv` per turn. No second sidecar, no new PM2 entry, no inverted transport.
|
||||
|
||||
**VERIFIED:** the type declarations above, and that `bunx tsgo` passes with an implementation written against
|
||||
them. Installed SDK is **0.2.59**; `package.json:31` pins `^0.2.41`.
|
||||
|
||||
**NOT VERIFIED:** that the hook behaves as documented at runtime. It has never been called. If it turns out
|
||||
the SDK also needs to reach the spawned process in a way `setpriv` breaks, this whole approach is wrong and
|
||||
the fallback is the earlier plan (member turn as its own process).
|
||||
|
||||
Second stale claim, already corrected once: `docs/per-user-linux-accounts.md:230` still says a member's
|
||||
`claude` "needs only `ANTHROPIC_BASE_URL` pointed at the proxy and no key of its own." The previous handoff
|
||||
§5 retracted that. **Both passages need editing; neither has been edited.** Left alone deliberately so this
|
||||
document is the record of what was believed when the code was written, rather than the doc silently agreeing
|
||||
with the code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture: the platform never runs as a member
|
||||
|
||||
The owner's constraint, and the design follows it: **there is never a platform process running under a
|
||||
member's uid.** The platform runs once as the service user and spawns everything.
|
||||
|
||||
This also rules out the tempting reading of "each member runs their own Claude" — a second `officer-agent`
|
||||
under their uid. It does not work, for a reason worth stating because it looks like plumbing and is actually a
|
||||
boundary. That sidecar needs `POSTGRES_URL` (it imports `officerdb` at `user-instance.ts:19`) and the JWT
|
||||
signing secret (it mints a 30-day owner token for the MCP tools at `user-instance.ts:72`). A member-uid
|
||||
process holding those could read every account's data and sign a token as the owner — strictly more than
|
||||
their shell can do. `docs/per-user-linux-accounts.md:60-82` already forbids this shape: `.env` is 600 and
|
||||
`assertSecretsClosed` refuses to boot with `OFFICER_OS_USERS` on while it is group- or world-readable.
|
||||
|
||||
So the split is:
|
||||
|
||||
| | runs as | holds |
|
||||
|---|---|---|
|
||||
| `officer-agent` (the harness) | service user | DB, JWT, MCP config, session log |
|
||||
| `claude` (the member's turn) | **the member** | their binary, their `~/.claude` credential, their home |
|
||||
|
||||
This is the pty sidecar's established shape — one process, per-request identity, privileges dropped at the
|
||||
point the member's code starts (`sidecar/pty/sessions.mjs:85-93`, `:113-125`). It is not a new pattern.
|
||||
|
||||
**It also avoids the registry rework.** A second sidecar would have needed one: registration is name-keyed and
|
||||
a same-named sidecar evicts the incumbent (`sidecar-registry.ts:52-57`), `RegisteredSidecar` has no identity
|
||||
field, and all nine claude verbs resolve by capability with no selector (`sidecar-registry.ts:275-402`).
|
||||
Under the one-sidecar shape none of that has to change.
|
||||
|
||||
---
|
||||
|
||||
## 3. What is in the tree
|
||||
|
||||
### `src/servers/os-user-claude.ts` (new)
|
||||
|
||||
- `provisionClaudeCli({email, osUser})` — runs `curl -fsSL https://claude.ai/install.sh | sh` **as the
|
||||
member**, via `runAs`. Same installer the owner's own install uses (`scripts/setup.sh:853`), chosen there
|
||||
for auto-update support. Per-member binary rather than a shared one is the owner's explicit decision:
|
||||
everything Claude-related is user-specific, and a root-owned binary is one a member cannot update.
|
||||
- Idempotent by **skipping when the binary is present**, not by re-running the installer. The retry button
|
||||
reprovisions on every press; re-downloading would cost a network round trip per press and would quietly move
|
||||
a member off a version they had updated to.
|
||||
- `claudeLoginState({email, osUser})` — `{installed, loggedIn}`. Login marker is
|
||||
`~/.claude/.credentials.json`, **not** `~/.claude.json` (which holds settings/history and appears on first
|
||||
run regardless).
|
||||
- Both checks run **as the member**, not as root. A `true` then means the member's own process can reach those
|
||||
files, which is what the answer gets used to promise. This deviates from the earlier handoff §6.4, which
|
||||
said "detect login by reading `~/.claude` as root" — deliberately.
|
||||
- Never throws; returns a result. Same posture as SSH/shell/Docker in `provisionOsAccount`.
|
||||
|
||||
### `src/servers/sidecar/claude/spawn-as-member.ts` (new)
|
||||
|
||||
The `spawnClaudeCodeProcess` implementation, built on the existing `runAsArgv` (`os-user.ts:95`) — i.e.
|
||||
`sudo -n setpriv --reuid --regid --init-groups --reset-env --`.
|
||||
|
||||
- **Env is an allowlist, not a filter** (`ALLOWED_ENV`). At the moment of the call, the calling process's
|
||||
environment contains `POSTGRES_URL`, the JWT secret and the owner's `ANTHROPIC_API_KEY`. A denylist would
|
||||
have to be right about every variable that exists now and every one added later. `--reset-env` means
|
||||
nothing crosses unless written into the argv (`os-user.ts:120-124`), so the allowlist is the complete
|
||||
answer to "what can this turn see".
|
||||
- `NEVER_ENV` throws rather than leaks if someone later widens the allowlist or the SDK starts merging its own
|
||||
environment into `SpawnOptions.env`.
|
||||
- Sets `HOME` and `CLAUDE_CONFIG_DIR` to the member's home explicitly. `--reset-env` already sets `HOME` from
|
||||
their passwd entry, so `CLAUDE_CONFIG_DIR` is belt — but "which account's credential did this turn use"
|
||||
should be answerable by reading one line.
|
||||
- Uses `node:child_process`, not `Bun.spawn`: its return value satisfies `SpawnedProcess` (Bun gives web
|
||||
streams and no emitter), and `Bun.spawn` silently ignores `uid`/`gid` anyway (`os-user.test.ts:23`).
|
||||
|
||||
### `src/servers/api/users/provision-os.ts` (modified)
|
||||
|
||||
`provisionClaudeCli` called after `seedShellConfig`, before `provisionRootlessDocker`. Non-fatal. Error
|
||||
precedence is now ssh → claude → shell → docker.
|
||||
|
||||
---
|
||||
|
||||
## 4. What is NOT wired — read this before testing
|
||||
|
||||
**`spawn-as-member.ts` is never called.** `claude-manager.ts:350-352` still passes the owner's `CLAUDE_BIN`
|
||||
(`:31`), `HOST_HOME` (`:35`) and `cleanEnv` (`:315`) into `query()`. The remaining work is threading a
|
||||
`MemberRun` through `ClaudeSpawnStreamingParams` and the sidecar protocol so a turn carries whose it is.
|
||||
|
||||
**Therefore both owner gates must stay up:**
|
||||
|
||||
- `src/servers/api/chat/chat.ts:49-53` — HTTP refusal of non-owners
|
||||
- `src/server.tsx:214-217` — chat socket refusal
|
||||
|
||||
Pinned by `capabilities/registry.test.ts:157-170` and `:180-185` so one cannot move without the other.
|
||||
Lifting either now would run a member's turn **as the owner, with the owner's credential** — the exact
|
||||
failure they exist to prevent. Do not lift them to "see if it works".
|
||||
|
||||
Note `chat` is already granted at `write` for every role by default (`f0af723`, seeded at bootstrap), so the
|
||||
permission is live and the route refusal is the only thing standing in the way.
|
||||
|
||||
**Still owner-bound, untouched:**
|
||||
|
||||
- `api/chat/claude-sessions.ts:25` — `claudeHome` is a private copy of the owner-home logic; discards its
|
||||
`email` whenever `HOME_DIR` is set. This is the transcript-history half.
|
||||
- `api/chat/websocket.ts:51-60` — `resolveCwd`/`resolveBaseCwd` expand `~` against `getOwnerHomeDir`.
|
||||
- `workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx:12` — shortens with `/^\/home\/[^/]+/`, which
|
||||
will not match a member's `DATA_PATH/<email>/home`. Cosmetic. The pwd picker itself is already per-caller
|
||||
correct via `DirPickerModal.tsx:16` → `useFilesAPI('home')` → `rootDir`.
|
||||
|
||||
The pattern for all of these is `resolveHomeDir` in `src/servers/user-home.ts:34`, which already exists from
|
||||
the file-browser move.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verified vs assumed
|
||||
|
||||
**VERIFIED on the dev machine:**
|
||||
|
||||
- `bunx tsgo` clean across the project with all three files in place.
|
||||
- `bunx prettier --write` on those three files only (not `bun format` — the tree has other uncommitted work).
|
||||
- The SDK type declarations quoted in §1.
|
||||
- `~/.claude/.credentials.json` exists mode 600 on a logged-in account; `~/.claude.json` exists separately.
|
||||
- `CLAUDE_CONFIG_DIR` is honoured by the bundled CLI (4 occurrences in `cli.js`) and by `sdk.mjs`
|
||||
(`CLAUDE_CONFIG_DIR ?? join(homedir(),'.claude')`). Zero occurrences anywhere in this repo before this
|
||||
change.
|
||||
|
||||
**ASSUMED, NOT TESTED — the whole list:**
|
||||
|
||||
- That `provisionClaudeCli` succeeds for a real member. It needs network egress as the member and a writable
|
||||
home. `--reset-env` means `PATH` comes from their passwd entry; if `curl` is not on that `PATH` the install
|
||||
fails and the account still provisions, reporting the error.
|
||||
- That the installer writes to `~/.local/bin/claude` when run under `setpriv` with a passwd-derived `HOME`.
|
||||
`claudeBinPath` assumes it does. If it picks a different target the install "succeeds" and the `test -x`
|
||||
gate reports failure, which is the safe direction.
|
||||
- That `claudeLoginState` returns `{installed: true, loggedIn: false}` for a freshly provisioned member and
|
||||
flips after they run `claude` once themselves.
|
||||
- That the spawn hook works at all (§1).
|
||||
- Everything about how this behaves with `DATA_PATH` at 711 with ACLs
|
||||
(`docs/per-user-linux-accounts.md:392-415`). Untested for the agent's paths specifically.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions and known risks
|
||||
|
||||
**Network isolation is not solved and is not in scope of this change.** A member's `claude` has general
|
||||
outbound reach — it must, to authenticate — which includes loopback, where the platform's own services
|
||||
listen. `docs/per-user-linux-accounts.md:221-225` already says this plainly: not a sandbox; members are
|
||||
isolated from each other and from accidents, not from the host. The terminal and rootless Docker have the same
|
||||
property today. If "a member cannot reach the others or the outside" is a requirement rather than an
|
||||
aspiration, it is a separate stage (per-uid firewalling or a network namespace) covering all three surfaces,
|
||||
and it wants looking at **on the host that actually runs members** — not on the dev box. I probed the dev box
|
||||
for this and the results say nothing about the deployment; disregard any such finding from me.
|
||||
|
||||
**`curl … | sh` per member sits against this repo's supply-chain stance.** `bunfig.toml` sets
|
||||
`frozenLockfile` precisely so nothing resolves that nobody chose, with the 2026-08-04 npm compromise as the
|
||||
reasoning. This adds an unreviewed network fetch executed once per account creation. It is how the tool ships
|
||||
and what the owner's own install does, and the owner accepted the trade — but it should be a conscious one,
|
||||
not a thing discovered later in a diff.
|
||||
|
||||
**`spawn-as-member.ts` has no test.** It is the one file standing between a member's turn and the owner's
|
||||
credential, and the guarantee is currently a code-read. The test to write is in the shape of
|
||||
`os-user.test.ts`: assert no `NEVER_ENV` name survives into the argv `runAsArgv` produces, and that `HOME` and
|
||||
`CLAUDE_CONFIG_DIR` point inside the member's home.
|
||||
|
||||
**Untested interaction with `claude-manager.ts`'s module-level state.** `CLAUDE_BIN` (`:31`) and `HOST_HOME`
|
||||
(`:35`) are resolved once at import, from the *sidecar process's* environment. Both are wrong for a member and
|
||||
both are what the wiring in §4 has to override per turn, not globally.
|
||||
|
||||
---
|
||||
|
||||
## 7. Running it
|
||||
|
||||
Provisioning lives in the main server, so the change takes effect with:
|
||||
|
||||
```
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`spawn-as-member.ts` is not imported by anything yet, so `officer-agent` does **not** need restarting and
|
||||
restarting it proves nothing about this change.
|
||||
|
||||
To exercise the provisioning half on the server: press the terminal/retry icon on a member's row in
|
||||
Settings → User management (`provisionOsAccount` is idempotent and will run the new step), then as root:
|
||||
|
||||
```bash
|
||||
sudo ls -l ~<member>/.local/bin/claude # expect: present, owned by the member
|
||||
sudo ls -l ~<member>/.claude/.credentials.json # expect: ABSENT until they log in themselves
|
||||
```
|
||||
|
||||
Then have the member open a terminal and run `claude` once to log in with **their own** account. Confirm the
|
||||
credential appears and that `claudeLoginState` flips. Do not log in on their behalf with the owner's account
|
||||
— that is the thing this whole design exists to avoid.
|
||||
|
||||
Expect **no change to chat behaviour for anyone**, member or owner: the gates in §4 are still up and the
|
||||
spawn hook is not called. If a member's chat starts working after this change, something is wrong.
|
||||
@@ -2,6 +2,7 @@ import { updateUser } from 'officerdb';
|
||||
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
|
||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||
import { seedShellConfig } from '@@/os-user-shell';
|
||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||
import { provisionRootlessDocker } from '@@/os-user-docker';
|
||||
import { provisionUserDirs } from '@@/data-path';
|
||||
|
||||
@@ -73,6 +74,13 @@ export async function provisionOsAccount(params: {
|
||||
// work, the terminal opens; it just opens with zsh's bare defaults.
|
||||
const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid });
|
||||
|
||||
// Their own `claude`, in their own home. After the shell because it installs into a home that is not ours
|
||||
// until `ensureOsUser` has chowned it away, and because the installer wants a working HOME.
|
||||
//
|
||||
// Only the binary. Logging in is the member's own act against their own Anthropic account — the platform
|
||||
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
|
||||
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
|
||||
|
||||
// Their own rootless Docker daemon. Last, and the most tolerant of failure: a host without the uidmap
|
||||
// package or a kernel that will not do rootless still gets a perfectly good account, minus containers.
|
||||
//
|
||||
@@ -93,9 +101,17 @@ export async function provisionOsAccount(params: {
|
||||
// Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
|
||||
// which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
|
||||
// in the log.
|
||||
for (const step of [shell, docker] as const) {
|
||||
for (const step of [claude, shell, docker] as const) {
|
||||
if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
|
||||
}
|
||||
const error = !ssh.ok ? ssh.error : !shell.ok ? shell.error : !docker.ok ? docker.error : null;
|
||||
const error = !ssh.ok
|
||||
? ssh.error
|
||||
: !claude.ok
|
||||
? claude.error
|
||||
: !shell.ok
|
||||
? shell.error
|
||||
: !docker.ok
|
||||
? docker.error
|
||||
: null;
|
||||
return { osUser: account.osUser, sshPublicKey, error };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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