import { 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'; // 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. async function resolveOwner() { const explicit = process.env.CLAUDE_USER_EMAIL?.trim(); for (;;) { 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`); await Bun.sleep(5_000); } } const dbUser = await resolveOwner(); const email = dbUser.email; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the // WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset. const OFFICER_PORT = process.env.PORT ?? '9010'; const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${OFFICER_PORT}`; const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${OFFICER_PORT}`; 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. const homeDir = process.env.HOME_DIR ?? 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). const emailAccounts = await getEmailAccounts(dbUser.id); 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); if (!acquireLock()) { console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`); process.exit(1); } loadState(); // ── 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, }, }, }, }; writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig)); 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. const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; function ensureAnthropicEnv(): void { process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`; 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); sessionLog.drop(cmd.sessionKey); reply({ type: 'claude:killed', id: cmd.id }); break; case 'claude:interrupt': await claudeManager.interruptClaudeSession(cmd.sessionKey); reply({ type: 'claude:interrupted', id: cmd.id }); break; case 'claude:is-generating': reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) }); break; case 'claude:find-session': reply({ type: 'claude:session-key', id: cmd.id, sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null, }); break; case 'claude:clear-session': claudeManager.clearSession(cmd.sessionKey); 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'));