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