Every PORT fallback in the tree now says 9000. There were three answers to one question, each defensible where it was written and none of them visible from the others: server.tsx and 19 sidecars 5000 a default from before there was an installer user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote .env.example 9000 what we told people to write 5000 goes first because macOS binds it — AirPlay Receiver has owned it since Monterey, so a dev server there fails to bind or gets shadowed by something that answers. All 22 sites moved together, which is the point. Changing the app alone would have turned a consistent-but-wrong default into a split one: the app on 9000 while nineteen sidecars still dialled 5000. 9010 was the interesting one. It was the only value that ever matched a real machine, because it is what the old installer wrote — and it was in the single file whose disagreement would have broken chat alone, with nothing else looking wrong. Its own comment records the same bug being fixed once already, within the file, by a change that left it disagreeing with everything outside it. Note what these defaults actually are: the sidecars bind nothing. user-instance.ts has no listener at all — it builds ws:// and http:// URLs that both address the app's single listener. So every one of these numbers is a guess at where the app is, for a value that .env always supplies. Worth removing rather than aligning, which is a separate change. Not typechecked (empty node_modules, frozen installs). Every edited file parses under `bun build --no-bundle`; the pm2 profile loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
350 lines
16 KiB
TypeScript
350 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';
|
|
|
|
// 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 OFFICER_PORT = process.env.PORT ?? '9000';
|
|
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);
|
|
|
|
// ── 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.
|
|
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}`;
|
|
// 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'));
|