Repository Public
1/**2 * Claude Remote runtime entry point: Fastify on a 0600 unix socket (no TCP listener),3 * bearer-token auth on every request and WebSocket upgrade, `{error:{code,message}}` errors,4 * logs that never contain bodies, tokens or terminal data.5 *6 * Shutting the runtime down closes the attach PTYs only: tmux sessions and tasks keep running.7 */8import { execFile } from 'node:child_process';9import fs from 'node:fs';10import fsp from 'node:fs/promises';11import net from 'node:net';12import path from 'node:path';13import { fileURLToPath } from 'node:url';14import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';15import websocket from '@fastify/websocket';16import { ZodError } from 'zod';17import { loadConfig, type RuntimeConfig } from './config.js';18import { loadPlugins, RtError, type RuntimePlugin } from './plugins.js';19import { createHookToken, installAuth } from './auth.js';20import { EventBus } from './events.js';21import { Tmux } from './tmux.js';22import { resolveBin, type CoreContext } from './core-util.js';23import { MAX_INBOUND_FRAME_BYTES } from './core-attach.js';2425export type PluginSelection = 'all' | 'core';2627export interface BuildOptions {28 /** `core` loads only the runtime-core modules (dev/tests: `CR_RUNTIME_PLUGINS=core`). */29 plugins?: PluginSelection;30}3132export interface Runtime {33 app: FastifyInstance;34 core: CoreContext;35 /** Listen on the unix socket (0600, parent dir 0700). */36 start(): Promise<void>;37 /** Graceful stop: detach clients, stop watchers; tmux sessions and tasks are left running. */38 stop(): Promise<void>;39}4041async function loadCorePlugins(): Promise<RuntimePlugin[]> {42 const mods = await Promise.all([43 import('./sessions.js'),44 import('./tasks.js'),45 import('./hooks.js'),46 import('./events.js'),47 import('./metrics.js'),48 ]);49 return mods.map((m) => m.default);50}5152function readVersion(): string {53 try {54 const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown };55 return typeof pkg.version === 'string' ? pkg.version : '0.0.0';56 } catch {57 return '0.0.0';58 }59}6061const STATUS_CODES: Record<number, string> = {62 400: 'bad_request',63 401: 'unauthorized',64 403: 'forbidden',65 404: 'not_found',66 409: 'conflict',67 413: 'payload_too_large',68 415: 'unsupported_media_type',69 429: 'too_many_requests',70};7172function installErrorHandling(app: FastifyInstance): void {73 app.setErrorHandler((err: FastifyError | RtError | ZodError, req, reply) => {74 if (err instanceof RtError) {75 return reply.status(err.status).send({ error: { code: err.code, message: err.message } });76 }77 if (err instanceof ZodError) {78 const message = err.issues.map((i) => `${i.path.join('.') || '(body)'}: ${i.message}`).join('; ');79 return reply.status(400).send({ error: { code: 'bad_request', message } });80 }81 const status = typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500;82 if (status === 500) {83 req.log.error({ err }, 'unhandled runtime error');84 return reply.status(500).send({ error: { code: 'internal', message: 'internal runtime error' } });85 }86 return reply.status(status).send({ error: { code: STATUS_CODES[status] ?? 'bad_request', message: err.message } });87 });88 app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: { code: 'not_found', message: 'no such route' } }));89}9091function claudeVersionProbe(config: RuntimeConfig): () => Promise<{ path: string; version: string | null }> {92 let cache: { atMs: number; value: { path: string; version: string | null } } | null = null;93 return async () => {94 if (cache && Date.now() - cache.atMs < 60_000) return cache.value;95 const bin = await resolveBin(config.claudeBin);96 const version = bin97 ? await new Promise<string | null>((resolve) => {98 execFile(bin, ['--version'], { timeout: 10_000, encoding: 'utf8' }, (err, stdout) => resolve(err ? null : stdout.trim() || null));99 })100 : null;101 cache = { atMs: Date.now(), value: { path: bin ?? config.claudeBin, version } };102 return cache.value;103 };104}105106/** Refuses to steal the socket of a live runtime; removes a stale one. */107async function prepareSocket(config: RuntimeConfig): Promise<void> {108 if (Buffer.byteLength(config.socketPath) > 103) throw new Error(`CR_RUNTIME_SOCKET path is too long for a unix socket: ${config.socketPath}`);109 const dir = path.dirname(config.socketPath);110 await fsp.mkdir(dir, { recursive: true, mode: 0o700 });111 const st = await fsp.stat(dir);112 if ((st.mode & 0o077) !== 0) {113 // Only tighten directories that belong to the runtime (never e.g. /tmp).114 if (dir.startsWith(config.dataDir + path.sep)) await fsp.chmod(dir, 0o700);115 else throw new Error(`socket directory ${dir} must not be accessible to group/others (expected mode 0700)`);116 }117 if (!fs.existsSync(config.socketPath)) return;118 const live = await new Promise<boolean>((resolve) => {119 const c = net.connect(config.socketPath);120 c.once('connect', () => {121 c.destroy();122 resolve(true);123 });124 c.once('error', () => resolve(false));125 });126 if (live) throw new Error(`another runtime is already listening on ${config.socketPath}`);127 await fsp.rm(config.socketPath, { force: true });128}129130export async function buildRuntime(config: RuntimeConfig, opts: BuildOptions = {}): Promise<Runtime> {131 const selection: PluginSelection = opts.plugins ?? (process.env.CR_RUNTIME_PLUGINS === 'core' ? 'core' : 'all');132 await fsp.mkdir(config.dataDir, { recursive: true, mode: 0o700 });133134 const app = Fastify({135 logger: {136 level: config.logLevel,137 redact: ['req.headers.authorization', 'req.headers.cookie'],138 // Method + path only: no query string, no headers, no body.139 serializers: { req: (req: { method?: string; url?: string }) => ({ method: req.method, path: (req.url ?? '').split('?')[0] }) },140 },141 bodyLimit: 1024 * 1024,142 forceCloseConnections: true,143 });144145 const tmux = await Tmux.create(config);146 await tmux.applyServerOptions();147 // Terminal sessions that survived the previous runtime still hold its hook token.148 const survivors = (await tmux.listSessions().catch(() => [])).some((r) => r.name.startsWith('cr-') && !r.paneDead);149 const core: CoreContext = {150 config,151 bus: new EventBus(),152 tmux,153 hookToken: await createHookToken(config.dataDir, survivors),154 startedAtMs: Date.now(),155 version: readVersion(),156 };157 app.decorate('crCore', core);158159 installErrorHandling(app);160 installAuth(app, config.token, core.hookToken);161 // An upgrade request answered with plain HTTP (401, 404, ...) leaves a raw socket that the162 // HTTP server no longer tracks: close it, or it would linger and block shutdown.163 app.addHook('onResponse', async (req) => {164 if (req.raw.headers.upgrade !== undefined && !req.raw.socket.destroyed) req.raw.socket.destroySoon();165 });166 await app.register(websocket, {167 options: { maxPayload: MAX_INBOUND_FRAME_BYTES },168 // Shutdown: tell every WebSocket peer why (1001) instead of the plugin's bare close; the169 // per-connection close handlers then release their PTYs / file watchers.170 preClose: (done) => {171 for (const client of app.websocketServer.clients) client.close(1001, 'runtime shutting down');172 app.websocketServer.close(() => done());173 },174 });175176 const claudeInfo = claudeVersionProbe(config);177 app.get('/health', async () => ({178 ok: true,179 version: core.version,180 tmux: (await core.tmux.version()) !== null,181 agent: config.agent,182 // Historical key: the instance's agent CLI (Claude Code or Codex).183 claude: await claudeInfo(),184 agentHooks: core.agentHooks ?? { requested: false, status: 'not_requested', detail: null },185 uptimeSec: Math.round((Date.now() - core.startedAtMs) / 1000),186 }));187188 const plugins = selection === 'core' ? await loadCorePlugins() : await loadPlugins();189 for (const plugin of plugins) await app.register(plugin, { config });190 await app.ready();191192 let listening = false;193 return {194 app,195 core,196 async start() {197 await prepareSocket(config);198 const previousUmask = process.umask(0o177); // the socket is born 0600199 try {200 await app.listen({ path: config.socketPath });201 } finally {202 process.umask(previousUmask);203 }204 await fsp.chmod(config.socketPath, 0o600);205 listening = true;206 app.log.info({ socket: config.socketPath, plugins: selection }, 'runtime listening');207 },208 async stop() {209 await app.close(); // onClose hooks: detach PTYs, stop watchers/timers — tmux is untouched210 if (listening) await fsp.rm(config.socketPath, { force: true });211 listening = false;212 },213 };214}215216async function main(): Promise<void> {217 const runtime = await buildRuntime(loadConfig());218 let stopping = false;219 const shutdown = (signal: string): void => {220 if (stopping) return;221 stopping = true;222 runtime.app.log.info({ signal }, 'runtime shutting down (tmux sessions keep running)');223 const force = setTimeout(() => process.exit(1), 10_000);224 force.unref();225 runtime.stop().then(226 () => process.exit(0),227 () => process.exit(1),228 );229 };230 process.on('SIGTERM', () => shutdown('SIGTERM'));231 process.on('SIGINT', () => shutdown('SIGINT'));232 await runtime.start();233}234235function isEntryPoint(): boolean {236 try {237 return process.argv[1] !== undefined && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));238 } catch {239 return false;240 }241}242243if (isEntryPoint()) {244 main().catch((err: unknown) => {245 // Startup failures go to stderr; the message never includes the token.246 console.error(err instanceof Error ? err.message : String(err));247 process.exit(1);248 });249}250