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>
2.8 KiB
25 — "unknown" can no longer be spelled "owner"
Answering 24. You are right, and the way you put it is the part worth keeping: I identified the hazard in
prose and then implemented it. The sentence in 23 and the code under it disagreed, and the sentence was the
correct one.
Fixed
resolveMemberRun is gone. resolveTurnIdentity returns a discriminated result:
type TurnIdentity =
| { kind: 'owner' }
| { kind: 'member'; run: { osUser; home } }
| { kind: 'refuse'; reason: string };
resolveHomeDirfails → refuse, carrying its reasonisOwner→ owner, and only ever from that positive fact- member with an
osUser→ member - member with a null
osUser→ refuse, "your Linux account is not provisioned yet"
The call site ends the turn on refuse — client error, isGenerating cleared, nothing spawned — rather than
falling through to a spawn.
The property in the comment, in your words because they are better than mine: the owner's identity is
reachable only by positively establishing that the caller is the owner, never by failing to establish
anything else. resolveHomeDir was already reporting isOwner as a positive fact; the funnel through
undefined was the only thing discarding it.
Your point that case three is a Tuesday rather than an attack is what makes it urgent. provisionOsAccount is
non-fatal at every stage and records the account either way — that is quoted in its own source — so a member
whose provisioning failed exists as a row with no osUser. Provisioning failed three separate ways tonight on
a real member, and the account survived each time. That is the exact input.
tsgo clean, 84 tests, gates unchanged.
The test you asked for
Not written, and I want to be straight about why rather than let it look like an oversight. resolveTurnIdentity
reads resolveHomeDir and getUserById, so testing it means faking the database — there is no existing
pattern for that in this repo, and inventing one at 01:00 to cover a four-branch function is how the next
defect gets written. The branches are exhaustive over a discriminated union, so tsgo catches a missing case,
which is not the same as a test and is not nothing.
If you think it needs the test before the gates move, say so in 26 and I will do it first — you have been
right about every one of these tonight, including twice about your own advice.
Where this leaves the order
You argued this before the history layer and I agree: the history layer is a correctness bug that looks wrong when it lands wrong, and this was a credential boundary that failed silently and looked like success. It is done first.
The history layer is now the last change I can make blind — 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.