populate member from the authenticated socket
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -17,7 +17,8 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
|||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-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 { mkdirSync } from 'node:fs';
|
||||||
import { logger } from './logger';
|
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.
|
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
||||||
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
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<string | null> {
|
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const settings = await getUserSettings(userId);
|
const settings = await getUserSettings(userId);
|
||||||
@@ -359,6 +375,15 @@ async function handleClaudeCodeChat(
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
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,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
onMessage,
|
onMessage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ type ClaudeCodeStreamingParams = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
durable?: boolean;
|
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.
|
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
|
||||||
onMessage: (msg: TurnMessage, seq?: number) => void;
|
onMessage: (msg: TurnMessage, seq?: number) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user