SPB Git

Repository Public

main.ts 9.6 KB 250 lines typescript

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
Files main
Menu

Theme