Files
platform/src/servers/sidecar/claude/state.ts
T
pastilhasandClaude Opus 5 56ddb6bebf close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the
owner's Claude Max subscription: officer-anthropic-proxy accepts it as
x-api-key, swaps it for the owner's OAuth bearer and forwards to
api.anthropic.com. it listens on 127.0.0.1, which every local account can
reach.

it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and
Bun.write at ubuntu's default umask of 002. confirmed by reading the live
secret as 'nobody', an account with no relationship to the platform, and by
confirming the proxy 401s without the key so the secret is the only control.

this does NOT mean members shared the subscription. spawnClaudeAsMember runs
their claude through setpriv --reset-env with a six-name allowlist, so
ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns
go straight to anthropic on their own OAuth. that design is intact - it could
just be walked around by anyone with a shell.

fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the
authoritative fix, and on every write rather than at creation because
writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets()
sweeps at boot because officer-anthropic-proxy is a separate pm2 process that
can recreate the file hours after officer started, and because every existing
install has the old mode until something repairs it.

it repairs rather than refusing to boot, unlike assertSecretsClosed beside it.
the line is who wrote the file: .env is the installer's and we must not guess
at it; this is our own output at the machine's umask, so refusing to start
would punish the user for our bug.

the per-account copies are swept too. no live secret in them today, but they
map session keys to transcript uuids for named accounts.

tests verified to bite: removing the chmod while still reporting success fails
four of them.

a chmod does not undo exposure - the secret still needs rotating on the
production host. TODO.md has the sequence, including that officer-claude-code
caches it and must be restarted alongside the proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:27:09 +00:00

306 lines
12 KiB
TypeScript

import { join } from 'node:path';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
import { DATA_PATH } from '../../data-path';
/**
* 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();
/*
This file holds `proxySecret`, and that secret is the ONLY thing standing between a local account and the
owner's Claude Max subscription.
`officer-anthropic-proxy` listens on 127.0.0.1, swaps an `x-api-key` matching this value for the owner's
OAuth bearer, and forwards to api.anthropic.com. Any account on the box can reach a loopback port, so
whoever can read this file can spend the owner's subscription — deliberately, and with no trace tying it
to them.
The platform's whole per-member design is built so this CANNOT happen by accident: `spawnClaudeAsMember`
runs a member's `claude` through `setpriv --reset-env` with a six-name allowlist, so `ANTHROPIC_BASE_URL`
and `ANTHROPIC_API_KEY` cannot cross into it and their turns go straight to Anthropic on their own OAuth.
None of that helps if the key is world-readable on disk — the isolation is intact and simply walked
around.
It WAS world-readable. Ubuntu's default umask is 002, `mkdirSync`/`writeFileSync` take it, and nothing
here ever set a mode: verified 2026-08-17 by reading the live secret as `nobody`, an account with no
relationship to the platform at all. The same umask reasoning is already written down for the owner's
home in `os-user.ts` — this file was simply not on anyone's list.
0600 on the file and 0700 on the directory. Applied on every write and not only at creation, because
`writeFileSync` leaves an EXISTING file's mode alone, so creating it correctly would have fixed nothing
on the installs that already have one.
*/
const SECRET_FILE_MODE = 0o600;
const SECRET_DIR_MODE = 0o700;
function ensureDir() {
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true, mode: SECRET_DIR_MODE });
}
// Unconditional, for the directories that already exist at 0775.
try {
chmodSync(stateDir, SECRET_DIR_MODE);
} catch {
// Not ours to chmod. The boot sweep reports it; failing here would take the sidecar down over a
// permission bit on a directory it may not own.
}
}
/** Clamp the state file to 0600. Safe to call when it does not exist yet. */
function secureStateFile(): void {
try {
if (existsSync(stateFile)) chmodSync(stateFile, SECRET_FILE_MODE);
} catch {
// Same reasoning as ensureDir.
}
}
/**
* @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));
// After the write, never before: `Bun.write` creates at the process umask, and this is the only moment
// the file is guaranteed to exist.
secureStateFile();
}
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;
}
}