CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
9.9 KiB
TypeScript
259 lines
9.9 KiB
TypeScript
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,
|
|
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';
|
|
|
|
// 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.
|
|
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');
|
|
// 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 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);
|
|
|
|
if (!acquireLock()) {
|
|
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
|
|
process.exit(1);
|
|
}
|
|
|
|
loadState();
|
|
|
|
// ── 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,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig));
|
|
|
|
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
|
|
// 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);
|
|
sessionLog.drop(cmd.sessionKey);
|
|
reply({ type: 'claude:killed', id: cmd.id });
|
|
break;
|
|
|
|
case 'claude:interrupt':
|
|
await claudeManager.interruptClaudeSession(cmd.sessionKey);
|
|
reply({ type: 'claude:interrupted', id: cmd.id });
|
|
break;
|
|
|
|
case 'claude:is-generating':
|
|
reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) });
|
|
break;
|
|
|
|
case 'claude:find-session':
|
|
reply({
|
|
type: 'claude:session-key',
|
|
id: cmd.id,
|
|
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null,
|
|
});
|
|
break;
|
|
|
|
case 'claude:clear-session':
|
|
claudeManager.clearSession(cmd.sessionKey);
|
|
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'));
|