diff --git a/COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md b/COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md new file mode 100644 index 00000000..d2f623ad --- /dev/null +++ b/COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md @@ -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//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 ~/.local/bin/claude # expect: present, owned by the member +sudo ls -l ~/.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. diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index dbd9ff82..ff1074b2 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -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 }; } diff --git a/src/servers/os-user-claude.ts b/src/servers/os-user-claude.ts new file mode 100644 index 00000000..4b282487 --- /dev/null +++ b/src/servers/os-user-claude.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/servers/sidecar/claude/spawn-as-member.ts b/src/servers/sidecar/claude/spawn-as-member.ts new file mode 100644 index 00000000..c7e32b82 --- /dev/null +++ b/src/servers/sidecar/claude/spawn-as-member.ts @@ -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//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): Record { + const env: Record = {}; + 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; + }; +}