Files
platform/src/servers/sidecar/claude/state.ts
T
pastilhasandClaude Opus 5 d59adbf1f2 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>
2026-08-12 00:17:53 +00:00

261 lines
9.7 KiB
TypeScript

import { join } from 'node:path';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
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, SessionRecord>; // sessionKey → whose, and which transcript
};
const DEFAULT_STATE: PersistedState = {
proxySecret: '',
claudeSessions: {},
};
let stateDir: string;
let stateFile: string;
let lockFile: string;
let currentState: PersistedState = { ...DEFAULT_STATE };
let saveTimer: Timer | null = null;
/** Call once at startup to configure paths. For proxy: no email. For per-user: pass email. */
export function initPaths(email?: string): void {
if (email) {
stateDir = join(DATA_PATH, email, 'sidecar');
stateFile = join(stateDir, 'claude-state.json');
lockFile = join(stateDir, 'claude.lock');
} else {
stateDir = join(DATA_PATH, 'sidecar');
stateFile = join(stateDir, 'claude-state.json');
lockFile = join(stateDir, 'claude.lock');
}
}
// Default to proxy paths
initPaths();
function ensureDir() {
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
}
/**
* @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)) {
currentState = { ...DEFAULT_STATE };
return currentState;
}
const text = readFileSync(stateFile, 'utf-8');
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId);
return currentState;
} catch {
currentState = { ...DEFAULT_STATE };
return currentState;
}
}
/**
* 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));
}
export function getState(): PersistedState {
return currentState;
}
export function updateState(patch: Partial<PersistedState>): void {
Object.assign(currentState, patch);
scheduleSave();
}
/**
* Both of these write through instead of debouncing.
*
* This map is the only thing that lets a conversation be reattached with `--resume` after the agent
* sidecar dies, so it is precisely the state that must not be in a 30s window when the process is
* killed rather than asked to stop — `flushAndSave` on SIGTERM covers a `pm2 restart`, but not a
* crash or a SIGKILL, which is the case the resume path exists for. Losing it silently starts the
* next turn as a fresh Claude session and orphans the transcript on disk.
*
* 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, userId: number): void {
const existing = currentState.claudeSessions[sessionKey];
if (existing?.claudeSessionId === sessionId && existing.userId === userId) return;
currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId };
writeThrough();
}
export function clearClaudeSession(sessionKey: string): void {
if (!(sessionKey in currentState.claudeSessions)) return;
delete currentState.claudeSessions[sessionKey];
writeThrough();
}
function writeThrough(): void {
void flushAndSave().catch((err) => {
console.error('[claude-state] save failed:', err instanceof Error ? err.message : err);
});
}
/**
* 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;
}
/**
* The same map read backwards: Claude's transcript uuid → the key officer made up for the session.
*
* A browser that has refreshed holds only the uuid, because that is what is in the URL; officer's own
* key lived in page state and is gone. This is the only record anywhere that relates the two, which is
* why re-binding a socket to a running turn has to come through the sidecar rather than being answerable
* on the platform side.
*
* A linear scan over a handful of live sessions. If that ever stops being true, add the inverse map —
* but a second copy of a mapping is a second thing to keep honest, and this one is written on every turn.
* Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants
* the session generating now, not the one that produced the same file yesterday.
*/
export function findSessionKeyByClaudeSession(claudeSessionId: string, userId: number): string | undefined {
const keys = Object.keys(currentState.claudeSessions);
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i]!;
const record = currentState.claudeSessions[key];
// Scoped to the caller: this is the reattach hinge, and a browser holding a transcript uuid it should
// not have would otherwise be handed the session key that drives it.
if (record?.claudeSessionId === claudeSessionId && record.userId === userId) return key;
}
return undefined;
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(async () => {
saveTimer = null;
await saveState();
}, 30_000);
}
export async function flushAndSave(): Promise<void> {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
await saveState();
}
/**
* Read the Anthropic proxy secret out of the *proxy* sidecar's state file.
*
* The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see
* `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH/<email>/sidecar/` — so the agent cannot reach
* the secret through `getState()`. It used to be handed the secret in env by the main server, and
* needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died
* with it). Reading it off disk keeps the two processes independent, with the proxy still the only
* writer.
*
* Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce,
* so a brand-new install has a window where the file exists without it. Callers should treat '' as
* "retry later" rather than fatal.
*/
export function readProxySecretFromDisk(): string {
try {
const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(proxyStateFile)) return '';
const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial<PersistedState>;
return parsed.proxySecret ?? '';
} catch {
return '';
}
}
// ── Lockfile ──
export function acquireLock(): boolean {
ensureDir();
try {
if (existsSync(lockFile)) {
const pidStr = readFileSync(lockFile, 'utf-8').trim();
const pid = Number(pidStr);
if (pid && isProcessAlive(pid)) {
return false;
}
}
writeFileSync(lockFile, String(process.pid));
return true;
} catch {
return false;
}
}
export function releaseLock(): void {
try {
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
} catch {
// best effort
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}