SPB Git

Repository Public

config.ts 7.7 KB 203 lines typescript

1/**2 * Server configuration: zod-validated environment. In production the process refuses to start3 * without AOS_SECRET_KEY (64 hex), DATABASE_URL and an https AOS_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';1213/**14 * Holds the model API key VALUES for one purpose only: masking them in anything the server sends15 * (REST bodies, WebSocket frames). The values are private fields: they never appear in16 * `JSON.stringify(config)`, logs or errors. The server itself never calls a model with them17 * (`@aos/providers` reads the environment on its own).18 */19export class SecretScrubber {20  readonly #values: string[];21  constructor(values: readonly (string | undefined)[]) {22    this.#values = [...new Set(values.filter((v): v is string => typeof v === 'string' && v.length >= 8))];23  }24  get active(): boolean {25    return this.#values.length > 0;26  }27  scrub(text: string): string {28    let out = text;29    for (const v of this.#values) if (out.includes(v)) out = out.split(v).join('[redacted]');30    return out;31  }32  toJSON(): string {33    return '[secrets]';34  }35}3637export interface PasswordCost {38  /** argon2id memory in KiB / passes; scrypt log2(N). Lowered only by the test-suite. */39  argonMemoryKiB: number;40  argonPasses: number;41  scryptLogN: number;42}4344export interface ServerConfig {45  env: 'production' | 'development' | 'test';46  version: string;47  port: number;48  host: string;49  publicOrigin: string;50  rpId: string;51  dataDir: string;52  /** Projects live in `<workspaceRoot>/<rel_path>`. */53  workspaceRoot: string;54  databaseUrl: string;55  secretKey: Buffer;56  trustedProxies: string[];57  webDir: string;58  logLevel: string;59  /** Presence of the model API keys (booleans only; the values stay in the environment). */60  providerKeys: { anthropic: boolean; openai: boolean };61  secrets: SecretScrubber;62  /** `__Host-` + Secure cookies. Disabled only outside production on an http://localhost origin. */63  secureCookies: boolean;64  cookieName: string;65  preAuthCookieName: string;66  /**67   * Requests per minute. `api`: per session (authenticated API). `preAuth`: per client IP, shared68   * by the credential-checking endpoints. `status`: per client IP for anonymous `GET /auth/status`69   * (read-only, called on every page load). `health`: per client IP for `GET /health`.70   */71  rateLimit: { api: number; preAuth: number; status: number; health: number };72  passwordCost: PasswordCost;73  /** How often open WebSockets re-check their auth session (ms). */74  wsAuthRecheckMs: number;75  /** `GET /models` cache (ms) and per-provider listing timeout (ms). */76  modelsCacheMs: number;77  modelsTimeoutMs: number;78  /** `git clone` hard timeout (ms). */79  cloneTimeoutMs: number;80  /** Events WebSocket: replay bound and slow-consumer threshold (bytes buffered). */81  eventsReplayMax: number;82  eventsSlowBytes: number;83}8485const list = (raw: string | undefined): string[] =>86  (raw ?? '')87    .split(',')88    .map((s) => s.trim())89    .filter(Boolean);9091function isIpOrCidr(s: string): boolean {92  const [addr, prefix, extra] = s.split('/');93  if (extra !== undefined || !addr) return false;94  const fam = net.isIP(addr);95  if (!fam) return false;96  if (prefix === undefined) return true;97  if (!/^\d{1,3}$/.test(prefix)) return false;98  return Number(prefix) <= (fam === 4 ? 32 : 128);99}100101const Env = z.object({102  NODE_ENV: z.string().optional(),103  AOS_PORT: z.coerce.number().int().min(1).max(65535).default(8640),104  AOS_HOST: z.string().default('127.0.0.1'),105  AOS_PUBLIC_ORIGIN: z.string().default('http://localhost:8640'),106  AOS_DATA_DIR: z.string().optional(),107  AOS_WORKSPACE_ROOT: z.string().optional(),108  DATABASE_URL: z.string().optional(),109  AOS_SECRET_KEY: z.string().optional(),110  AOS_TRUSTED_PROXIES: z.string().optional(),111  AOS_WEB_DIR: z.string().optional(),112  AOS_LOG_LEVEL: z.string().default('info'),113  ANTHROPIC_API_KEY: z.string().optional(),114  OPENAI_API_KEY: z.string().optional(),115});116117function readVersion(): string {118  try {119    const p = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');120    const v = (JSON.parse(fs.readFileSync(p, 'utf8')) as { version?: unknown }).version;121    return typeof v === 'string' ? v : '0.0.0';122  } catch {123    return '0.0.0';124  }125}126127function devSecretKey(dataDir: string): Buffer {128  // Development convenience only: a persisted random key so TOTP secrets survive restarts.129  const file = path.join(dataDir, 'dev-secret-key');130  try {131    const hex = fs.readFileSync(file, 'utf8').trim();132    if (/^[0-9a-f]{64}$/i.test(hex)) return Buffer.from(hex, 'hex');133  } catch {134    /* create below */135  }136  const key = randomBytes(32);137  fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });138  fs.writeFileSync(file, key.toString('hex'), { mode: 0o600 });139  return key;140}141142export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {143  const e = Env.parse(env);144  const mode: ServerConfig['env'] =145    e.NODE_ENV === 'production' ? 'production' : e.NODE_ENV === 'test' ? 'test' : 'development';146  const prod = mode === 'production';147  const problems: string[] = [];148149  let origin: URL;150  try {151    origin = new URL(e.AOS_PUBLIC_ORIGIN);152  } catch {153    throw new Error('AOS_PUBLIC_ORIGIN is not a valid URL');154  }155  if (origin.origin !== e.AOS_PUBLIC_ORIGIN.replace(/\/$/, '')) {156    problems.push('AOS_PUBLIC_ORIGIN must be an exact origin (scheme://host[:port], no path)');157  }158  if (prod && origin.protocol !== 'https:') problems.push('AOS_PUBLIC_ORIGIN must be https in production');159  if (prod && !e.DATABASE_URL) problems.push('DATABASE_URL is required in production');160  if (prod && !/^[0-9a-f]{64}$/i.test(e.AOS_SECRET_KEY ?? '')) problems.push('AOS_SECRET_KEY must be 64 hex characters in production');161  if (e.AOS_SECRET_KEY && !/^[0-9a-f]{64}$/i.test(e.AOS_SECRET_KEY)) problems.push('AOS_SECRET_KEY must be 64 hex characters');162163  const trustedProxies = list(e.AOS_TRUSTED_PROXIES ?? '127.0.0.1,::1,10.67.0.1');164  for (const p of trustedProxies) if (!isIpOrCidr(p)) problems.push(`AOS_TRUSTED_PROXIES: "${p}" is not an IP or CIDR`);165166  if (problems.length) throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`);167168  const dataDir = path.resolve(e.AOS_DATA_DIR ?? path.join(os.homedir(), 'agent-os-data'));169  const workspaceRoot = path.resolve(e.AOS_WORKSPACE_ROOT ?? path.join(os.homedir(), 'workspace'));170  const localHttp = origin.protocol === 'http:' && (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1');171  const secureCookies = prod || !localHttp;172  const here = path.dirname(fileURLToPath(import.meta.url));173174  return {175    env: mode,176    version: readVersion(),177    port: e.AOS_PORT,178    host: e.AOS_HOST,179    publicOrigin: origin.origin,180    rpId: origin.hostname,181    dataDir,182    workspaceRoot,183    databaseUrl: e.DATABASE_URL ?? 'postgres://localhost/agent_os_dev',184    secretKey: e.AOS_SECRET_KEY ? Buffer.from(e.AOS_SECRET_KEY, 'hex') : devSecretKey(dataDir),185    trustedProxies,186    webDir: path.resolve(e.AOS_WEB_DIR ?? path.join(here, '..', '..', 'web', 'out')),187    logLevel: e.AOS_LOG_LEVEL,188    providerKeys: { anthropic: Boolean(e.ANTHROPIC_API_KEY), openai: Boolean(e.OPENAI_API_KEY) },189    secrets: new SecretScrubber([e.ANTHROPIC_API_KEY, e.OPENAI_API_KEY]),190    secureCookies,191    cookieName: secureCookies ? '__Host-aos_sid' : 'aos_sid',192    preAuthCookieName: secureCookies ? '__Host-aos_pre' : 'aos_pre',193    rateLimit: { api: 600, preAuth: 20, status: 240, health: 600 },194    passwordCost: { argonMemoryKiB: 65536, argonPasses: 3, scryptLogN: 17 },195    wsAuthRecheckMs: 60_000,196    modelsCacheMs: 10 * 60_000,197    modelsTimeoutMs: 10_000,198    cloneTimeoutMs: 10 * 60_000,199    eventsReplayMax: 5000,200    eventsSlowBytes: 1024 * 1024,201  };202}203
Files main
Menu

Theme