diff --git a/src/browser/index.ts b/src/browser/index.ts index 02b7cf0..df560bf 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -6,10 +6,10 @@ import { chromium } from 'playwright'; import { z } from 'zod'; import { validateUrl } from './security.js'; import { readResource } from './transport.js'; +import { parseBrowserEnv } from '../config/env.js'; -const secret = process.env.BROWSER_SECRET; -if (!secret || secret.length < 24) - throw new Error('BROWSER_SECRET must be at least 24 characters.'); +const browserEnv = parseBrowserEnv(); +const secret = browserEnv.BROWSER_SECRET; const app = new Hono<{ Bindings: HttpBindings }>(); app.use('*', bodyLimit({ maxSize: 16_384 })); app.use('*', async (c, next) => { @@ -130,6 +130,6 @@ app.post('/browse', async (c) => { app.get('/health', (c) => c.json({ ok: true })); serve({ fetch: app.fetch, - hostname: process.env.BROWSER_HOST ?? '127.0.0.1', - port: Number(process.env.BROWSER_PORT ?? 4311), + hostname: browserEnv.BROWSER_HOST, + port: browserEnv.BROWSER_PORT, }); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..c9b061d --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,7 @@ +export { + serverEnvSchema, + serverBrowserFieldsSchema, + parseServerEnv, + parseBrowserEnv, + type ServerEnv, +} from './env/index.js'; \ No newline at end of file diff --git a/src/config/env/browser.ts b/src/config/env/browser.ts new file mode 100644 index 0000000..2674adb --- /dev/null +++ b/src/config/env/browser.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const browserEnvSchema = z.object({ + BROWSER_SECRET: z + .string() + .min(24, 'BROWSER_SECRET must be at least 24 characters.'), + BROWSER_HOST: z.string().min(1).default('127.0.0.1'), + BROWSER_PORT: z.coerce.number().int().positive().max(65_535).default(4311), + BROWSER_URL: z.string().url().optional(), +}); + +export type BrowserEnv = z.infer; \ No newline at end of file diff --git a/src/config/env/computer.ts b/src/config/env/computer.ts new file mode 100644 index 0000000..bfebd73 --- /dev/null +++ b/src/config/env/computer.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const computerEnvSchema = z.object({ + COMPUTER_SUPERVISOR_URL: z.string().url().optional(), + COMPUTER_SUPERVISOR_TOKEN: z.string().optional(), + COMPUTER_TOKEN: z.string().optional(), + COMPUTER_NAMESPACE: z.string().min(1).default('opendots'), + COMPUTER_MEMORY_BYTES: z.coerce.number().int().positive().optional(), + COMPUTER_RUNTIME: z.string().optional(), + ENGINE_SOCKET: z.string().optional(), +}); + +export type ComputerEnv = z.infer; \ No newline at end of file diff --git a/src/config/env/core.ts b/src/config/env/core.ts new file mode 100644 index 0000000..c43bbed --- /dev/null +++ b/src/config/env/core.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const LOCAL_HOSTS = ['127.0.0.1', '::1', 'localhost'] as const; +export const isLocalHost = (host: string): boolean => + (LOCAL_HOSTS as readonly string[]).includes(host); + +export const coreEnvSchema = z.object({ + HOST: z.string().min(1).default('127.0.0.1'), + PORT: z.coerce.number().int().positive().max(65_535).default(4310), + DATABASE_PATH: z.string().min(1).default('data/opendots.sqlite'), + OWNER_ID: z.string().min(1).default('opendots-owner'), + OWNER_TOKEN: z.string().min(24).optional(), + INTELLIGENCE_API_KEY: z.string().optional(), + INTELLIGENCE_API_URL: z.string().url().optional(), + INTELLIGENCE_WS_URL: z.string().url().optional(), + OPENAI_API_KEY: z.string().optional(), + OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'), + OPENAI_MODEL: z.string().optional(), + APP_ORIGIN: z.string().url().optional(), + NODE_ENV: z + .enum(['development', 'production', 'test']) + .default('production'), +}); + +export type CoreEnv = z.infer; + +export const EXTERNAL_OWNER_TOKEN_ERROR = + 'External binding requires an OWNER_TOKEN of at least 24 characters.'; \ No newline at end of file diff --git a/src/config/env/index.ts b/src/config/env/index.ts new file mode 100644 index 0000000..aa0f53f --- /dev/null +++ b/src/config/env/index.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { + browserEnvSchema, + type BrowserEnv, +} from './browser.js'; +import { + computerEnvSchema, + type ComputerEnv, +} from './computer.js'; +import { + coreEnvSchema, + EXTERNAL_OWNER_TOKEN_ERROR, + isLocalHost, + type CoreEnv, +} from './core.js'; +import { + parallelEnvSchema, + type ParallelEnv, +} from './parallel.js'; +import { slackEnvSchema, type SlackEnv } from './slack.js'; +import { voiceEnvSchema, type VoiceEnv } from './voice.js'; + +export const serverBrowserFieldsSchema = z.object({ + BROWSER_URL: z.string().url().optional(), + BROWSER_SECRET: z.string().optional(), +}); + +export const serverEnvSchema = z.object({ + ...coreEnvSchema.shape, + ...parallelEnvSchema.shape, + ...slackEnvSchema.shape, + ...voiceEnvSchema.shape, + ...computerEnvSchema.shape, + ...serverBrowserFieldsSchema.shape, +}); + +export type ServerEnv = CoreEnv & + ParallelEnv & + SlackEnv & + VoiceEnv & + ComputerEnv & { + BROWSER_URL?: string; + BROWSER_SECRET?: string; + }; + +const formatIssues = (issues: z.ZodIssue[]): string => + issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; '); + +export function parseServerEnv(input: NodeJS.ProcessEnv = process.env): ServerEnv { + const parsed = serverEnvSchema.safeParse(input); + if (!parsed.success) { + throw new Error(`Invalid server environment: ${formatIssues(parsed.error.issues)}`); + } + const env = parsed.data; + if (!isLocalHost(env.HOST) && !env.OWNER_TOKEN) { + throw new Error(EXTERNAL_OWNER_TOKEN_ERROR); + } + return env; +} + +export function parseBrowserEnv( + input: NodeJS.ProcessEnv = process.env, +): BrowserEnv { + const parsed = browserEnvSchema.safeParse(input); + if (!parsed.success) { + throw new Error(`Invalid browser environment: ${formatIssues(parsed.error.issues)}`); + } + return parsed.data; +} \ No newline at end of file diff --git a/src/config/env/parallel.ts b/src/config/env/parallel.ts new file mode 100644 index 0000000..e49cd1d --- /dev/null +++ b/src/config/env/parallel.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const WEB_SEARCH_PROVIDERS = ['parallel', 'browser', 'disabled'] as const; +export type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; + +export const parallelEnvSchema = z.object({ + WEB_SEARCH_PROVIDER: z.enum(WEB_SEARCH_PROVIDERS).default('parallel'), + PARALLEL_API_KEY: z.string().optional(), +}); + +export type ParallelEnv = z.infer; \ No newline at end of file diff --git a/src/config/env/slack.ts b/src/config/env/slack.ts new file mode 100644 index 0000000..ac5970b --- /dev/null +++ b/src/config/env/slack.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const slackEnvSchema = z.object({ + SLACK_CHANNEL_NAME: z.string().optional(), + SLACK_TEAM_ID: z.string().optional(), + SLACK_USER_IDS: z + .string() + .default('') + .transform((value) => + value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ), + SLACK_DOT_ID: z.string().optional(), +}); + +export type SlackEnv = z.infer; \ No newline at end of file diff --git a/src/config/env/voice.ts b/src/config/env/voice.ts new file mode 100644 index 0000000..35087ae --- /dev/null +++ b/src/config/env/voice.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const voiceEnvSchema = z.object({ + VOICE_API_KEY: z.string().optional(), + VOICE_MODEL: z.string().optional(), + VOICE_NAME: z.string().min(1).default('marin'), +}); + +export type VoiceEnv = z.infer; \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index 6f8978e..9c84d37 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,3 @@ -import { webSearchProvider } from './parallel.js'; import { createShutdown } from './shutdown.js'; import { reportChannelFailure, safeFailure } from './slack-channel.js'; import { serve } from '@hono/node-server'; @@ -8,51 +7,19 @@ import { Runner } from './runner.js'; import { createApp } from './app.js'; import { WorkspaceStore } from './workspace.js'; import { Platform } from './platform.js'; -import type { PlatformConfig } from './platform-config.js'; -const host = process.env.HOST ?? '127.0.0.1'; -const port = Number(process.env.PORT ?? 4310); -const ownerToken = process.env.OWNER_TOKEN; -if ( - !['127.0.0.1', '::1', 'localhost'].includes(host) && - (!ownerToken || ownerToken.length < 24) -) - throw new Error( - 'External binding requires an OWNER_TOKEN of at least 24 characters.', - ); -const database = process.env.DATABASE_PATH ?? 'data/opendots.sqlite'; -const store = new Store(database); -const workspace = new WorkspaceStore( - database, - process.env.OWNER_ID ?? 'opendots-owner', -); -const config: PlatformConfig = { - intelligenceKey: process.env.INTELLIGENCE_API_KEY, - intelligenceApiUrl: process.env.INTELLIGENCE_API_URL || undefined, - intelligenceWsUrl: process.env.INTELLIGENCE_WS_URL || undefined, - apiKey: process.env.OPENAI_API_KEY, - model: process.env.OPENAI_MODEL, - baseUrl: process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1', - webSearchProvider: webSearchProvider(process.env.WEB_SEARCH_PROVIDER), - parallelApiKey: process.env.PARALLEL_API_KEY, - browserUrl: process.env.BROWSER_URL, - browserSecret: process.env.BROWSER_SECRET, - computerSupervisorUrl: process.env.COMPUTER_SUPERVISOR_URL, - computerSupervisorToken: process.env.COMPUTER_SUPERVISOR_TOKEN, - computerToken: process.env.COMPUTER_TOKEN, - computerNamespace: process.env.COMPUTER_NAMESPACE, - voiceKey: process.env.VOICE_API_KEY, - voiceModel: process.env.VOICE_MODEL, - voiceName: process.env.VOICE_NAME ?? 'marin', - slackChannel: process.env.SLACK_CHANNEL_NAME, - slackTeam: process.env.SLACK_TEAM_ID, - slackUsers: (process.env.SLACK_USER_IDS ?? '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean), - slackDotId: process.env.SLACK_DOT_ID || undefined, - runtimeUrl: `http://${host === '::1' ? '[::1]' : '127.0.0.1'}:${port}/api/copilotkit`, - ownerToken, -}; +import { platformConfigFromEnv } from './platform-config.js'; +import { parseServerEnv } from '../config/env.js'; + +const env = parseServerEnv(); +const host = env.HOST; +const port = env.PORT; +const store = new Store(env.DATABASE_PATH); +const workspace = new WorkspaceStore(env.DATABASE_PATH, env.OWNER_ID); +const runtimeUrl = `http://${host === '::1' ? '[::1]' : '127.0.0.1'}:${port}/api/copilotkit`; +const config = platformConfigFromEnv(env, { + runtimeUrl, + ownerToken: env.OWNER_TOKEN, +}); const platform = new Platform(store, workspace, config); const researchConfig = { mode: 'live' as const, @@ -81,16 +48,15 @@ const runner = new Runner( const wsOrigin = new URL( config.intelligenceWsUrl ?? 'wss://realtime.intelligence.copilotkit.ai', ).origin; +const origin = + env.APP_ORIGIN ?? + (env.NODE_ENV === 'development' ? 'http://127.0.0.1:5173' : undefined); const app = createApp({ store, runner, config: researchConfig, - ownerToken, - origin: - process.env.APP_ORIGIN ?? - (process.env.NODE_ENV === 'development' - ? 'http://127.0.0.1:5173' - : undefined), + ownerToken: config.ownerToken, + origin, platform, }); app.use('*', async (c, next) => { @@ -129,4 +95,4 @@ const shutdown = createShutdown({ reportChannelFailure(operation, [safeFailure(error)]), }); process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); +process.on('SIGINT', shutdown); \ No newline at end of file diff --git a/src/server/parallel.ts b/src/server/parallel.ts index cf6f613..03cf153 100644 --- a/src/server/parallel.ts +++ b/src/server/parallel.ts @@ -3,19 +3,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { z } from 'zod'; -export type WebSearchProvider = 'parallel' | 'browser' | 'disabled'; +import type { WebSearchProvider } from '../config/env/parallel.js'; +export type { WebSearchProvider }; export interface WebConfig { webSearchProvider?: WebSearchProvider; parallelApiKey?: string; } -export function webSearchProvider(value?: string): WebSearchProvider { - if (!value) return 'parallel'; - if (value === 'parallel' || value === 'browser' || value === 'disabled') - return value; - throw new Error( - 'WEB_SEARCH_PROVIDER must be parallel, browser, or disabled.', - ); -} const source = z.object({ url: z .string() diff --git a/src/server/platform-config.ts b/src/server/platform-config.ts index 4d4e8ad..404610d 100644 --- a/src/server/platform-config.ts +++ b/src/server/platform-config.ts @@ -1,5 +1,7 @@ +import type { ServerEnv } from '../config/env.js'; import type { WebConfig } from './parallel.js'; import type { SetupStatus } from '../shared/types.js'; + export interface PlatformConfig extends WebConfig { intelligenceKey?: string; intelligenceApiUrl?: string; @@ -23,9 +25,45 @@ export interface PlatformConfig extends WebConfig { runtimeUrl: string; ownerToken?: string; } + +/** + * Project the parsed server env (uppercase keys) onto the runtime + * {@link PlatformConfig} shape consumed by the rest of the server. + */ +export function platformConfigFromEnv( + env: ServerEnv, + overrides: { runtimeUrl: string; ownerToken?: string }, +): PlatformConfig { + return { + webSearchProvider: env.WEB_SEARCH_PROVIDER, + parallelApiKey: env.PARALLEL_API_KEY, + intelligenceKey: env.INTELLIGENCE_API_KEY, + intelligenceApiUrl: env.INTELLIGENCE_API_URL, + intelligenceWsUrl: env.INTELLIGENCE_WS_URL, + apiKey: env.OPENAI_API_KEY, + model: env.OPENAI_MODEL, + baseUrl: env.OPENAI_BASE_URL, + computerSupervisorUrl: env.COMPUTER_SUPERVISOR_URL, + computerSupervisorToken: env.COMPUTER_SUPERVISOR_TOKEN, + computerToken: env.COMPUTER_TOKEN, + computerNamespace: env.COMPUTER_NAMESPACE, + browserUrl: env.BROWSER_URL, + browserSecret: env.BROWSER_SECRET, + voiceKey: env.VOICE_API_KEY, + voiceModel: env.VOICE_MODEL, + voiceName: env.VOICE_NAME, + slackChannel: env.SLACK_CHANNEL_NAME, + slackTeam: env.SLACK_TEAM_ID, + slackUsers: env.SLACK_USER_IDS, + slackDotId: env.SLACK_DOT_ID, + runtimeUrl: overrides.runtimeUrl, + ownerToken: overrides.ownerToken, + }; +} + export function setupStatus( config: PlatformConfig, - slack = 'not_configured', + slack: string = 'not_configured', activationFailed = false, ): SetupStatus { const missing = [ @@ -53,4 +91,4 @@ export function setupStatus( slack, missing, }; -} +} \ No newline at end of file diff --git a/tests/env.test.ts b/tests/env.test.ts new file mode 100644 index 0000000..541cbee --- /dev/null +++ b/tests/env.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { browserEnvSchema } from '../src/config/env/browser.js'; +import { computerEnvSchema } from '../src/config/env/computer.js'; +import { coreEnvSchema, isLocalHost } from '../src/config/env/core.js'; +import { parseServerEnv } from '../src/config/env.js'; +import { parallelEnvSchema } from '../src/config/env/parallel.js'; +import { slackEnvSchema } from '../src/config/env/slack.js'; +import { voiceEnvSchema } from '../src/config/env/voice.js'; + +describe('core env schema', () => { + it('applies the documented defaults', () => { + const parsed = coreEnvSchema.parse({}); + expect(parsed.HOST).toBe('127.0.0.1'); + expect(parsed.PORT).toBe(4310); + expect(parsed.DATABASE_PATH).toBe('data/opendots.sqlite'); + expect(parsed.OWNER_ID).toBe('opendots-owner'); + expect(parsed.OPENAI_BASE_URL).toBe('https://api.openai.com/v1'); + expect(parsed.NODE_ENV).toBe('production'); + }); + + it('accepts any non-empty host (token is gated after parse)', () => { + expect(coreEnvSchema.parse({ HOST: '0.0.0.0' }).HOST).toBe('0.0.0.0'); + }); + + it('coerces PORT from string', () => { + expect(coreEnvSchema.parse({ PORT: '5500' }).PORT).toBe(5500); + }); + + it('classifies the local-host set', () => { + expect(isLocalHost('127.0.0.1')).toBe(true); + expect(isLocalHost('::1')).toBe(true); + expect(isLocalHost('localhost')).toBe(true); + expect(isLocalHost('0.0.0.0')).toBe(false); + }); + + it('requires an OWNER_TOKEN when HOST is external', () => { + expect(() => parseServerEnv({ HOST: '0.0.0.0' })).toThrow( + /OWNER_TOKEN/, + ); + const valid = parseServerEnv({ + HOST: '0.0.0.0', + OWNER_TOKEN: 'x'.repeat(24), + }); + expect(valid.OWNER_TOKEN).toHaveLength(24); + }); +}); + +describe('parallel env schema', () => { + it('defaults to the Parallel provider', () => { + expect(parallelEnvSchema.parse({}).WEB_SEARCH_PROVIDER).toBe('parallel'); + }); + + it('rejects an unknown provider', () => { + expect(() => + parallelEnvSchema.parse({ WEB_SEARCH_PROVIDER: 'other' }), + ).toThrow(); + }); +}); + +describe('browser env schema', () => { + it('requires BROWSER_SECRET to be at least 24 characters', () => { + expect(() => browserEnvSchema.parse({ BROWSER_SECRET: 'short' })).toThrow( + /24 characters/, + ); + expect(() => browserEnvSchema.parse({})).toThrow(/BROWSER_SECRET/); + }); + + it('accepts a valid secret and applies defaults', () => { + const parsed = browserEnvSchema.parse({ + BROWSER_SECRET: 'x'.repeat(24), + }); + expect(parsed.BROWSER_HOST).toBe('127.0.0.1'); + expect(parsed.BROWSER_PORT).toBe(4311); + }); +}); + +describe('voice env schema', () => { + it('defaults VOICE_NAME to marin', () => { + expect(voiceEnvSchema.parse({}).VOICE_NAME).toBe('marin'); + }); +}); + +describe('slack env schema', () => { + it('parses SLACK_USER_IDS into a trimmed list', () => { + expect( + slackEnvSchema.parse({ SLACK_USER_IDS: 'a, b ,,c' }).SLACK_USER_IDS, + ).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty list when unset', () => { + expect(slackEnvSchema.parse({}).SLACK_USER_IDS).toEqual([]); + }); +}); + +describe('computer env schema', () => { + it('defaults the namespace and keeps optional URLs out', () => { + const parsed = computerEnvSchema.parse({}); + expect(parsed.COMPUTER_NAMESPACE).toBe('opendots'); + expect(parsed.COMPUTER_SUPERVISOR_URL).toBeUndefined(); + }); + + it('rejects a non-URL supervisor', () => { + expect(() => + computerEnvSchema.parse({ COMPUTER_SUPERVISOR_URL: 'not-a-url' }), + ).toThrow(); + }); +}); \ No newline at end of file diff --git a/tests/parallel.test.ts b/tests/parallel.test.ts index 82e79e9..3064ced 100644 --- a/tests/parallel.test.ts +++ b/tests/parallel.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parallelSources, sourcesFromResult, - webSearchProvider, } from '../src/server/parallel.js'; import { research } from '../src/server/research.js'; +import { parallelEnvSchema } from '../src/config/env/parallel.js'; const mcp = vi.hoisted(() => ({ connect: vi.fn(), callTool: vi.fn(), @@ -35,10 +35,18 @@ afterEach(() => vi.unstubAllGlobals()); const signal = new AbortController().signal; describe('Parallel research', () => { it('defaults to Parallel and rejects invalid configuration', () => { - expect(webSearchProvider()).toBe('parallel'); - expect(webSearchProvider('browser')).toBe('browser'); - expect(webSearchProvider('disabled')).toBe('disabled'); - expect(() => webSearchProvider('other')).toThrow('WEB_SEARCH_PROVIDER'); + expect(parallelEnvSchema.parse({}).WEB_SEARCH_PROVIDER).toBe('parallel'); + expect( + parallelEnvSchema.parse({ WEB_SEARCH_PROVIDER: 'browser' }) + .WEB_SEARCH_PROVIDER, + ).toBe('browser'); + expect( + parallelEnvSchema.parse({ WEB_SEARCH_PROVIDER: 'disabled' }) + .WEB_SEARCH_PROVIDER, + ).toBe('disabled'); + expect(() => + parallelEnvSchema.parse({ WEB_SEARCH_PROVIDER: 'other' }), + ).toThrow(); }); it('searches, extracts and grounds a topic-only brief without a browser', async () => { mcp.callTool