# 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 retracted once by the previous handoff §5:
`docs/per-user-linux-accounts.md:230` said a member's `claude` "needs only `ANTHROPIC_BASE_URL` pointed at the
proxy and no key of its own." That is backwards — the proxy holds the **owner's** credential
(`sidecar/claude/proxy.ts:7` reads the owner's own `~/.claude/.credentials.json`), so pointing a member at it
spends the owner's account on their turns.

**Both passages have now been corrected in `docs/per-user-linux-accounts.md`** (same commit range as the code).
The superseded text is in that file's git history. If you are reading this handoff against an older checkout,
the doc is the one that moved.

---

## 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.
