SPB Git

Repository Public

config.ts 9.3 KB 244 lines typescript

1/**2 * Server configuration: zod-validated environment. In production the process refuses to start3 * without CR_SECRET_KEY (64 hex), CR_RUNTIME_TOKEN, DATABASE_URL and an https CR_PUBLIC_ORIGIN.4 */5import fs from 'node:fs';6import net from 'node:net';7import os from 'node:os';8import path from 'node:path';9import { randomBytes } from 'node:crypto';10import { fileURLToPath } from 'node:url';11import { z } from 'zod';12import { agentProfile, resolveAgentId, type AgentProfile } from '@cr/shared';1314export interface WorkspaceRoot {15  label: string;16  path: string;17}18export const LocalModelConfig = z.object({19  id: z.string().min(1).max(80),20  name: z.string().min(1).max(200),21  host: z.string().max(80),22  engine: z.string().max(80).nullable().default(null),23  endpoint: z.string().url(),24});25export type LocalModelConfig = z.infer<typeof LocalModelConfig>;2627export interface PasswordCost {28  /** argon2id memory in KiB / passes; scrypt log2(N). Lowered only by the test-suite. */29  argonMemoryKiB: number;30  argonPasses: number;31  scryptLogN: number;32}3334export interface ServerConfig {35  env: 'production' | 'development' | 'test';36  version: string;37  /** Agent profile of this instance (`CR_AGENT`: `claude` by default, or `codex`). */38  agent: AgentProfile;39  port: number;40  host: string;41  publicOrigin: string;42  rpId: string;43  dataDir: string;44  workspaceRoots: WorkspaceRoot[];45  runtimeSocket: string;46  runtimeToken: string;47  databaseUrl: string;48  secretKey: Buffer;49  agentdUrl: string | null;50  agentdToken: string | null;51  nodeAlias: string | null;52  execHosts: string[];53  termBufferBytes: number;54  trustedProxies: string[];55  localModels: LocalModelConfig[];56  skillsRegistryUrl: string | null;57  webDir: string;58  logLevel: string;59  /** `__Host-` + Secure cookies. Disabled only outside production on an http://localhost origin. */60  secureCookies: boolean;61  cookieName: string;62  preAuthCookieName: string;63  /**64   * Requests per minute. `api`: per session (authenticated API). `preAuth`: per client IP, shared65   * by the credential-checking endpoints. `status`: per client IP for anonymous `GET /auth/status`66   * (read-only, called on every page load). `health`: per client IP for `GET /health`.67   */68  rateLimit: { api: number; preAuth: number; status: number; health: number };69  passwordCost: PasswordCost;70  /** Hard cap on a streamed upload (bytes), enforced here as well as by the runtime. */71  uploadLimitBytes: number;72  /** How often open WebSockets re-check their auth session (ms). */73  wsAuthRecheckMs: number;74  reconcileIntervalMs: number;75  clusterPollMs: number;76}7778/** Same parsing as apps/runtime/src/config.ts (copied on purpose: no cross-app imports). */79export function parseWorkspaceRoots(raw: string): WorkspaceRoot[] {80  return raw81    .split(',')82    .map((s) => s.trim())83    .filter(Boolean)84    .map((entry) => {85      const eq = entry.indexOf('=');86      const p = path.resolve(eq > 0 ? entry.slice(eq + 1) : entry);87      const label = eq > 0 ? entry.slice(0, eq) : path.basename(p);88      return { label, path: p };89    });90}9192const list = (raw: string | undefined): string[] =>93  (raw ?? '')94    .split(',')95    .map((s) => s.trim())96    .filter(Boolean);9798function isIpOrCidr(s: string): boolean {99  const [addr, prefix, extra] = s.split('/');100  if (extra !== undefined || !addr) return false;101  const fam = net.isIP(addr);102  if (!fam) return false;103  if (prefix === undefined) return true;104  if (!/^\d{1,3}$/.test(prefix)) return false;105  return Number(prefix) <= (fam === 4 ? 32 : 128);106}107108const Env = z.object({109  NODE_ENV: z.string().optional(),110  CR_AGENT: z.string().optional(),111  CR_PORT: z.coerce.number().int().min(1).max(65535).default(8620),112  CR_HOST: z.string().default('127.0.0.1'),113  CR_PUBLIC_ORIGIN: z.string().default('http://localhost:8620'),114  CR_DATA_DIR: z.string().optional(),115  CR_WORKSPACE_ROOTS: z.string().optional(),116  CR_RUNTIME_SOCKET: z.string().optional(),117  CR_RUNTIME_TOKEN: z.string().optional(),118  DATABASE_URL: z.string().optional(),119  CR_SECRET_KEY: z.string().optional(),120  CR_AGENTD_URL: z.string().optional(),121  CR_AGENTD_TOKEN: z.string().optional(),122  CR_NODE_ALIAS: z.string().optional(),123  CR_EXEC_HOSTS: z.string().optional(),124  CR_TERM_BUFFER_BYTES: z.coerce.number().int().min(65536).default(4 * 1024 * 1024),125  CR_TRUSTED_PROXIES: z.string().optional(),126  CR_LOCAL_MODELS: z.string().optional(),127  CR_SKILLS_REGISTRY_URL: z.string().optional(),128  CR_WEB_DIR: z.string().optional(),129  CR_LOG_LEVEL: z.string().default('info'),130});131132function readVersion(): string {133  try {134    const p = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');135    const v = (JSON.parse(fs.readFileSync(p, 'utf8')) as { version?: unknown }).version;136    return typeof v === 'string' ? v : '0.0.0';137  } catch {138    return '0.0.0';139  }140}141142function devSecretKey(dataDir: string): Buffer {143  // Development convenience only: a persisted random key so TOTP secrets survive restarts.144  const file = path.join(dataDir, 'dev-secret-key');145  try {146    const hex = fs.readFileSync(file, 'utf8').trim();147    if (/^[0-9a-f]{64}$/i.test(hex)) return Buffer.from(hex, 'hex');148  } catch {149    /* create below */150  }151  const key = randomBytes(32);152  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });153  fs.writeFileSync(file, key.toString('hex'), { mode: 0o600 });154  return key;155}156157export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {158  const e = Env.parse(env);159  const mode: ServerConfig['env'] =160    e.NODE_ENV === 'production' ? 'production' : e.NODE_ENV === 'test' ? 'test' : 'development';161  const prod = mode === 'production';162  const problems: string[] = [];163164  let origin: URL;165  try {166    origin = new URL(e.CR_PUBLIC_ORIGIN);167  } catch {168    throw new Error('CR_PUBLIC_ORIGIN is not a valid URL');169  }170  if (origin.origin !== e.CR_PUBLIC_ORIGIN.replace(/\/$/, '')) {171    problems.push('CR_PUBLIC_ORIGIN must be an exact origin (scheme://host[:port], no path)');172  }173  if (prod && origin.protocol !== 'https:') problems.push('CR_PUBLIC_ORIGIN must be https in production');174  if (prod && !e.DATABASE_URL) problems.push('DATABASE_URL is required in production');175  if (prod && (e.CR_RUNTIME_TOKEN ?? '').length < 32) problems.push('CR_RUNTIME_TOKEN (>= 32 chars) is required in production');176  if (prod && !/^[0-9a-f]{64}$/i.test(e.CR_SECRET_KEY ?? '')) problems.push('CR_SECRET_KEY must be 64 hex characters in production');177  if (e.CR_SECRET_KEY && !/^[0-9a-f]{64}$/i.test(e.CR_SECRET_KEY)) problems.push('CR_SECRET_KEY must be 64 hex characters');178179  const trustedProxies = list(e.CR_TRUSTED_PROXIES ?? '127.0.0.1,::1,10.67.0.1');180  for (const p of trustedProxies) if (!isIpOrCidr(p)) problems.push(`CR_TRUSTED_PROXIES: "${p}" is not an IP or CIDR`);181182  let localModels: LocalModelConfig[] = [];183  if (e.CR_LOCAL_MODELS) {184    try {185      localModels = z.array(LocalModelConfig).parse(JSON.parse(e.CR_LOCAL_MODELS));186    } catch {187      problems.push('CR_LOCAL_MODELS must be a JSON array of {id,name,host,engine,endpoint}');188    }189  }190  for (const [k, v] of [191    ['CR_AGENTD_URL', e.CR_AGENTD_URL],192    ['CR_SKILLS_REGISTRY_URL', e.CR_SKILLS_REGISTRY_URL],193  ] as const) {194    if (v && !/^https?:\/\//.test(v)) problems.push(`${k} must be an http(s) URL`);195  }196  if (problems.length) throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`);197198  const agent = agentProfile(resolveAgentId(e.CR_AGENT));199  const dataDir = path.resolve(e.CR_DATA_DIR ?? path.join(os.homedir(), `${agent.slug}-data`));200  const workspaceRoots = parseWorkspaceRoots(e.CR_WORKSPACE_ROOTS ?? path.join(os.homedir(), 'workspace'));201  if (workspaceRoots.length === 0) throw new Error('CR_WORKSPACE_ROOTS is empty');202  if (new Set(workspaceRoots.map((r) => r.label)).size !== workspaceRoots.length) {203    throw new Error('CR_WORKSPACE_ROOTS labels must be unique');204  }205  const localHttp = origin.protocol === 'http:' && (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1');206  const secureCookies = prod || !localHttp;207  const here = path.dirname(fileURLToPath(import.meta.url));208209  return {210    env: mode,211    version: readVersion(),212    agent,213    port: e.CR_PORT,214    host: e.CR_HOST,215    publicOrigin: origin.origin,216    rpId: origin.hostname,217    dataDir,218    workspaceRoots,219    runtimeSocket: e.CR_RUNTIME_SOCKET ?? path.join(dataDir, 'run', 'runtime.sock'),220    runtimeToken: e.CR_RUNTIME_TOKEN ?? '',221    databaseUrl: e.DATABASE_URL ?? 'postgres://localhost/claude_remote_dev',222    secretKey: e.CR_SECRET_KEY ? Buffer.from(e.CR_SECRET_KEY, 'hex') : devSecretKey(dataDir),223    agentdUrl: e.CR_AGENTD_URL ? e.CR_AGENTD_URL.replace(/\/$/, '') : null,224    agentdToken: e.CR_AGENTD_TOKEN || null,225    nodeAlias: e.CR_NODE_ALIAS || null,226    execHosts: list(e.CR_EXEC_HOSTS),227    termBufferBytes: e.CR_TERM_BUFFER_BYTES,228    trustedProxies,229    localModels,230    skillsRegistryUrl: e.CR_SKILLS_REGISTRY_URL ? e.CR_SKILLS_REGISTRY_URL.replace(/\/$/, '') : null,231    webDir: path.resolve(e.CR_WEB_DIR ?? path.join(here, '..', '..', 'web', 'out')),232    logLevel: e.CR_LOG_LEVEL,233    secureCookies,234    cookieName: secureCookies ? '__Host-cr_sid' : 'cr_sid',235    preAuthCookieName: secureCookies ? '__Host-cr_pre' : 'cr_pre',236    rateLimit: { api: 600, preAuth: 20, status: 240, health: 600 },237    passwordCost: { argonMemoryKiB: 65536, argonPasses: 3, scryptLogN: 17 },238    uploadLimitBytes: 200 * 1024 * 1024,239    wsAuthRecheckMs: 60_000,240    reconcileIntervalMs: 15_000,241    clusterPollMs: 20_000,242  };243}244
Files main
Menu

Theme