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>
258 lines
9.5 KiB
TypeScript
258 lines
9.5 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): string | undefined {
|
|
const keys = Object.keys(currentState.claudeSessions);
|
|
for (let i = keys.length - 1; i >= 0; i--) {
|
|
const key = keys[i]!;
|
|
if (currentState.claudeSessions[key]?.claudeSessionId === claudeSessionId) 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;
|
|
}
|
|
}
|