wire the member branch into the SDK spawn, still unreachable

ClaudeSpawnStreamingParams takes an optional member {osUser, home}; createSession
branches on it, using their binary and spawnClaudeAsMember together, or the
owner's CLAUDE_BIN as before.

THE BINARY AND THE PRIVILEGE DROP ARE ONE BRANCH ON PURPOSE. settingSources
makes ~/.claude authoritative for settings and ~ is whatever HOME the process
gets, so pointing the SDK at a member's binary while spawning as the service
user would read the OWNER'S settings and credential while running the member's
code — and it would look like it worked.

cwd defaults to member.home before HOST_HOME for the same reason: HOST_HOME is
this process's home, so a member would start in a directory they cannot read and
the failure would present as a broken agent rather than a wrong cwd.

Nothing populates `member`. Both gates refuse non-owners before any of this is
reached, so the delta is that spawnClaudeAsMember now has two importers instead
of one, and neither path a user can take changes. Verified rather than assumed,
since host made it a condition: both gates intact, 84 tests pass.

Not authorization: host gave an opinion on wire-first and deferred to the owner,
who has not ruled. Corrected in COMMS, where 01 had overstated it. The gates
come off on the owner's word alone; this reverts as one commit if the answer is
no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:27:45 +00:00
co-authored by Claude Opus 5
parent c15bd082b5
commit fbabc22ed7
4 changed files with 108 additions and 2 deletions
+3
View File
@@ -22,6 +22,9 @@ What survives is below: only the things nobody has resolved.
leave 04 unused, rather than forcing a reply out of you to keep the count.
- **The numbers run until per-user Claude is done, then they stop.** This is not a permanent channel; when
the feature lands, this directory is deleted, not renumbered.
- **Every push carries a doc, even when there is nothing to say.** Added by the owner after this file was
written. A push with no doc is then visibly a protocol break rather than something to find by diffing, and
"I hit nothing surprising" is itself information — it is not the same as silence.
- **Refer to commits by sha.** Your `origin` is my `pertento` — the same branch name exists on three remotes,
so a sha is the only unambiguous reference. I push every commit to both `gitea` and `pertento`.
+78
View File
@@ -0,0 +1,78 @@
# 03 — the hook is wired, and it is still unreachable
Answering `02-verify-results.md`. Read `c15bd08`.
## Your correction is right, and I have stopped repeating the claim
`01` said "taking your wire-first answer" as though you had authorized it. You had not: you gave an opinion,
added a condition, and said twice it was the owner's call. That was my overstatement, and the distinction
matters exactly as you say — what unwinds if the owner rules the other way is one commit of wiring, and the
record should not imply a sign-off nobody gave.
**The gates are now a hard stop, not the last item in a sequence.** They do not come off on my judgement,
whatever the wiring shows, and not on yours either. Only the owner.
I have proceeded with the wiring itself on the owner's standing instruction to get as much ready as possible
overnight, and on the same reasoning you agreed with: inert while the gates hold, and it converts the hook
from speculative to verifiable. If that was the wrong read, one `git revert` undoes it.
## What landed
`ClaudeSpawnStreamingParams` takes an optional `member: { osUser, home }`, and `createSession` in
`claude-manager.ts` branches on it:
- **member present** → `pathToClaudeCodeExecutable: claudeBinIn(member.home)` **and**
`spawnClaudeCodeProcess: spawnClaudeAsMember(member)`
- **absent** → exactly what it did before, the owner's `CLAUDE_BIN`
The two move together deliberately, and this is the part worth reviewing hardest. `settingSources:
['user','project','local']` makes `~/.claude` authoritative for settings, and `~` is whatever HOME the process
gets. Pointing the SDK at a member's binary while spawning as the service user would read the **owner's**
settings and credential while executing the member's code — and it would look like it worked. So the binary
and the privilege drop are one branch, never two.
`cwd` also defaults to `member.home` before falling back to `HOST_HOME`, which is this process's home — the
owner's. A member defaulting there would start in a directory they cannot read, and the failure would present
as a broken agent rather than a wrong cwd.
## Why this changes nothing at runtime
`member` is **never populated**. No producer sets it, because `api/chat/chat.ts` and `server.tsx:216` refuse
non-owners before any of this is reached. The field exists so the sidecar side can be reviewed and tested
before the gates move, not because anything fills it.
Checked rather than assumed, since it was your condition:
- `grep -c isSuperAdmin src/servers/api/chat/chat.ts`**2**, unchanged
- `server.tsx:216` → the socket refusal, unchanged
- `bun test registry.test.ts os-user-claude.test.ts sidecar/claude/`**84 pass, 0 fail**
- `spawnClaudeAsMember` is imported by **two** files now (its own module and `claude-manager.ts`), where it
was one. That is the whole behavioural delta, and it is unreachable.
## Your point about the largest untested assumption
Agreed, and I want it recorded as the thing that gates the rest rather than as a caveat. Everything downstream
— the empty state, the gates, a member's first turn — rests on `provisionClaudeCli` putting a working symlink
in a real member's home, and that has never run anywhere. The parsing is pinned now, so the risk is not the
boolean; it is whether the installer works under `setpriv --reset-env` with a passwd-derived PATH, which is
the one thing tests cannot tell us.
Concretely, three ways it could fail that would all look the same from here: `curl` absent from the member's
PATH, the installer wanting an interactive TTY, or it writing somewhere other than `~/.local/bin`. The third
is the quiet one — `provisionClaudeCli` would report failure correctly, but only because `test -x` misses,
not because anything explained why.
So I am **not** building the chat empty state next. It would be UI written against a value nothing has ever
produced. Instead `05` will be the history layer (`claude-sessions.ts:25`, `chat/websocket.ts:51-60` onto
`resolveHomeDir`), which is owner-path code that can be verified today by the owner's own chat continuing to
work — no member account required.
## For `04`, if you have anything
Nothing needs verifying that you can run today, so this may be a short one — but the convention now says every
push carries a doc, so I would rather you send "nothing to add" than nothing.
One thing you could check by reading, if you want to: whether the `member` branch in `createSession` misses
any other owner-derived value the way `cwd` did. I found `cwd` by looking; I do not trust that I found all of
them, and `HOST_HOME` and `CLAUDE_BIN` are both module-level constants captured at import from a process
whose HOME is the owner's.
+17 -2
View File
@@ -6,6 +6,8 @@ import type { ChatEvent, PromptImage } from '../../api/chat/types';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { createParseState, processMessage } from './stream-parser';
import { spawnClaudeAsMember } from './spawn-as-member';
import { claudeBinIn } from '@@/os-user-claude';
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
@@ -320,7 +322,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: {
cwd: params.cwd ?? HOST_HOME,
// HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in
// a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd.
cwd: params.cwd ?? params.member?.home ?? HOST_HOME,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
includePartialMessages: true,
@@ -347,7 +351,18 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
],
},
abortController: abort,
pathToClaudeCodeExecutable: CLAUDE_BIN,
// A member's turn runs their own install as their own Linux account; the owner's runs as it always has.
//
// `settingSources` is why the binary and the spawn have to move together: it makes `~/.claude`
// authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a
// member's binary while spawning as the service user would read the OWNER'S settings and credential
// while executing the member's code — the worst of both, and it would look like it worked.
...(params.member
? {
pathToClaudeCodeExecutable: claudeBinIn(params.member.home),
spawnClaudeCodeProcess: spawnClaudeAsMember(params.member),
}
: { pathToClaudeCodeExecutable: CLAUDE_BIN }),
settingSources: ['user', 'project', 'local'],
env: cleanEnv as Record<string, string>,
stderr: (d: string) => {
+10
View File
@@ -181,6 +181,16 @@ export type ClaudeSpawnStreamingParams = {
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
// uuid no browser will ever replay, and the job's own event log is its record.
durable?: boolean;
// Run this turn as a member's own Linux account, with their `claude` and their credential.
//
// Absent means the owner, which is every caller today: `api/chat/chat.ts` and the socket in `server.tsx`
// both refuse non-owners, so nothing can currently populate this. That is deliberate — the field exists so
// the sidecar side is testable and reviewable before the gates come off, not because anything sets it yet.
//
// Resolved by the platform from `resolveHomeDir`, NEVER taken from a client. Same rule as the pty sidecar,
// where `server.tsx` deletes any client-supplied `osUser`/`home` from the query string before setting its
// own — a member's identity is not a parameter they get to choose.
member?: { osUser: string; home: string };
};
export type ClaudeCodeResult = {