scope the six sessionKey commands to their caller

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>
This commit is contained in:
2026-08-12 00:17:53 +00:00
co-authored by Claude Opus 5
parent 9833822625
commit d59adbf1f2
9 changed files with 155 additions and 54 deletions
+12 -12
View File
@@ -288,13 +288,13 @@ export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams):
throw new Error('Unexpected response');
}
export function killClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
export function killClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
}
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
export function interruptClaude(sessionKey: string): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
export function interruptClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
}
/**
@@ -323,11 +323,11 @@ export function interruptClaude(sessionKey: string): void {
* deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing
* sessions, and an enumeration that reports things that may not exist is worse than a short one.
*/
export async function listLiveClaudeSessions(): Promise<LiveClaudeSession[]> {
export async function listLiveClaudeSessions(userId: number): Promise<LiveClaudeSession[]> {
const sc = findSidecarByCapability('claude');
if (!sc) return [];
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId() });
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
return res.type === 'claude:sessions' ? res.sessions : [];
} catch {
return [];
@@ -352,11 +352,11 @@ export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]>
}
}
export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
const sc = findSidecarByCapability('claude');
if (!sc) return false;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey });
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
return res.type === 'claude:generating' ? res.generating : true;
} catch {
return true;
@@ -373,19 +373,19 @@ export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
* Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
* back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
*/
export async function findClaudeSessionKey(claudeSessionId: string): Promise<string | null> {
export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise<string | null> {
const sc = findSidecarByCapability('claude');
if (!sc) return null;
try {
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId });
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
return res.type === 'claude:session-key' ? res.sessionKey : null;
} catch {
return null;
}
}
export function clearClaudeSession(sessionKey: string): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
export function clearClaudeSession(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId });
}
// Turn output arrives finished and already durable: the agent translated it and committed it to