import path from "node:path"; import { fileURLToPath } from "node:url"; import { config as loadDotenv } from "dotenv"; import { z } from "zod"; loadDotenv({ quiet: true }); loadDotenv({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.env"), quiet: true }); const OptionalUrlSchema = z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.string().trim().url().optional() ); const ChatWebSearchEngineSchema = z.preprocess( (value) => { if (typeof value !== "string") return value; const trimmed = value.trim(); return trimmed ? trimmed.toLowerCase() : undefined; }, z.enum(["exa", "searxng"]).default("exa") ); const BooleanFlagSchema = z.preprocess((value) => { if (typeof value !== "string") return value; const normalized = value.trim().toLowerCase(); if (!normalized) return undefined; if (["1", "true", "yes", "on"].includes(normalized)) return true; if (["0", "false", "no", "off"].includes(normalized)) return false; return value; }, z.boolean().default(false)); const OptionalTrimmedStringSchema = z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.string().trim().min(1).optional() ); function defaultedPositiveInt(defaultValue: number) { return z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.coerce.number().int().positive().default(defaultValue) ); } function defaultedTrimmedString(defaultValue: string) { return z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.string().trim().min(1).default(defaultValue) ); } const EnvSchema = z.object({ PORT: z.coerce.number().int().positive().default(8787), HOST: z.string().default("0.0.0.0"), // simple bearer-token auth for your personal backend ADMIN_TOKEN: z.string().min(20).optional(), // provider keys OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), XAI_API_KEY: z.string().optional(), EXA_API_KEY: z.string().optional(), // Chat-mode web_search tool configuration. Search mode remains Exa-only for now. CHAT_WEB_SEARCH_ENGINE: ChatWebSearchEngineSchema, SEARXNG_BASE_URL: OptionalUrlSchema, // Optional chat-mode Codex tool. When enabled, the server SSHes into a remote // devbox and runs `codex exec` in a persistent scratch directory there. CHAT_CODEX_TOOL_ENABLED: BooleanFlagSchema, CHAT_CODEX_REMOTE_HOST: OptionalTrimmedStringSchema, CHAT_CODEX_REMOTE_USER: OptionalTrimmedStringSchema, CHAT_CODEX_REMOTE_PORT: defaultedPositiveInt(22), CHAT_CODEX_REMOTE_WORKDIR: defaultedTrimmedString("/workspace/sybil-codex"), CHAT_CODEX_SSH_KEY_PATH: OptionalTrimmedStringSchema, CHAT_CODEX_SSH_PRIVATE_KEY_B64: OptionalTrimmedStringSchema, CHAT_CODEX_EXEC_TIMEOUT_MS: defaultedPositiveInt(600_000), // Optional arbitrary shell tool that runs only on the configured devbox. CHAT_SHELL_TOOL_ENABLED: BooleanFlagSchema, CHAT_SHELL_EXEC_TIMEOUT_MS: defaultedPositiveInt(120_000), }).superRefine((value, ctx) => { if (value.CHAT_WEB_SEARCH_ENGINE === "searxng" && !value.SEARXNG_BASE_URL) { ctx.addIssue({ code: "custom", path: ["SEARXNG_BASE_URL"], message: "SEARXNG_BASE_URL is required when CHAT_WEB_SEARCH_ENGINE=searxng", }); } if ((value.CHAT_CODEX_TOOL_ENABLED || value.CHAT_SHELL_TOOL_ENABLED) && !value.CHAT_CODEX_REMOTE_HOST) { ctx.addIssue({ code: "custom", path: ["CHAT_CODEX_REMOTE_HOST"], message: "CHAT_CODEX_REMOTE_HOST is required when CHAT_CODEX_TOOL_ENABLED=true or CHAT_SHELL_TOOL_ENABLED=true", }); } }); export type Env = z.infer; export const env: Env = EnvSchema.parse(process.env);