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:
2026-08-11 23:33:08 +00:00
co-authored by Claude Opus 5
parent 4da82e7f91
commit 7cb402b25a
5 changed files with 147 additions and 15 deletions
+17 -5
View File
@@ -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,
}));
+58 -8
View File
@@ -3,9 +3,22 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from '
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
/**
* A resumable session, and whose it is.
*
* `userId` exists because the map is global to this sidecar and `sessionKey` travels in a client message. The
* pty sidecar learned this first (`4d4a253f`): *"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."* Chat never got the same treatment, because both gates made it unreachable and therefore invisible.
*
* Stored rather than derived: there is nothing in a `sessionKey` or a Claude transcript uuid that says who
* owns it, so ownership has to be written down at the moment it is created.
*/
export type SessionRecord = { userId: number; claudeSessionId: string };
export type PersistedState = {
proxySecret: string;
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
claudeSessions: Record<string, SessionRecord>; // sessionKey → whose, and which transcript
};
const DEFAULT_STATE: PersistedState = {
@@ -41,7 +54,11 @@ function ensureDir() {
}
}
export function loadState(): PersistedState {
/**
* @param ownerUserId who existing sessions belong to — see the migration below. Omitted by the proxy
* process, which holds no sessions; legacy entries are then dropped rather than attributed to a guess.
*/
export function loadState(ownerUserId?: number): PersistedState {
ensureDir();
try {
if (!existsSync(stateFile)) {
@@ -50,6 +67,7 @@ export function loadState(): PersistedState {
}
const text = readFileSync(stateFile, 'utf-8');
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId);
return currentState;
} catch {
currentState = { ...DEFAULT_STATE };
@@ -57,6 +75,28 @@ export function loadState(): PersistedState {
}
}
/**
* Entries used to be a bare `sessionKey → transcript uuid` string. Adopt those to the owner.
*
* Safe because it is a statement about the past rather than a guess: until this commit, `api/chat/chat.ts`
* and the chat socket refused every non-owner, so nothing but the owner could ever have created one. The
* alternative — dropping unrecognised entries — would silently lose the owner's resumable history on upgrade,
* and "my old chats stopped resuming" is a bad way to discover a migration.
*/
function migrateSessions(raw: Record<string, unknown>, ownerUserId?: number): Record<string, SessionRecord> {
const out: Record<string, SessionRecord> = {};
for (const [key, value] of Object.entries(raw ?? {})) {
if (typeof value === 'string') {
if (ownerUserId !== undefined) out[key] = { userId: ownerUserId, claudeSessionId: value };
} else if (value && typeof value === 'object' && 'claudeSessionId' in value && 'userId' in value) {
out[key] = value as SessionRecord;
}
// Anything else is unreadable and dropped: a malformed entry cannot be attributed to anyone, and
// guessing an owner for it is exactly the mistake this whole change exists to stop.
}
return out;
}
export async function saveState(): Promise<void> {
ensureDir();
await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
@@ -83,9 +123,10 @@ export function updateState(patch: Partial<PersistedState>): void {
* There is nothing to debounce: `onSessionId` fires on every message but with the same id, so the
* equality guard collapses it to one write per session, and clearing happens once.
*/
export function setClaudeSession(sessionKey: string, sessionId: string): void {
if (currentState.claudeSessions[sessionKey] === sessionId) return;
currentState.claudeSessions[sessionKey] = sessionId;
export function setClaudeSession(sessionKey: string, sessionId: string, userId: number): void {
const existing = currentState.claudeSessions[sessionKey];
if (existing?.claudeSessionId === sessionId && existing.userId === userId) return;
currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId };
writeThrough();
}
@@ -101,8 +142,17 @@ function writeThrough(): void {
});
}
export function getClaudeSession(sessionKey: string): string | undefined {
return currentState.claudeSessions[sessionKey];
/**
* The transcript for this session key, **only if it belongs to the caller**.
*
* A mismatch returns undefined rather than throwing: to the caller it is simply "no session to resume", which
* is the truthful answer — there is no session of theirs under that key. Throwing would confirm that somebody
* else's exists, which is the one thing a guesser learns from.
*/
export function getClaudeSession(sessionKey: string, userId: number): string | undefined {
const record = currentState.claudeSessions[sessionKey];
if (!record || record.userId !== userId) return undefined;
return record.claudeSessionId;
}
/**
@@ -122,7 +172,7 @@ export function findSessionKeyByClaudeSession(claudeSessionId: string): string |
const keys = Object.keys(currentState.claudeSessions);
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i]!;
if (currentState.claudeSessions[key] === claudeSessionId) return key;
if (currentState.claudeSessions[key]?.claudeSessionId === claudeSessionId) return key;
}
return undefined;
}
+1 -1
View File
@@ -97,7 +97,7 @@ if (!acquireLock()) {
process.exit(1);
}
loadState();
loadState(dbUser.id);
// ── MCP config ──
+1 -1
View File
@@ -152,7 +152,7 @@ export type SidecarEvent =
export type ClaudeState = {
proxySecret: string;
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
claudeSessions: Record<string, { userId: number; claudeSessionId: string }>; // sessionKey → whose, and which transcript
};
// ── Param types ──