Files
platform/COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md
T
pastilhasandClaude Opus 5 ed52faae02 correct two claims about agents that the SDK disproves
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>
2026-08-11 21:33:19 +00:00

14 KiB
Raw Blame History

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 13 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:25claudeHome 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-60resolveCwd/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:16useFilesAPI('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:

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.