reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid.
381 lines
18 KiB
TypeScript
381 lines
18 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 { 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 ──
|
|
|
|
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 }),
|
|
);
|
|
|
|
// 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':
|
|
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;
|
|
|
|
// 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':
|
|
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'));
|