Files
platform/src/servers/sidecar/claude/user-instance.ts
T
pastilhasandClaude Opus 5 912ecd04f7 a plugin can hand the agent its own MCP server
openseo is the first: it fronts something that already ships an MCP server, and
the choice was to reimplement that vocabulary as Officer tools or hand the real
one over. Reimplementing means a second copy that goes stale, and it fails
silently — a tool exists upstream that Officer cannot see. Handing it over costs
one manifest field.

What a wrapper would have bought — authentication, logging, refusal — is already
true of the path, because the agent reaches it through Officer's own API with
Officer's own gate in front. Wrapping would buy it twice.

The manifest declares a PATH and never a URL, and that is the safety property
rather than a convention: mcp-host.json carries OFFICER_AUTH_TOKEN, which signs
as the owner, so a manifest able to name an arbitrary host would be a manifest
able to post the owner's credential anywhere. A path can only reach this machine.

Installed AND enabled, since a disabled plugin's routes are not mounted and
offering the agent a tool list that 404s is worse than offering nothing. A
plugin that cannot be read loses its tools for the session rather than stopping
the agent from starting.

`generateMcpConfig` becomes async because it now asks the database. Awaited, not
fire-and-forget: setMcpConfigPath is what the manager reads when it spawns, so a
config arriving later is an agent running without its plugin tools and no sign
of why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:53:22 +01:00

444 lines
21 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 } from 'officerdb';
import { DATA_PATH, findEmailAccountName } from '../../data-path';
import { listPluginInstalls } from 'officerdb';
import { discoverPlugins } from '../../plugins/discover';
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`
// permission (permissions/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` permission 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; found on disk, never queried.
//
// This used to `await getEmailAccounts(dbUser.id)` at the top level. Email is a plugin now and the platform
// may not import its queries — and that import is also what put this process into a PM2 restart loop when
// the table went away (`fbb6d6c7`), taking every live chat session with it. A directory read cannot throw
// the same way and cannot depend on the plugin being installed.
const emailAccountName = findEmailAccountName(email);
const emailDbRel = join('email_accounts', emailAccountName ?? '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 ──
async function generateMcpConfig(): Promise<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(':');
/*
MCP servers contributed by plugins.
A plugin that fronts something with its own MCP server can either reimplement that vocabulary as
Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it
fails silently — a tool exists upstream that Officer cannot see. Handing it over costs this block.
The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is
the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that signs as the
owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's
credential anywhere. A path can only ever reach this machine, through this machine's own gate.
Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a
tool list that 404s is worse than not offering it.
*/
const pluginMcpServers: Record<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
try {
const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName));
for (const plugin of discovered.plugins) {
const declared = plugin.manifest?.mcp?.path;
if (!declared || !live.has(plugin.appName)) continue;
const path = declared.startsWith('/') ? declared : `/${declared}`;
pluginMcpServers[plugin.appName] = {
type: 'http',
url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`,
headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` },
};
}
} catch {
// A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this
// session and everything else works, which is the right side to fail on.
}
const hostConfig = {
mcpServers: {
...pluginMcpServers,
'officer-tools': {
type: 'stdio',
command: 'bun',
args: ['run', MCP_SERVER_SCRIPT],
env: {
PI_TOOLS_DIRS: hostToolsDirs,
OFFICER_EMAIL_DB: join(userRoot, emailDbRel),
// The PARENT, because `OFFICER_EMAIL_DB` is one account and an owner may have several.
//
// `findEmailAccountName` picks the most recently synced, which is the right guess for a single
// question and a silent lie for "search all my email" — the agent gets one mailbox and no way to
// learn the others exist. Handing it the directory makes the rest discoverable by listing it,
// rather than by deriving a path from a convention it would have to be told anyway.
OFFICER_EMAIL_ACCOUNTS_DIR: join(userRoot, 'email_accounts'),
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.
/*
Awaited, because the config now asks the database which plugins are installed.
Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config
that arrives after the first spawn is an agent running without its plugin tools and no sign of why.
*/
setMcpConfigPath(await 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 }),
);
// Notification delivery is a separate, non-durable ping — see `PersistentSession.notify` in
// claude-manager.ts for why it isn't folded into the message above.
const notifySignal = (sessionKey: string, userId: number, kind: 'turn-ended' | 'input-needed') =>
connection.send({ type: 'claude:notify', sessionKey, userId, kind });
// ── 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),
(kind) => notifySignal(sessionKey, cmd.params.userId, kind),
)
.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': {
/*
Reply only once the child is actually gone.
The caller deleting a session's transcript has to know the CLI has stopped writing it, and this
reply is the only signal it can get — officer's side of the socket has no view of the process.
Bounded inside `killClaudeSessionAndWait`, so a child that will not die delays this by seconds
rather than hanging the caller's request.
*/
await claudeManager.killClaudeSessionAndWait(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;
// Both plan commands answer through the SESSION LOG rather than with a reply, and that is the point:
// what the user needs to see is `plan:mode` / `plan:decided` arriving in the conversation, durably and
// in order with everything else. A command reply would be a second, weaker channel saying the same
// thing to one socket — and officer sends both of these fire-and-forget, so nothing is waiting on one.
case 'claude:set-plan-mode':
await claudeManager.setClaudePlanMode(cmd.sessionKey, cmd.userId, cmd.enabled);
break;
case 'claude:plan-decision':
claudeManager.decideClaudePlan(cmd.sessionKey, cmd.userId, cmd.requestId, cmd.approve, cmd.feedback);
break;
// Answers through the tool result the agent is blocked on, not through a reply — the same reasoning as
// the plan commands above, one layer deeper: what the user needs to see is the question's own card
// filling in, and that arrives as the `tool:result` this unblocks.
case 'claude:question-answer':
// The boolean was discarded here, which is how a dropped answer became invisible. It reports its
// own reason now — this only has to stop throwing it away.
claudeManager.answerClaudeQuestion(cmd.sessionKey, cmd.userId, cmd.toolCallId, cmd.answer);
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' handler, 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',
handles: ['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'));