Repository Public
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