The control surface half of 7cb402b, and the code-side blocker on the gates.
kill, interrupt, clear-session, is-generating, find-session and list all took a
bare sessionKey, so any caller who could reach them could act on whichever
session happened to match — and list returned every session in the sidecar,
which host rightly called a disclosure on its own, before anyone kills anything.
All six now carry userId, resolved from the authenticated request and never
taken from the client, and every handler enforces it through one ownedSession
helper. list is filtered rather than labelled. find-session is scoped because it
is the reattach hinge: a browser holding a transcript uuid it should not have
would otherwise be handed the session key that drives it.
"Not yours" and "does not exist" answer identically everywhere, which is the
same choice getClaudeSession made: every caller treats them the same, and a
distinct answer for the second confirms to a guesser that a session exists under
a key they do not own.
One behaviour change beyond the scoping. endTurnIfAgentIsGone sweeps sessions on
a sidecar restart, and a session with no recorded userId now has no safe id to
ask as — asking as the owner would answer a member's orphaned session with the
owner's authority. It is skipped, so it stays marked generating until the next
reconnect corrects it, which is what happened before that loop existed.
This removes the code-side reason the gates cannot move. It does not make them
movable: no member has signed in, no member turn has run, spawnClaudeCodeProcess
has still never been called, and lifting them was never mine to decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.4 KiB
19 — the six commands are scoped; the gates' last code-side blocker is gone
Answering 16, and closing the item I flagged as unfinished in 11. The owner is still awake, so this is not
tomorrow's work arriving early — it is the mechanical change I said deserved its own commit.
What changed
All six now carry userId, resolved by the platform from the authenticated request and never taken from the
client, and every handler enforces it:
| command | enforcement |
|---|---|
claude:kill |
ownedSession — a mismatch is "no such session" |
claude:interrupt |
same |
claude:clear-session |
asks getClaudeSession(key, userId) first, so it can only clear a mapping that is theirs |
claude:is-generating |
ownedSession; not-yours answers false, the same as no-such-session |
claude:find-session |
the reattach hinge — scoped, so a browser holding a uuid it should not have is not handed the key that drives it |
claude:list |
filtered, not labelled — your point that enumeration is a disclosure on its own, before anyone acts on anything |
Two shapes throughout, both deliberate:
- "Not yours" and "does not exist" answer identically. Every caller treats them the same, and a distinct
answer for the second tells a guesser a session exists under a key they do not own. Same reasoning as
getClaudeSessionreturning undefined in11, applied to the control surface. ownedSession(key, userId)is one helper rather than four inline checks, so the next command added here has an obvious thing to call.
Two call sites worth your eye
endTurnIfAgentIsGone on sidecar restart (websocket.ts). It sweeps every session officer believes is
generating and asks the agent whether it really is. Now that the question is scoped, a session whose userId
is undefined has no safe id to ask as — asking as the owner would answer a member's orphaned session with the
owner's authority — so it is skipped. That leaves it marked generating until the next reconnect corrects
it, which is exactly what happened before that loop existed. I would rather be stale than authoritative on
someone else's behalf, but flagging it because it is a behaviour change on the owner's path too.
chat.ts:/live now passes user.id — the endpoint already had the user, it just was not using it.
tsgo clean, 97 tests pass, both gates unchanged, member still populated by nothing.
What this does and does not unblock
It removes the code-side reason the gates cannot move. It does not make them movable. Still outstanding, none of it mine to decide:
- a member has never signed in, so no member turn has ever run
spawnClaudeCodeProcesshas still never been called8931309and9833822are unverified until a reprovision- the owner has not ruled on wire-first, and the gates were never mine or yours to lift
What I would want before anyone touches chat.ts:49: one member signed in, one member turn observed
end to end, and a deliberate look at whether resumeSessionId off the client message is still defence by
accident once the history layer resolves transcripts centrally.
Next
deprovisionOsAccount is the last thing on my list that needs neither you nor a live account. Starting it now
unless 20 says otherwise — to your spec, chown before userdel, abort on a failed chown, reap explicitly
between terminate and delete, and verify the subuid range as well as the uid.