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:
@@ -294,7 +294,8 @@ function armIdle(session: PersistentSession): void {
|
||||
armIdle(session);
|
||||
return;
|
||||
}
|
||||
killClaudeSession(session.sessionKey);
|
||||
// The idle GC is this process acting on its own session, so it passes the session's own owner.
|
||||
killClaudeSession(session.sessionKey, session.userId);
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
@@ -494,8 +495,8 @@ export async function spawnClaudeStreaming(
|
||||
}
|
||||
|
||||
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
|
||||
export async function interruptClaudeSession(sessionKey: string): Promise<boolean> {
|
||||
const session = sessions.get(sessionKey);
|
||||
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
|
||||
// consumer loop reads this flag to tell a stop from a fault.
|
||||
@@ -513,8 +514,8 @@ export async function interruptClaudeSession(sessionKey: string): Promise<boolea
|
||||
}
|
||||
|
||||
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
|
||||
export function killClaudeSession(sessionKey: string): boolean {
|
||||
const session = sessions.get(sessionKey);
|
||||
export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
if (session.stallTimer) clearTimeout(session.stallTimer);
|
||||
@@ -532,10 +533,26 @@ export function killClaudeSession(sessionKey: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearSession(sessionKey: string): void {
|
||||
export function clearSession(sessionKey: string, userId: number): void {
|
||||
// Only clears a mapping that is theirs. `getClaudeSession` already refuses a mismatch, so this asks it
|
||||
// first rather than reimplementing the check.
|
||||
if (!getClaudeSession(sessionKey, userId)) return;
|
||||
clearClaudeSession(sessionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live session under this key, **only if it belongs to the caller**.
|
||||
*
|
||||
* Undefined for both "no such session" and "not yours", deliberately: every caller of this treats the two the
|
||||
* same, and a distinct answer for the second would tell a guesser that a session exists under a key they do
|
||||
* not own — which is the whole thing being defended against.
|
||||
*/
|
||||
function ownedSession(sessionKey: string, userId: number): PersistentSession | undefined {
|
||||
const session = sessions.get(sessionKey);
|
||||
if (!session || session.userId !== userId) return undefined;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything this process is holding, with the two facts that decide whether it is busy.
|
||||
*
|
||||
@@ -543,15 +560,19 @@ export function clearSession(sessionKey: string): void {
|
||||
* alone could not distinguish a session mid-turn from one merely open, which is the whole question a
|
||||
* caller has. These are the same two fields `armIdle` consults before collecting a session.
|
||||
*/
|
||||
export function listSessions(): LiveClaudeSession[] {
|
||||
return Array.from(sessions.values()).map((session) => ({
|
||||
sessionKey: session.sessionKey,
|
||||
// The only place this mapping exists. Without it a caller cannot find the transcript, because the
|
||||
// key is officer's handle and the filename is Claude's id.
|
||||
claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
|
||||
isGenerating: session.isGenerating,
|
||||
pendingTasks: session.pendingTasks.size,
|
||||
}));
|
||||
export function listSessions(userId: number): LiveClaudeSession[] {
|
||||
// Filtered, not just labelled. Enumerating every live session is a disclosure on its own, before anyone
|
||||
// acts on one: it names other accounts' conversations and says which are busy.
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.userId === userId)
|
||||
.map((session) => ({
|
||||
sessionKey: session.sessionKey,
|
||||
// The only place this mapping exists. Without it a caller cannot find the transcript, because the
|
||||
// key is officer's handle and the filename is Claude's id.
|
||||
claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
|
||||
isGenerating: session.isGenerating,
|
||||
pendingTasks: session.pendingTasks.size,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,6 +583,8 @@ export function listSessions(): LiveClaudeSession[] {
|
||||
* nothing — and if *this* process was the one that restarted, the session is simply absent and the turn
|
||||
* it was running is gone, however alive the client still believes it to be.
|
||||
*/
|
||||
export function isSessionGenerating(sessionKey: string): boolean {
|
||||
return sessions.get(sessionKey)?.isGenerating ?? false;
|
||||
export function isSessionGenerating(sessionKey: string, userId: number): boolean {
|
||||
// "Not yours" answers the same as "no such session": false. The caller uses this to decide whether to end
|
||||
// a turn it believes is running, and its own turn is the only one it can be right about.
|
||||
return ownedSession(sessionKey, userId)?.isGenerating ?? false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user