run the agent as a pm2 peer instead of a child of officer
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry and was spawned on demand by the main server, with stdout/stderr inherited. that made every agent session a grandchild of officer, so pm2's tree-kill took the session down on every `pm2 restart officer` — the single thing that makes it impossible to work on the platform while an agent is running. give it its own entry (officer-agent) and delete the spawn machinery: ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns no sidecar at all. for that to work the sidecar had to stop needing officer to start: - it resolves the owner from the database (getOwnerUser) instead of reading CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins when set, for manual runs, and a fresh install waits for bootstrap rather than exiting into a restart loop. - it reads the anthropic proxy secret from the proxy sidecar's own state file rather than being handed it in env. lazily, because ensureProxySecret persists on a 30s debounce and pm2 starts both processes together. it registers as 'agent' with capability 'claude', so the registry finds it the way it finds every other sidecar. that removes the email argument from killClaude, interruptClaude and clearClaudeSession, which only ever existed to locate a per-email sidecar by name. what officer keeps is a short wait-for-capability, because pm2 brings peers up together and the first request after a boot can beat the sidecar's registration. also align the two officer port fallbacks in the sidecar (5000 for the socket, 9010 for the rest base) — same instance, so they cannot disagree. this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while officer is down are still lost; that is stage 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,31 @@ export async function flushAndSave(): Promise<void> {
|
||||
await saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Anthropic proxy secret out of the *proxy* sidecar's state file.
|
||||
*
|
||||
* The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see
|
||||
* `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH/<email>/sidecar/` — so the agent cannot reach
|
||||
* the secret through `getState()`. It used to be handed the secret in env by the main server, and
|
||||
* needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died
|
||||
* with it). Reading it off disk keeps the two processes independent, with the proxy still the only
|
||||
* writer.
|
||||
*
|
||||
* Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce,
|
||||
* so a brand-new install has a window where the file exists without it. Callers should treat '' as
|
||||
* "retry later" rather than fatal.
|
||||
*/
|
||||
export function readProxySecretFromDisk(): string {
|
||||
try {
|
||||
const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json');
|
||||
if (!existsSync(proxyStateFile)) return '';
|
||||
const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial<PersistedState>;
|
||||
return parsed.proxySecret ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lockfile ──
|
||||
|
||||
export function acquireLock(): boolean {
|
||||
|
||||
@@ -2,30 +2,39 @@ 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 } from './state';
|
||||
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk } from './state';
|
||||
import { setMcpConfigPath } from './claude-manager';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
import { getUserByEmail, getEmailAccounts } from 'officerdb';
|
||||
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
|
||||
|
||||
const email = process.env.CLAUDE_USER_EMAIL;
|
||||
if (!email) {
|
||||
console.error('[user-instance] CLAUDE_USER_EMAIL is required');
|
||||
process.exit(1);
|
||||
// PM2 starts this sidecar with no user in its env. Single-user platform, 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.
|
||||
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');
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`;
|
||||
// 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 dbUser = await getUserByEmail(email);
|
||||
if (!dbUser) {
|
||||
console.error(`[user-instance] no user found for ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
|
||||
|
||||
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so
|
||||
@@ -49,7 +58,7 @@ process.env.HOME = homeDir;
|
||||
initPaths(email);
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error(`[claude:${email}] another instance is already running (lock file exists with live PID)`);
|
||||
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -85,6 +94,30 @@ function generateMcpConfig(): string {
|
||||
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
|
||||
@@ -92,8 +125,9 @@ function generateMcpConfig(): string {
|
||||
// terminal `claude` loads too.
|
||||
|
||||
setMcpConfigPath(generateMcpConfig());
|
||||
ensureAnthropicEnv();
|
||||
|
||||
console.log(`[claude:${email}] started (HOME=${homeDir})`);
|
||||
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
@@ -106,6 +140,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
break;
|
||||
|
||||
case 'claude:spawn': {
|
||||
ensureAnthropicEnv();
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply({ type: 'claude:result', id: cmd.id, result });
|
||||
@@ -116,6 +151,7 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
ensureAnthropicEnv();
|
||||
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../../api/chat/types').ChatEvent) => {
|
||||
@@ -158,9 +194,12 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
|
||||
// ── 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: `claude:${email}`,
|
||||
name: 'agent',
|
||||
capabilities: ['claude'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
@@ -170,7 +209,7 @@ const connection = createSidecarConnector({
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[claude:${email}] ${signal} received, saving state...`);
|
||||
console.log(`[agent] ${signal} received, saving state...`);
|
||||
connection.destroy();
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
|
||||
Reference in New Issue
Block a user