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
+3 -2
View File
@@ -129,11 +129,12 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// Titles are resolved here rather than in the client, which can only name the sessions in the group it
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
chatRouter.get('/live', async (ctx) => {
const email = ctx.get('user').email;
const user = ctx.get('user');
const email = user.email;
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
// both registry calls swallow their errors and return [].
const [live, liveOpenCode] = await Promise.all([
sidecar.listLiveClaudeSessions(),
sidecar.listLiveClaudeSessions(user.id),
sidecar.listLiveOpenCodeSessions(),
]);
const sessions = live.map((session) => {
+13 -6
View File
@@ -537,7 +537,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (isClaudeModel(session.model)) {
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
void sidecar.interruptClaude(sessionId);
void sidecar.interruptClaude(sessionId, ws.data.userId);
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
} else {
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
@@ -604,7 +604,7 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
// Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of
// this session" and opens a *second* subscription, which would then deliver every message twice.
session._claudeKill = () => {
if (isClaudeModel(model)) sidecar.killClaude(sessionId);
if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId);
else sidecar.killOpenCode(sessionId);
unsub();
};
@@ -653,7 +653,7 @@ async function handleResumeCursor(
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
// away" into a turn that was running perfectly well.
if (msg.generating && decision.kind !== 'assume') {
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
await endTurnIfAgentIsGone([ws], sessionId, decision.model, ws.data.userId);
}
}
@@ -738,7 +738,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
const { claudeSessionId } = msg;
if (!claudeSessionId) return;
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId);
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId);
if (!sessionId) {
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
// opened from history lands here every time. Stay silent and leave the socket as it was — the next
@@ -774,7 +774,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
// same reason, as `endTurnIfAgentIsGone`.
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId);
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId, ws.data.userId);
session.isGenerating = isGenerating;
// One read serves both answers: the head of the log is the cursor, and folding the whole log gives the
@@ -826,9 +826,10 @@ async function endTurnIfAgentIsGone(
targets: Iterable<ServerWebSocket<WSData> | null>,
sessionId: string,
model: string,
userId: number,
): Promise<void> {
if (!isClaudeModel(model)) return;
if (await sidecar.isClaudeGenerating(sessionId)) return;
if (await sidecar.isClaudeGenerating(sessionId, userId)) return;
const session = sessionManager.getSession(sessionId);
if (session) session.isGenerating = false;
@@ -852,10 +853,16 @@ async function endTurnIfAgentIsGone(
sidecar.onClaudeSidecarStarted(() => {
for (const session of sessionManager.getAllSessions()) {
if (!session.isGenerating) continue;
// No owner recorded, no question asked. `isClaudeGenerating` is now scoped to a caller, and there is no
// safe id to substitute — asking as the owner would let a member's orphaned session be answered with the
// owner's authority, and asking as nobody is not a thing. Leaving it marked generating is the same
// outcome as before this loop existed, and it self-corrects on the next reconnect.
if (session.userId === undefined) continue;
void endTurnIfAgentIsGone(
session.sockets as Set<ServerWebSocket<WSData>>,
session.sessionId,
session.model,
session.userId,
);
}
});