Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
});
7 changes: 7 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
serverEnvSchema,
serverBrowserFieldsSchema,
parseServerEnv,
parseBrowserEnv,
type ServerEnv,
} from './env/index.js';
12 changes: 12 additions & 0 deletions src/config/env/browser.ts
Original file line number Diff line number Diff line change
@@ -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<typeof browserEnvSchema>;
13 changes: 13 additions & 0 deletions src/config/env/computer.ts
Original file line number Diff line number Diff line change
@@ -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<typeof computerEnvSchema>;
28 changes: 28 additions & 0 deletions src/config/env/core.ts
Original file line number Diff line number Diff line change
@@ -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<typeof coreEnvSchema>;

export const EXTERNAL_OWNER_TOKEN_ERROR =
'External binding requires an OWNER_TOKEN of at least 24 characters.';
71 changes: 71 additions & 0 deletions src/config/env/index.ts
Original file line number Diff line number Diff line change
@@ -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('.') || '<root>'}: ${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;
}
11 changes: 11 additions & 0 deletions src/config/env/parallel.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parallelEnvSchema>;
18 changes: 18 additions & 0 deletions src/config/env/slack.ts
Original file line number Diff line number Diff line change
@@ -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<typeof slackEnvSchema>;
9 changes: 9 additions & 0 deletions src/config/env/voice.ts
Original file line number Diff line number Diff line change
@@ -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<typeof voiceEnvSchema>;
72 changes: 19 additions & 53 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -129,4 +95,4 @@ const shutdown = createShutdown({
reportChannelFailure(operation, [safeFailure(error)]),
});
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGINT', shutdown);
11 changes: 2 additions & 9 deletions src/server/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 40 additions & 2 deletions src/server/platform-config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 = [
Expand Down Expand Up @@ -53,4 +91,4 @@ export function setupStatus(
slack,
missing,
};
}
}
Loading