diff --git a/apps/server/package.json b/apps/server/package.json index 0a6c8883..24edef9e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@browseros/server", - "version": "0.0.41", + "version": "0.0.42", "description": "BrowserOS server", "type": "module", "main": "./src/index.ts", diff --git a/apps/server/src/api/services/chat-service.ts b/apps/server/src/api/services/chat-service.ts index 4a8b9714..9ae71c62 100644 --- a/apps/server/src/api/services/chat-service.ts +++ b/apps/server/src/api/services/chat-service.ts @@ -11,6 +11,7 @@ import { MCPServerConfig } from '@google/gemini-cli-core' import type { HonoSSEStream } from '../../agent/provider-adapter/types' import type { SessionManager } from '../../agent/session' import type { ProviderConfig, ResolvedAgentConfig } from '../../agent/types' +import { INLINED_ENV } from '../../env' import { fetchBrowserOSConfig, getLLMConfigFromProvider, @@ -106,7 +107,7 @@ export class ChatService { request: ChatRequest, ): Promise { if (request.provider === LLM_PROVIDERS.BROWSEROS) { - const configUrl = process.env.BROWSEROS_CONFIG_URL + const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL if (!configUrl) { throw new Error( 'BROWSEROS_CONFIG_URL environment variable is required for BrowserOS provider', diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e949efee..4b816a62 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -11,6 +11,7 @@ import path from 'node:path' import { Command, InvalidArgumentError } from 'commander' import { z } from 'zod' +import { INLINED_ENV, REQUIRED_FOR_PRODUCTION } from './env' import { VERSION } from './version' const portSchema = z.number().int() @@ -32,52 +33,47 @@ export const ServerConfigSchema = z.object({ export type ServerConfig = z.infer -type PartialConfig = { - cdpPort?: number | null - serverPort?: number - agentPort?: number - extensionPort?: number - resourcesDir?: string - executionDir?: string - mcpAllowRemote?: boolean - codegenServiceUrl?: string - instanceClientId?: string - instanceInstallId?: string - instanceBrowserosVersion?: string - instanceChromiumVersion?: string -} +type PartialConfig = Partial> export type ConfigResult = | { ok: true; value: T } | { ok: false; error: string } +interface ParsedCliArgs { + configPath?: string + cwd: string + overrides: PartialConfig +} + export function loadServerConfig( argv: string[] = process.argv, - env: NodeJS.ProcessEnv = process.env, ): ConfigResult { - // 1. Parse CLI (commander with exitOverride - throws instead of exit) - const cli = parseCli(argv) + // 1. Parse CLI args + const cli = parseCliArgs(argv) if (!cli.ok) return cli - // 2. Load config file (only if --config provided) - const file = loadConfigFile(cli.value.configPath) + // 2. Parse config file (only if --config provided) + const file = parseConfigFile(cli.value.configPath) if (!file.ok) return file - // 3. Load from environment - const envConfig = loadEnv(env) + // 3. Parse runtime environment variables + const runtimeEnv = parseRuntimeEnv() // 4. Merge: Defaults < Env < File < CLI - const merged = merge( - defaults(cli.value.cwd), - envConfig, + const merged = mergeConfigs( + getDefaults(cli.value.cwd), + runtimeEnv, file.value, cli.value.overrides, ) - // 5. agentPort is deprecated - always use serverPort + // 5. Add build-time inlined values + merged.codegenServiceUrl = INLINED_ENV.CODEGEN_SERVICE_URL + + // 6. agentPort is deprecated - always equals serverPort merged.agentPort = merged.serverPort - // 6. Validate with Zod (single source of truth) + // 7. Validate with Zod const result = ServerConfigSchema.safeParse(merged) if (!result.success) { const errors = result.error.issues @@ -89,16 +85,14 @@ export function loadServerConfig( } } + // 8. Validate required inlined env vars for production + const inlinedValidation = validateInlinedEnv() + if (!inlinedValidation.ok) return inlinedValidation + return { ok: true, value: result.data } } -interface CliResult { - configPath?: string - cwd: string - overrides: PartialConfig -} - -function parseCli(argv: string[]): ConfigResult { +function parseCliArgs(argv: string[]): ConfigResult { const program = new Command() try { @@ -179,15 +173,15 @@ function parseCli(argv: string[]): ConfigResult { value: { configPath: opts.config, cwd, - overrides: filterUndefined({ + overrides: omitUndefined({ cdpPort: opts.cdpPort, serverPort: opts.serverPort ?? opts.httpMcpPort, extensionPort: opts.extensionPort, resourcesDir: opts.resourcesDir - ? resolvePath(opts.resourcesDir, cwd) + ? toAbsolutePath(opts.resourcesDir, cwd) : undefined, executionDir: opts.executionDir - ? resolvePath(opts.executionDir, cwd) + ? toAbsolutePath(opts.executionDir, cwd) : undefined, mcpAllowRemote: opts.allowRemoteInMcp || undefined, }), @@ -203,14 +197,14 @@ function parsePortArg(value: string): number { return port } -function loadConfigFile(explicitPath?: string): ConfigResult { - if (!explicitPath) { +function parseConfigFile(filePath?: string): ConfigResult { + if (!filePath) { return { ok: true, value: {} } } - const absPath = path.isAbsolute(explicitPath) - ? explicitPath - : path.resolve(process.cwd(), explicitPath) + const absPath = path.isAbsolute(filePath) + ? filePath + : path.resolve(process.cwd(), filePath) if (!fs.existsSync(absPath)) { return { ok: false, error: `Config file not found: ${absPath}` } @@ -223,18 +217,12 @@ function loadConfigFile(explicitPath?: string): ConfigResult { return { ok: true, - value: filterUndefined({ + value: omitUndefined({ cdpPort: cfg.ports?.cdp, serverPort: cfg.ports?.server ?? cfg.ports?.http_mcp, extensionPort: cfg.ports?.extension, - resourcesDir: resolvePathIfString( - cfg.directories?.resources, - configDir, - ), - executionDir: resolvePathIfString( - cfg.directories?.execution, - configDir, - ), + resourcesDir: parseAbsolutePath(cfg.directories?.resources, configDir), + executionDir: parseAbsolutePath(cfg.directories?.execution, configDir), mcpAllowRemote: cfg.flags?.allow_remote_in_mcp === true ? true : undefined, instanceClientId: @@ -261,36 +249,52 @@ function loadConfigFile(explicitPath?: string): ConfigResult { } } -function loadEnv(env: NodeJS.ProcessEnv): PartialConfig { +function parseRuntimeEnv(): PartialConfig { const cwd = process.cwd() - return filterUndefined({ - cdpPort: env.BROWSEROS_CDP_PORT - ? safeParseInt(env.BROWSEROS_CDP_PORT) + return omitUndefined({ + cdpPort: process.env.BROWSEROS_CDP_PORT + ? safeParseInt(process.env.BROWSEROS_CDP_PORT) : undefined, - serverPort: env.BROWSEROS_SERVER_PORT - ? safeParseInt(env.BROWSEROS_SERVER_PORT) + serverPort: process.env.BROWSEROS_SERVER_PORT + ? safeParseInt(process.env.BROWSEROS_SERVER_PORT) : undefined, - extensionPort: env.BROWSEROS_EXTENSION_PORT - ? safeParseInt(env.BROWSEROS_EXTENSION_PORT) + extensionPort: process.env.BROWSEROS_EXTENSION_PORT + ? safeParseInt(process.env.BROWSEROS_EXTENSION_PORT) : undefined, - resourcesDir: env.BROWSEROS_RESOURCES_DIR - ? resolvePath(env.BROWSEROS_RESOURCES_DIR, cwd) + resourcesDir: process.env.BROWSEROS_RESOURCES_DIR + ? toAbsolutePath(process.env.BROWSEROS_RESOURCES_DIR, cwd) : undefined, - executionDir: env.BROWSEROS_EXECUTION_DIR - ? resolvePath(env.BROWSEROS_EXECUTION_DIR, cwd) + executionDir: process.env.BROWSEROS_EXECUTION_DIR + ? toAbsolutePath(process.env.BROWSEROS_EXECUTION_DIR, cwd) : undefined, - codegenServiceUrl: env.CODEGEN_SERVICE_URL, - instanceInstallId: env.BROWSEROS_INSTALL_ID, - instanceClientId: env.BROWSEROS_CLIENT_ID, + instanceInstallId: process.env.BROWSEROS_INSTALL_ID, + instanceClientId: process.env.BROWSEROS_CLIENT_ID, }) } -function safeParseInt(value: string): number | undefined { - const num = parseInt(value, 10) - return Number.isNaN(num) ? undefined : num +function validateInlinedEnv(): ConfigResult { + if (process.env.NODE_ENV !== 'production') { + return { ok: true, value: undefined } + } + + const missing: string[] = [] + for (const varName of REQUIRED_FOR_PRODUCTION) { + if (!INLINED_ENV[varName]) { + missing.push(varName) + } + } + + if (missing.length > 0) { + return { + ok: false, + error: `Missing required environment variables for production:\n${missing.map((v) => ` - ${v}`).join('\n')}`, + } + } + + return { ok: true, value: undefined } } -function defaults(cwd: string): PartialConfig { +function getDefaults(cwd: string): PartialConfig { return { cdpPort: null, resourcesDir: cwd, @@ -299,7 +303,7 @@ function defaults(cwd: string): PartialConfig { } } -function merge(...configs: PartialConfig[]): PartialConfig { +function mergeConfigs(...configs: PartialConfig[]): PartialConfig { const result: PartialConfig = {} for (const config of configs) { for (const [key, value] of Object.entries(config)) { @@ -311,22 +315,22 @@ function merge(...configs: PartialConfig[]): PartialConfig { return result } -function filterUndefined>( - obj: T, -): Partial { +function safeParseInt(value: string): number | undefined { + const num = parseInt(value, 10) + return Number.isNaN(num) ? undefined : num +} + +function omitUndefined>(obj: T): Partial { return Object.fromEntries( Object.entries(obj).filter(([_, v]) => v !== undefined), ) as Partial } -function resolvePath(target: string, baseDir: string): string { +function toAbsolutePath(target: string, baseDir: string): string { return path.isAbsolute(target) ? target : path.resolve(baseDir, target) } -function resolvePathIfString( - val: unknown, - baseDir: string, -): string | undefined { +function parseAbsolutePath(val: unknown, baseDir: string): string | undefined { if (typeof val !== 'string') return undefined - return resolvePath(val, baseDir) + return toAbsolutePath(val, baseDir) } diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 00000000..6aecaef5 --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 BrowserOS + * + * Build-time inlined environment variables. + * + * IMPORTANT: Values here are replaced at build time by Bun's `--env inline` flag. + * The `process.env.X` access MUST be direct (not via a variable) for inlining to work. + * + * These variables are: + * - Replaced with literal strings in production builds + * - Read from actual env vars during development + * + * For runtime-only env vars (like BROWSEROS_CDP_PORT), use process.env directly. + */ + +export const INLINED_ENV = { + SENTRY_DSN: process.env.SENTRY_DSN, + CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL, + POSTHOG_API_KEY: process.env.POSTHOG_API_KEY, + BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL, +} as const + +export const REQUIRED_FOR_PRODUCTION = [ + 'SENTRY_DSN', + 'CODEGEN_SERVICE_URL', + 'POSTHOG_API_KEY', + 'BROWSEROS_CONFIG_URL', +] as const satisfies readonly (keyof typeof INLINED_ENV)[] diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9986cd2c..0b3e49d9 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -17,6 +17,7 @@ if (typeof Bun === 'undefined') { // Import polyfills first import './lib/polyfill' +import { EXIT_CODES } from '@browseros/shared/constants/exit-codes' import { CommanderError } from 'commander' import { loadServerConfig } from './config' import { Sentry } from './lib/sentry' @@ -27,7 +28,7 @@ const configResult = loadServerConfig() if (!configResult.ok) { Sentry.captureException(new Error(configResult.error)) console.error(configResult.error) - process.exit(1) + process.exit(EXIT_CODES.GENERAL_ERROR) } const app = new Application(configResult.value) @@ -40,7 +41,7 @@ try { } Sentry.captureException(error) console.error('Failed to start server:', error) - process.exit(1) + process.exit(EXIT_CODES.GENERAL_ERROR) } process.on('SIGINT', () => app.stop()) diff --git a/apps/server/src/lib/clients/llm/config.ts b/apps/server/src/lib/clients/llm/config.ts index 18263fd4..ba829ca9 100644 --- a/apps/server/src/lib/clients/llm/config.ts +++ b/apps/server/src/lib/clients/llm/config.ts @@ -7,6 +7,7 @@ */ import { LLM_PROVIDERS, type LLMConfig } from '@browseros/shared/schemas/llm' +import { INLINED_ENV } from '../../../env' import { logger } from '../../logger' import { fetchBrowserOSConfig, getLLMConfigFromProvider } from '../gateway' import type { ResolvedLLMConfig } from './types' @@ -22,7 +23,7 @@ export async function resolveLLMConfig( return config as ResolvedLLMConfig } - const configUrl = process.env.BROWSEROS_CONFIG_URL + const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL if (!configUrl) { throw new Error( 'BROWSEROS_CONFIG_URL environment variable is required for BrowserOS provider', diff --git a/apps/server/src/lib/metrics.ts b/apps/server/src/lib/metrics.ts index 630413ff..1832c01b 100644 --- a/apps/server/src/lib/metrics.ts +++ b/apps/server/src/lib/metrics.ts @@ -5,7 +5,9 @@ import { EXTERNAL_URLS } from '@browseros/shared/constants/urls' import { PostHog } from 'posthog-node' -const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY +import { INLINED_ENV } from '../env' + +const POSTHOG_API_KEY = INLINED_ENV.POSTHOG_API_KEY const EVENT_PREFIX = 'browseros.server.' export interface MetricsConfig { diff --git a/apps/server/src/lib/rate-limiter/fetch-config.ts b/apps/server/src/lib/rate-limiter/fetch-config.ts index 383acd03..92302e14 100644 --- a/apps/server/src/lib/rate-limiter/fetch-config.ts +++ b/apps/server/src/lib/rate-limiter/fetch-config.ts @@ -8,6 +8,7 @@ import { RATE_LIMITS } from '@browseros/shared/constants/limits' +import { INLINED_ENV } from '../../env' import { fetchBrowserOSConfig } from '../clients/gateway' import { logger } from '../logger' @@ -26,7 +27,7 @@ export async function fetchDailyRateLimit( return RATE_LIMITS.DEV_DAILY } - const configUrl = process.env.BROWSEROS_CONFIG_URL + const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL if (!configUrl) { logger.info('No BROWSEROS_CONFIG_URL, using default rate limit', { dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY, diff --git a/apps/server/src/lib/sentry.ts b/apps/server/src/lib/sentry.ts index 1dc5db88..bff7ae5c 100644 --- a/apps/server/src/lib/sentry.ts +++ b/apps/server/src/lib/sentry.ts @@ -4,13 +4,14 @@ */ import * as Sentry from '@sentry/bun' +import { INLINED_ENV } from '../env' import { VERSION } from '../version' const SENTRY_ENVIRONMENT = process.env.NODE_ENV || 'development' // Ensure to call this before importing any other modules! Sentry.init({ - dsn: process.env.SENTRY_DSN, + dsn: INLINED_ENV.SENTRY_DSN, // Adds request headers and IP for users, for more info visit: // https://docs.sentry.io/platforms/javascript/guides/bun/configuration/options/#sendDefaultPii sendDefaultPii: true, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index ef0235c8..602e96ed 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,6 +18,7 @@ import { McpContext } from './browser/cdp/context' import { ControllerBridge } from './browser/extension/bridge' import { ControllerContext } from './browser/extension/context' import type { ServerConfig } from './config' +import { INLINED_ENV } from './env' import { initializeDb } from './lib/db' import { identity } from './lib/identity' import { logger } from './lib/logger' @@ -139,7 +140,7 @@ export class Application { logger.warn('Metrics disabled: missing POSTHOG_API_KEY') } - if (!process.env.SENTRY_DSN) { + if (!INLINED_ENV.SENTRY_DSN) { logger.warn('Sentry disabled: missing SENTRY_DSN') } diff --git a/apps/server/tests/config.test.ts b/apps/server/tests/config.test.ts index 7acfe04d..689788bf 100644 --- a/apps/server/tests/config.test.ts +++ b/apps/server/tests/config.test.ts @@ -84,11 +84,11 @@ describe('loadServerConfig', () => { describe('environment variables', () => { it('reads from env when CLI not provided', () => { - const result = loadServerConfig(['bun', 'src/index.ts'], { - BROWSEROS_CDP_PORT: '9222', - BROWSEROS_SERVER_PORT: '9223', - BROWSEROS_EXTENSION_PORT: '9224', - }) + process.env.BROWSEROS_CDP_PORT = '9222' + process.env.BROWSEROS_SERVER_PORT = '9223' + process.env.BROWSEROS_EXTENSION_PORT = '9224' + + const result = loadServerConfig(['bun', 'src/index.ts']) assert.strictEqual(result.ok, true) if (!result.ok) return @@ -100,13 +100,15 @@ describe('loadServerConfig', () => { }) it('CLI takes precedence over env', () => { - const result = loadServerConfig( - ['bun', 'src/index.ts', '--server-port=1111', '--extension-port=3333'], - { - BROWSEROS_SERVER_PORT: '9999', - BROWSEROS_EXTENSION_PORT: '9999', - }, - ) + process.env.BROWSEROS_SERVER_PORT = '9999' + process.env.BROWSEROS_EXTENSION_PORT = '9999' + + const result = loadServerConfig([ + 'bun', + 'src/index.ts', + '--server-port=1111', + '--extension-port=3333', + ]) assert.strictEqual(result.ok, true) if (!result.ok) return @@ -188,10 +190,13 @@ describe('loadServerConfig', () => { }), ) - const result = loadServerConfig( - ['bun', 'src/index.ts', `--config=${configPath}`], - { BROWSEROS_SERVER_PORT: '9999' }, - ) + process.env.BROWSEROS_SERVER_PORT = '9999' + + const result = loadServerConfig([ + 'bun', + 'src/index.ts', + `--config=${configPath}`, + ]) assert.strictEqual(result.ok, true) if (!result.ok) return diff --git a/packages/shared/src/constants/urls.ts b/packages/shared/src/constants/urls.ts index 14a0787c..8c190a7f 100644 --- a/packages/shared/src/constants/urls.ts +++ b/packages/shared/src/constants/urls.ts @@ -9,5 +9,5 @@ export const EXTERNAL_URLS = { KLAVIS_PROXY: 'https://llm.browseros.com/klavis', POSTHOG_DEFAULT: 'https://us.i.posthog.com', - CODEGEN_SERVICE: 'https://browseros-codegen.fly.dev', + CODEGEN_SERVICE: 'https://graph.browseros.com', } as const diff --git a/scripts/build/server.ts b/scripts/build/server.ts index 140b79e1..c4877cdd 100644 --- a/scripts/build/server.ts +++ b/scripts/build/server.ts @@ -13,8 +13,8 @@ * bun scripts/build/server.ts --mode=dev [--target=all] * * Modes: - * prod - Clean environment build using only .env.prod - * dev - Normal build using shell environment + .env.dev + * prod - Clean environment build using only .env.production + * dev - Normal build using shell environment + .env.development * * Targets: * linux-x64, linux-arm64, windows-x64, darwin-arm64, darwin-x64, all @@ -135,7 +135,7 @@ function validateProdEnv(envVars: Record): void { for (const varName of missing) { console.error(` - ${varName}`) } - console.error(`\n Please set these in .env.prod`) + console.error(`\n Please set these in .env.production`) process.exit(1) } } @@ -283,7 +283,7 @@ async function main() { `\n Tip: bun run version:server [patch|minor|major] to bump version`, ) - const envFile = mode === 'prod' ? '.env.prod' : '.env.dev' + const envFile = mode === 'prod' ? '.env.production' : '.env.development' const envPath = join(rootDir, envFile) console.log(`\nšŸ“„ Loading environment from ${envFile}...`)