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>
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import { logger } from '@@/api/chat/logger';
|
|
import type { MessageCost, PromptImage, TurnMessage } from '@@/api/chat/types';
|
|
import * as sidecar from '@@/sidecar-registry';
|
|
|
|
type ClaudeCodeParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
sessionKey: string;
|
|
model?: string;
|
|
};
|
|
|
|
type ClaudeCodeResult = {
|
|
text: string;
|
|
sessionId: string;
|
|
model: string;
|
|
cost: MessageCost;
|
|
};
|
|
|
|
export function clearClaudeCodeSession(sessionKey: string, userId: number): void {
|
|
sidecar.clearClaudeSession(sessionKey, userId);
|
|
}
|
|
|
|
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
|
logger.info('Claude Code exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
return sidecar.spawnClaude(params);
|
|
}
|
|
|
|
// ── Streaming variant for Chat Panel WebSocket ──
|
|
|
|
type ClaudeCodeStreamingParams = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
prompt: string;
|
|
images?: PromptImage[];
|
|
sessionKey: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
resumeSessionId?: string;
|
|
durable?: boolean;
|
|
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
|
|
onMessage: (msg: TurnMessage, seq?: number) => void;
|
|
};
|
|
|
|
type ClaudeCodeStreamingHandle = {
|
|
/** End the agent's session upstream and stop listening. For an explicit disconnect. */
|
|
kill: () => void;
|
|
/**
|
|
* Stop listening and leave the agent running.
|
|
*
|
|
* These are separate because officer's idle GC and a user's "disconnect" want different things, and
|
|
* for a long time they could not have them: `unsub` was a closure reachable only through `kill`, so
|
|
* letting go of a session necessarily killed it. That is why an idle browser took a live agent down
|
|
* with it — including one the sidecar had deliberately protected because background work was still
|
|
* in flight.
|
|
*/
|
|
detach: () => void;
|
|
};
|
|
|
|
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
|
|
const { onMessage, ...spawnParams } = params;
|
|
|
|
logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
|
|
// Session-scoped subscription. The persistent session outlives the turn, so background task events
|
|
// (task:notification) arrive AFTER 'result' — do NOT unsubscribe on a terminal turn event; only on
|
|
// an explicit kill/teardown (the returned handle, called from deleteSession/disconnect).
|
|
const unsub = sidecar.onClaudeMessage((sessionKey, msg, seq) => {
|
|
if (sessionKey === params.sessionKey) onMessage(msg, seq);
|
|
});
|
|
|
|
await sidecar.spawnClaudeStreaming(spawnParams);
|
|
|
|
return {
|
|
kill: () => {
|
|
sidecar.killClaude(params.sessionKey, params.userId);
|
|
unsub();
|
|
},
|
|
detach: unsub,
|
|
};
|
|
}
|