import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk, findSessionKeyByClaudeSession, } from './state'; import { createSessionLogStore } from './session-log'; import { setMcpConfigPath } from './claude-manager'; import * as claudeManager from './claude-manager'; import { createSidecarConnector } from '../connect'; import { sign } from '../../jwt'; import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb'; import { DATA_PATH } from '../../data-path'; import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; // PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than // being told who to run as by the main server — one less thing that has to come from `officer` before // this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs. // // "The owner" is not a simplification that multi-user will later invalidate. `chat` is an `execution` // capability (capabilities/registry.ts) and is never grantable at any level, so no account other than // the owner can ever reach this sidecar, however many accounts exist. // // ── Why this retries rather than throws ── // // There are two ways this can fail and it used to survive only one of them. A query that SUCCEEDS and // returns nothing is a fresh install waiting on POST /auth/bootstrap, and was handled. A query that // THROWS — Postgres restarting (`57P03: the database system is starting up`), or not up yet // (`ECONNREFUSED`) — escaped this function, rejected the top-level await, and exited the process into // exactly the PM2 restart loop the comment below says it exists to avoid. It spins until the database // answers, and every live agent session dies with the first crash. // // That is not hypothetical: it cost a session on 2026-08-10, and the restart counter on this sidecar // read 468 against 0 for every peer that starts without needing the database. // // Retrying forever rather than failing fast is deliberate, and matches the case below it: the database // coming back is a matter of time, and an agent that gave up would need a human to notice and restart it. async function resolveOwner() { const explicit = process.env.CLAUDE_USER_EMAIL?.trim(); for (;;) { try { const user = explicit ? await getUserByEmail(explicit) : await getOwnerUser(); if (user) return user; // Fresh install: wait for POST /auth/bootstrap instead of exiting into a PM2 restart loop. console.log(`[agent] no ${explicit ? `user "${explicit}"` : 'owner account'} yet — retrying in 5s`); } catch (err) { console.log( `[agent] database not reachable yet (${err instanceof Error ? err.message : String(err)}) — retrying in 5s`, ); } await Bun.sleep(5_000); } } const dbUser = await resolveOwner(); const email = dbUser.email; // Both URLs address the SAME officer instance — this file binds nothing, and the app serves HTTP and // WebSocket on one listener. So the fallback port has to agree, and twice it did not: 5000 for the // WebSocket against 9010 for the REST base here, and then 9010 here against 5000 in the app and every // other sidecar. The second one was the worse half, because chat is the only thing that would have // broken and nothing else would have looked wrong. // // 9000 everywhere now, matching server.tsx and .env.example. 9010 was the old installer's default // (scripts/setup-old/setup.sh), which is why it was the only one of the three that ever matched a real // machine. const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts'); // Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d'); // The owner runs Claude with no isolation — real HOME, real ~/.claude — so platform sessions have // perfect parity with terminal sessions (same config, credentials and transcript store, // interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an // `execution` capability and can never be granted: this is a shell, not a feature flag. // Evaluated here, ABOVE the `process.env.HOME = homeDir` below: homedir() reads $HOME, so a // read placed after that assignment would return whatever was last spawned into. const homeDir = homedir(); const globalToolsDir = join(DATA_PATH, 'tools'); // The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled). // // Guarded for the same reason `resolveOwner` above is, and it is the same bug in a second place: this // is a top-level await, so a query that THROWS rejects it and exits the process — straight into the PM2 // restart loop, taking every live agent session with it. `resolveOwner` was hardened after that cost a // session on 2026-08-10; this line has the identical shape and was not. // // It throws for real now: email is a plugin, so `email_accounts` is commented out of the schema // (officerdb/src/schema.ts) and does not exist on a core install. An agent must start without it — the // email tool is one tool, not a prerequisite for chat. const emailAccounts = await getEmailAccounts(dbUser.id).catch(() => { console.warn('[agent] no email accounts (the email plugin is not installed) — the email_db tool will be idle'); return [] as Awaited>; }); const emailAccount = emailAccounts.find((a) => a.enabled) ?? emailAccounts[0]; const emailDbRel = join('email_accounts', emailAccount?.email ?? 'none', 'emails.db'); const userToolsDir = join(DATA_PATH, email, 'tools'); // ── Path setup ── // Set HOME so claude inherits it process.env.HOME = homeDir; // Init per-user state paths initPaths(email); // ── One conversation must not be able to end the others ── // // Four crashes on the production host in one evening, one of them truncating the owner's turn mid-sentence: // // error: ProcessTransport is not ready for writing // at write (…/claude-agent-sdk/sdk.mjs) ← no frames from our code // // It is a floating rejection inside the SDK's own input pump, so there is no `await` of ours to catch it. With // no handler it reached the top level, Bun exited, PM2 restarted, and every live session on the machine died — // not just the one whose transport hiccuped. // // That is `975673a` for the second time. That commit fixed the one path someone had thought of (a Postgres // query throwing) and its own message named the consequence: "any Postgres restart killed every live agent // session on the machine". The general case had no backstop at all. // // So: log it and stay up. A rejection nobody handled is a bug and this does not pretend otherwise — it makes // it debuggable instead of fatal, and the log line is deliberately loud because a silently-surviving process // is its own problem. // // `uncaughtException` is deliberately NOT handled the same way. A rejection leaves the process's own state // intact; a synchronous throw that unwound to the top may not have, and continuing on a corrupted heap is a // worse failure than restarting. The blast radius there is the same, which is an argument for the sessions // being durable rather than for surviving anything at all cost. process.on('unhandledRejection', (reason) => { const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason); console.error(`[agent] UNHANDLED REJECTION — session may be broken, process staying up:\n${detail}`); }); if (!acquireLock()) { console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`); process.exit(1); } loadState(dbUser.id); // ── MCP config ── function generateMcpConfig(): string { // Was `.container-context`, from the architecture where each user ran inside their own Docker // container and this directory described that container to the agent. Nothing about it is // container-related now — it holds exactly one file, the MCP server config handed to the CLI. The // path is written here and consumed through the return value, so nothing else reads it and the // rename costs nothing; an old `.container-context` directory left on disk is inert. const contextDir = join(DATA_PATH, email!, 'agent-config'); mkdirSync(contextDir, { recursive: true }); const userRoot = join(DATA_PATH, email!); const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':'); const hostConfig = { mcpServers: { 'officer-tools': { type: 'stdio', command: 'bun', args: ['run', MCP_SERVER_SCRIPT], env: { PI_TOOLS_DIRS: hostToolsDirs, OFFICER_EMAIL_DB: join(userRoot, emailDbRel), MCP_TOOLS_LOG: join(userRoot, 'logs', 'mcp-tools.log'), OFFICER_API_URL, OFFICER_AUTH_TOKEN, }, }, }, }; // 0600, because this file's `env` block carries OFFICER_AUTH_TOKEN — a 30-day JWT that signs as the owner. // It was written at the default 0644 inside a 755 directory, and `terminal` is granted to every role by // default, so any member with a shell could `cat` it and hold owner-level API access against // OFFICER_API_URL on loopback. Verified as a real member on the production host, not reasoned about. // // The mode is only the half of this that is code. The directory chain above it still allows traversal and // listing, and a token that has been world-readable stays compromised however the file is chmod'ed // afterwards — it has to be rotated. Both are the owner's, and both are written up in COMMS. const mcpHostFile = join(contextDir, 'mcp-host.json'); writeFileSync(mcpHostFile, JSON.stringify(hostConfig), { mode: 0o600 }); // BOTH, and neither is redundant. `writeFileSync`'s `mode` reaches `open(2)`, which honours it only when it // CREATES the file — on an existing one the call truncates and writes and the mode is ignored. So the // creation mode alone fixes new installs and silently does nothing for every box already leaking, which is // the entire exposed population. `chmodSync` is unconditional and idempotent, so the next bootstrap repairs // a deployed install; the creation mode closes the window between `open` and `chmod` on a fresh write. chmodSync(mcpHostFile, 0o600); return join(contextDir, 'mcp-host.json'); } // ── Anthropic credentials ── // The `claude` CLI inherits this process's env (claude-manager spawns with `process.env`), so the proxy // endpoint and secret have to be set here. Officer used to inject both when it spawned this process; // reading them ourselves is what lets this sidecar be a PM2 peer instead of a child of the server. // // Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and // `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly // absent. Re-checked before every spawn until it lands. function ensureAnthropicEnv(): void { process.env.ANTHROPIC_BASE_URL ??= ANTHROPIC_PROXY_URL; // Without this, every platform chat session is capped at 200K context while the same `claude` in a // terminal gets Opus 5's full 1M — for no reason other than the proxy hop above. // // The CLI decides 1M eligibility with `provider === 'firstParty' && Fp()`, where `Fp()` is // `!ANTHROPIC_BASE_URL || new URL(ANTHROPIC_BASE_URL).host === 'api.anthropic.com'`. Pointing the // base URL at 127.0.0.1 fails that host check, so the client silently drops to 200K — the model is // fine (`claude-opus-5` is `native_1m: true`) and so is the account; only the hostname is wrong. // // The assertion this flag makes is true here: the proxy forwards verbatim to api.anthropic.com and // preserves `anthropic-beta` (see proxy.ts), so first-party is exactly what is on the other end. Do // not set it on a base URL that points anywhere else. process.env._CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL ??= '1'; if (process.env.ANTHROPIC_API_KEY) return; const secret = readProxySecretFromDisk(); if (secret) { process.env.ANTHROPIC_API_KEY = secret; console.log('[agent] anthropic proxy secret loaded from disk'); } else { console.warn('[agent] anthropic proxy secret not on disk yet — retrying before next spawn'); } } // ── Startup ── // The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is // deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the // terminal `claude` loads too. setMcpConfigPath(generateMcpConfig()); ensureAnthropicEnv(); console.log(`[agent] started for ${email} (HOME=${homeDir})`); // ── Turn output ── // Every message a turn produces is translated, committed to chat_session_events and only then pushed to // officer. `connection` is initialised below, before any command can arrive to invoke this. const sessionLog = createSessionLogStore((d) => connection.send({ type: 'claude:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }), ); // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; case 'claude:spawn': { ensureAnthropicEnv(); try { const result = await claudeManager.spawnClaude(cmd.params); reply({ type: 'claude:result', id: cmd.id, result }); } catch (err) { reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) }); } break; } case 'claude:spawn-streaming': { ensureAnthropicEnv(); reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey }); const { sessionKey, durable = true } = cmd.params; claudeManager .spawnClaudeStreaming(cmd.params, (event) => sessionLog.push(sessionKey, event, durable)) .catch((err) => { // Through the log like any other output, so a failure to start is durable and replayable too. const message = err instanceof Error ? err.message : String(err); sessionLog.push(sessionKey, { type: 'error', message }, durable); }); break; } case 'claude:kill': claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId); sessionLog.drop(cmd.sessionKey); reply({ type: 'claude:killed', id: cmd.id }); break; case 'claude:interrupt': await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId); reply({ type: 'claude:interrupted', id: cmd.id }); break; case 'claude:list': reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions(cmd.userId) }); break; case 'claude:is-generating': reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey, cmd.userId), }); break; case 'claude:find-session': reply({ type: 'claude:session-key', id: cmd.id, sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId, cmd.userId) ?? null, }); break; case 'claude:clear-session': claudeManager.clearSession(cmd.sessionKey, cmd.userId); sessionLog.drop(cmd.sessionKey); reply({ type: 'claude:session-cleared', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } // ── Connect to API server ── // A stable identity, not a per-email one. Officer looks this sidecar up by the 'claude' capability, so // it no longer needs to know which user is running to find it — that was the last thing tying the // registry's claude verbs to an email argument. const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'agent', capabilities: ['claude'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, }); // ── Graceful shutdown ── async function shutdown(signal: string) { console.log(`[agent] ${signal} received, saving state...`); connection.destroy(); await flushAndSave(); releaseLock(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));