user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.
The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
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." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.
Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.
Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).
The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
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<ReturnType<typeof getEmailAccounts>>;
|
|
});
|
|
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<string, unknown>).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'));
|