chat sessions record whose they are, and refuse a mismatched caller
host found this reading 10: chat sessions carry no identity at all. state.ts
held a flat sessionKey -> transcript uuid map, the in-memory sessions Map was
keyed the same way, and websocket.ts takes sessionKey and resumeSessionId
straight off the client message. 4d4a253f fixed exactly this for the pty
sidecar — "re-attaching to a session belonging to another account is refused,
otherwise a member resumes someone else's shell by guessing an id that travels
in a query string" — and chat never got the same treatment, because both gates
made it unreachable and therefore invisible.
Sessions now carry userId, persisted and in memory. getClaudeSession requires
the caller and returns undefined on a mismatch rather than throwing, since a
throw confirms that someone else's session exists. spawnClaudeStreaming throws
when a live session's owner does not match — that is the path that mattered
most, because handing over another account's sessionKey would otherwise push a
turn into their conversation and stream their agent's output back.
Legacy string entries are adopted to the owner on load. That is a statement
about the past rather than a guess: until this commit the gates refused every
non-owner, so nothing else could have created one. Dropping them would have
silently broken the owner's resume on upgrade.
PARTIAL, and the doc says so plainly: claude:kill, :interrupt, :clear-session,
:is-generating, :find-session and :list all still take a bare sessionKey with no
ownership check, and :list returns every session in the sidecar. Closing them is
a wide mechanical change across the protocol, the registry verbs and their
producers, and it belongs in its own reviewable commit rather than buried under
a state migration. The gates must not move on the strength of this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,7 @@ type ClaudeCodeOutput = {
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const { prompt, sessionKey } = params;
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
const existingSession = getClaudeSession(sessionKey, params.userId);
|
||||
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
}
|
||||
|
||||
if (output.session_id) {
|
||||
setClaudeSession(sessionKey, output.session_id);
|
||||
setClaudeSession(sessionKey, output.session_id, params.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -167,6 +167,8 @@ type SdkUserMessage = {
|
||||
|
||||
type PersistentSession = {
|
||||
sessionKey: string;
|
||||
/** Whose session this is. The map is global and `sessionKey` arrives in a client message. */
|
||||
userId: number;
|
||||
query: Query;
|
||||
pushTurn: (prompt: string, images?: PromptImage[]) => void;
|
||||
closeInput: () => void;
|
||||
@@ -303,6 +305,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
|
||||
const session: PersistentSession = {
|
||||
sessionKey,
|
||||
userId: params.userId,
|
||||
query: undefined as unknown as Query,
|
||||
pushTurn: () => {},
|
||||
closeInput: () => input.close(),
|
||||
@@ -316,7 +319,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
|
||||
|
||||
const resumeId = getClaudeSession(sessionKey) ?? params.resumeSessionId;
|
||||
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
|
||||
const subModel = params.model?.split('/')[1];
|
||||
|
||||
const q = query({
|
||||
@@ -450,7 +453,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
for await (const msg of q as AsyncGenerator<Record<string, unknown>>) {
|
||||
processMessage(msg, state, {
|
||||
onEvent: emit,
|
||||
onSessionId: (id: string) => setClaudeSession(sessionKey, id),
|
||||
onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -473,6 +476,15 @@ export async function spawnClaudeStreaming(
|
||||
onEvent: (event: ChatEvent) => void,
|
||||
): Promise<void> {
|
||||
let session = sessions.get(params.sessionKey);
|
||||
if (session && session.userId !== params.userId) {
|
||||
// A live session belongs to whoever started it. Without this, handing over someone else's `sessionKey`
|
||||
// pushes a turn into their conversation and streams their agent's output back — the chat equivalent of
|
||||
// resuming another account's shell, which `4d4a253f` refused for the pty sidecar.
|
||||
//
|
||||
// Throws rather than silently starting a fresh session under the same key: the caller asked to continue a
|
||||
// specific conversation, and quietly giving them a different one is its own kind of wrong.
|
||||
throw new Error('that chat session belongs to another account');
|
||||
}
|
||||
if (session) {
|
||||
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
|
||||
} else {
|
||||
@@ -536,7 +548,7 @@ export function listSessions(): LiveClaudeSession[] {
|
||||
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) ?? null,
|
||||
claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
|
||||
isGenerating: session.isGenerating,
|
||||
pendingTasks: session.pendingTasks.size,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user