never spell "I don't know whose turn this is" as "the owner"
host caught that resolveMemberRun failed open. Returning undefined means "run as the server owner" downstream — their binary, their ~/.claude credential, their HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs produced it: the caller being the owner, resolveHomeDir failing, and a member whose osUser is null. The last two mean "could not determine", and answering them with the owner's identity is the single thing this feature exists to prevent. 23's own comment said the caller must not fall back to the owner. The code did exactly that. The prose was right. Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The call site ends the turn on refuse instead of spawning. The owner's identity is reachable only by positively establishing isOwner, never by failing to establish anything else — resolveHomeDir already reported it as a positive fact and the funnel through undefined was the only thing discarding it. The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at every stage and records the account either way, as its own source says. Tonight provisioning failed three separate ways on a real member and the account survived each time. No test yet, and the reason is in COMMS rather than hidden: it needs database fakes this repo has no pattern for, and inventing one at 01:00 to cover four branches is how the next defect gets written. The union is exhaustive, so tsgo catches a missing case — not the same thing, not nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,18 +30,37 @@ const DEFAULT_MODEL = 'claude-code';
|
||||
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
||||
|
||||
/**
|
||||
* The member identity a turn runs as, or undefined for the owner.
|
||||
* Whose identity a turn runs as.
|
||||
*
|
||||
* 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`.
|
||||
* A three-way answer rather than a nullable one, because `undefined` downstream means **the owner** — their
|
||||
* binary, their `~/.claude` credential, their HOME, and their MCP config carrying `OFFICER_AUTH_TOKEN`. A
|
||||
* nullable return collapsed three inputs into that: the caller genuinely being the owner, `resolveHomeDir`
|
||||
* failing, and a member whose `osUser` is null. The last two mean "I could not determine whose this is", and
|
||||
* answering them with the owner's identity is the one wrong answer this whole feature exists to prevent.
|
||||
*
|
||||
* Case three is not hypothetical: `provisionOsAccount` is non-fatal at every stage and records the account
|
||||
* either way, so a member whose Linux provisioning failed exists as a row with no `osUser`. On the night this
|
||||
* was written, provisioning failed three separate ways on a real member while the account continued to exist.
|
||||
*
|
||||
* The property: the owner's identity is reachable only by positively establishing that the caller IS the
|
||||
* owner, never by failing to establish anything else. `resolveHomeDir` already reports `isOwner` as a positive
|
||||
* fact — the old funnel through `undefined` was the only thing throwing it away.
|
||||
*/
|
||||
async function resolveMemberRun(userId: number): Promise<{ osUser: string; home: string } | undefined> {
|
||||
type TurnIdentity =
|
||||
| { kind: 'owner' }
|
||||
| { kind: 'member'; run: { osUser: string; home: string } }
|
||||
| { kind: 'refuse'; reason: string };
|
||||
|
||||
async function resolveTurnIdentity(userId: number): Promise<TurnIdentity> {
|
||||
const resolved = await resolveHomeDir(userId);
|
||||
if (!resolved.ok || resolved.isOwner) return undefined;
|
||||
if (!resolved.ok) return { kind: 'refuse', reason: resolved.reason };
|
||||
if (resolved.isOwner) return { kind: 'owner' };
|
||||
|
||||
const row = await getUserById(userId);
|
||||
if (!row?.osUser) return undefined;
|
||||
return { osUser: row.osUser, home: resolved.home };
|
||||
if (!row?.osUser) {
|
||||
return { kind: 'refuse', reason: 'your Linux account is not provisioned yet, so an agent cannot run as you' };
|
||||
}
|
||||
return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } };
|
||||
}
|
||||
|
||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||
@@ -361,6 +380,16 @@ async function handleClaudeCodeChat(
|
||||
const onMessage = createMessageHandler(sessionId, model);
|
||||
|
||||
try {
|
||||
// Whose account this turn runs as, resolved from the authenticated socket and never from the client
|
||||
// message — the same rule `server.tsx` applies to the pty sidecar. A `refuse` ends the turn here rather
|
||||
// than spawning anything, because the alternative to knowing is not "assume the owner".
|
||||
const identity = await resolveTurnIdentity(userId);
|
||||
if (identity.kind === 'refuse') {
|
||||
sendToClient(ws, { type: 'error', message: identity.reason });
|
||||
session.isGenerating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session._claudeKill) {
|
||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
||||
@@ -375,15 +404,7 @@ 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),
|
||||
member: identity.kind === 'member' ? identity.run : undefined,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onMessage,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user