docs/per-user-linux-accounts.md carried the reasoning that per-user agents were a large piece of work, and both halves of that reasoning were wrong. The SDK does have somewhere to put a uid — spawnClaudeCodeProcess, documented for running Claude Code in VMs and containers — so a member's turn does not have to become its own process. And the credential claim was backwards: the proxy holds the OWNER'S credential, reading the owner's own ~/.claude/.credentials.json, so pointing a member at it spends the owner's account on the member's turns. The previous handoff had already retracted that one; the doc had not caught up, which is how a retracted claim stays live. Corrected in place rather than deleted, with what was believed and why it was wrong, because the superseded version is the interesting part: the first claim is what made agents look like a later stage than they are. Adds the constraint that actually is out of scope, which the old text never stated: no platform process ever runs as a member, because the sidecar holds POSTGRES_URL and the JWT secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
14 KiB
Markdown
240 lines
14 KiB
Markdown
# 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.
|