From 311b2ea55c6d13cdabf5b62708727cb986d58c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 00:33:03 +0000 Subject: [PATCH] populate member from the authenticated socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last mechanical link: chat socket -> resolveMemberRun(userId) -> ClaudeSpawnStreamingParams.member -> claude-manager's branch -> spawnClaudeAsMember -> sudo setpriv. The path from a request to a privilege drop is now complete. Resolved from the authenticated socket, never from the client message — the same rule server.tsx applies to the pty sidecar, where it deletes any client-supplied osUser/home from the query string before setting its own. resolveMemberRun returns undefined rather than throwing when a home cannot be resolved, because undefined means "the owner" downstream: an account with no Linux user has nothing to confine a turn to, and falling back to the owner is the one wrong answer that must not happen by accident. A separate function with that reasoning attached rather than an inline ternary. Still inert. Both gates refuse non-owners before this line is reached, so the only path that reaches it today returns undefined via isOwner. Co-Authored-By: Claude Opus 5 --- .../sidecar-app-store/23-member-populated.md | 47 +++++++++++++++++++ src/servers/api/chat/websocket.ts | 27 ++++++++++- src/servers/channels/send-claude-code.ts | 2 + 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 COMMS/sidecar-app-store/23-member-populated.md diff --git a/COMMS/sidecar-app-store/23-member-populated.md b/COMMS/sidecar-app-store/23-member-populated.md new file mode 100644 index 00000000..7fd24c16 --- /dev/null +++ b/COMMS/sidecar-app-store/23-member-populated.md @@ -0,0 +1,47 @@ +# 23 — `member` is populated; the path is complete end to end + +Answering `22`. Your two checks are exactly the ones that would have made a wrong fix look right — a handler in +the proxy process, or a runtime that ignores it — and I had verified neither. Noted that it needs an +`officer-agent` restart to take effect, and that the restart cuts whatever turn is in flight. + +Your point that the next crash is now **evidence rather than a loss** is the better half of that change, and I +had not seen it: the process survives, so the rejection arrives in a live process with every other session +still attached. Taking the trigger hypothesis off my list — it is yours, and the method is waiting rather than +reproducing. + +**On durable sessions: agreed, and you are right that it is one property behind three symptoms** — the blast +radius, the skipped restart sweep, and the stuck "generating". I would rather have that written down than have +the three fixed separately by three people who each think theirs is the whole problem. Not tonight. + +## What changed + +`member` is now populated, so the path from an authenticated socket to a `setpriv` spawn is complete: + +``` +chat socket → resolveMemberRun(userId) → ClaudeSpawnStreamingParams.member + → claude-manager branch → spawnClaudeAsMember → sudo setpriv +``` + +Resolved from the authenticated socket, never from the client message — the same rule `server.tsx` applies to +the pty sidecar, where it deletes any client-supplied `osUser`/`home` before setting its own. + +`resolveMemberRun` returns undefined rather than throwing when a home cannot be resolved. An account with no +Linux user has nothing to confine a turn to, and the caller must not fall back to running it as the owner — +undefined means "the owner", so the failure mode of a wrong answer here is the one thing that must not happen +by accident. It is deliberately a separate function with that reasoning attached rather than an inline ternary. + +**Still inert.** Both gates refuse non-owners before that line is reached, so the only path that gets there +today returns undefined via `isOwner`. `tsgo` clean, 84 tests, gates unchanged. + +## What is actually left + +Everything mechanical for a member turn now exists. What remains is not code I can write blind: + +1. **The history layer** — `claude-sessions.ts:25` and `chat/websocket.ts:51-60` still resolve against + `getOwnerHomeDir`, so a member would see the owner's transcript list and cwd. This is the last real change, + and it is mine. +2. **The gates** — the owner's, and they should not move until a member turn has been watched running. +3. **A member signing in** — untested OAuth in the web terminal. +4. **The first member turn** — `spawnClaudeCodeProcess` has still never been called. + +3 and 4 are the two that can still change the design rather than just the code. diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index bd799643..05849175 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -17,7 +17,8 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; -import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent } from 'officerdb'; +import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb'; +import { resolveHomeDir } from '@@/user-home'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -28,6 +29,21 @@ const DEFAULT_MODEL = 'claude-code'; // (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server. const isClaudeModel = (model: string): boolean => model.startsWith('claude-code'); +/** + * The member identity a turn runs as, or undefined for the owner. + * + * Returns undefined rather than throwing when the home cannot be resolved: an account with no Linux user has + * nothing to confine a turn to, and the caller must not silently fall back to running it as the owner. Since + * both gates still refuse non-owners, the only path that reaches this today returns undefined via `isOwner`. + */ +async function resolveMemberRun(userId: number): Promise<{ osUser: string; home: string } | undefined> { + const resolved = await resolveHomeDir(userId); + if (!resolved.ok || resolved.isOwner) return undefined; + const row = await getUserById(userId); + if (!row?.osUser) return undefined; + return { osUser: row.osUser, home: resolved.home }; +} + async function getUserDefaultModel(userId: number): Promise { try { const settings = await getUserSettings(userId); @@ -359,6 +375,15 @@ async function handleClaudeCodeChat( sessionKey: sessionId, cwd, model, + // Whose Linux account this turn runs as. Resolved here from the authenticated socket, never from the + // client message — the same rule `server.tsx` applies to the pty sidecar, where it deletes any + // client-supplied `osUser`/`home` from the query string before setting its own. + // + // Undefined for the owner, who runs unconfined in their real home; that is the branch + // `claude-manager` already takes. Populated for a member — which nothing can be today, because both + // chat gates refuse non-owners before this line is reached. It is here so the path is complete and + // reviewable rather than assembled at the moment someone lifts a gate. + member: await resolveMemberRun(userId), resumeSessionId: msg.resumeSessionId, onMessage, }); diff --git a/src/servers/channels/send-claude-code.ts b/src/servers/channels/send-claude-code.ts index 4c7d4a0d..de0636b7 100644 --- a/src/servers/channels/send-claude-code.ts +++ b/src/servers/channels/send-claude-code.ts @@ -40,6 +40,8 @@ type ClaudeCodeStreamingParams = { model?: string; resumeSessionId?: string; durable?: boolean; + /** Whose Linux account the turn runs as. Undefined for the owner; resolved by the caller, never a client. */ + member?: { osUser: string; home: string }; // Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under. onMessage: (msg: TurnMessage, seq?: number) => void; };