diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 87220f1f5..38c222e92 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -127,14 +127,6 @@ export function VoiceProvider({ children }: VoiceProviderProps) { detectionGracePeriod: 200, }); - // Update voice detection flags whenever they change - useEffect(() => { - activeSession?.methods?.setVoiceDetectionFlags( - realtimeAudio.isDetecting, - realtimeAudio.isSpeaking - ); - }, [activeSession?.methods, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); - useEffect(() => { realtimeSessionRef.current = activeSession; }, [activeSession]); @@ -196,7 +188,6 @@ export function VoiceProvider({ children }: VoiceProviderProps) { const session = realtimeSessionRef.current; session?.audioPlayer?.stop(); await realtimeAudio.stop(); - session?.methods?.setVoiceDetectionFlags(false, false); setIsVoiceMode(false); setActiveServerId(null); console.log("[Voice] Mode disabled"); diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index a6769e8ea..901efa715 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -7,12 +7,14 @@ import { createRootLogger, loadPersistedConfig, } from '@paseo/server' +import type { CliConfigOverrides } from '@paseo/server' interface StartOptions { port?: string home?: string foreground?: boolean noRelay?: boolean + noMcp?: boolean allowedHosts?: string } @@ -23,7 +25,11 @@ export function startCommand(): Command { .option('--home ', 'Paseo home directory (default: ~/.paseo)') .option('--foreground', 'Run in foreground (don\'t daemonize)') .option('--no-relay', 'Disable relay connection') - .option('--allowed-hosts ', 'Comma-separated list of allowed MCP hosts (e.g., "localhost:6767,127.0.0.1:6767")') + .option('--no-mcp', 'Disable the Agent MCP HTTP endpoint') + .option( + '--allowed-hosts ', + 'Comma-separated list of allowed Host header values (Vite-style; e.g., "localhost,.example.com" or "true")' + ) .action(async (options: StartOptions) => { await runStart(options) }) @@ -34,22 +40,41 @@ async function runStart(options: StartOptions): Promise { if (options.home) { process.env.PASEO_HOME = options.home } + + let paseoHome: string + let logger: ReturnType + let config: ReturnType + const cliOverrides: CliConfigOverrides = {} + if (options.port) { - process.env.PASEO_LISTEN = `127.0.0.1:${options.port}` + cliOverrides.listen = `127.0.0.1:${options.port}` } - const paseoHome = resolvePaseoHome() - const persistedConfig = loadPersistedConfig(paseoHome) - const logger = createRootLogger(persistedConfig) - const config = loadConfig(paseoHome) - - // Apply CLI overrides if (options.noRelay) { - config.relayEnabled = false + cliOverrides.relayEnabled = false } if (options.allowedHosts) { - config.agentMcpAllowedHosts = options.allowedHosts.split(',').map(h => h.trim()) + const raw = options.allowedHosts.trim() + cliOverrides.allowedHosts = + raw.toLowerCase() === 'true' + ? true + : raw.split(',').map(h => h.trim()).filter(Boolean) + } + + if (options.noMcp) { + cliOverrides.mcpEnabled = false + } + + try { + paseoHome = resolvePaseoHome() + const persistedConfig = loadPersistedConfig(paseoHome) + logger = createRootLogger(persistedConfig) + config = loadConfig(paseoHome, { cli: cliOverrides }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(chalk.red(message)) + process.exit(1) } // For now, only foreground mode is supported diff --git a/packages/server/package.json b/packages/server/package.json index 430c3e1db..f115c701b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ "build": "tsc -p tsconfig.server.json", "start": "NODE_ENV=production node dist/server/index.js", "typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit", + "generate:config-schema": "tsx scripts/generate-config-schema.ts", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", diff --git a/packages/server/scripts/generate-config-schema.ts b/packages/server/scripts/generate-config-schema.ts new file mode 100644 index 000000000..dfa3d32f5 --- /dev/null +++ b/packages/server/scripts/generate-config-schema.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs"; + +import { zodToJsonSchema } from "zod-to-json-schema"; +import { PersistedConfigSchema } from "../src/server/persisted-config.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function main() { + const repoRoot = path.resolve(__dirname, "../../.."); + const outPath = path.join( + repoRoot, + "packages/website/public/schemas/paseo.config.v1.json" + ); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + + const schema = zodToJsonSchema(PersistedConfigSchema, { + name: "PaseoConfigV1", + }); + + fs.writeFileSync(outPath, JSON.stringify(schema, null, 2) + "\n", "utf8"); + process.stdout.write(`Wrote ${outPath}\n`); +} + +main(); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 58e9ac5e6..527725f9f 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -84,12 +84,13 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - agentMcpRoute: "/mcp/agents", - agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + allowedHosts: true, + mcpEnabled: true, staticDir, mcpDebug: false, agentClients: createTestAgentClients(), agentStoragePath: path.join(paseoHome, "agents"), + openrouterApiKey: null, }; const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" })); diff --git a/packages/server/src/server/allowed-hosts.test.ts b/packages/server/src/server/allowed-hosts.test.ts new file mode 100644 index 000000000..2dec6c917 --- /dev/null +++ b/packages/server/src/server/allowed-hosts.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js"; + +describe("allowed hosts (vite-style)", () => { + it("allows localhost by default", () => { + expect(isHostAllowed("localhost:6767", undefined)).toBe(true); + }); + + it("allows subdomains of .localhost by default", () => { + expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true); + }); + + it("allows IP addresses by default", () => { + expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true); + expect(isHostAllowed("[::1]:6767", undefined)).toBe(true); + }); + + it("rejects non-default hosts when no allowlist is provided", () => { + expect(isHostAllowed("evil.com:6767", undefined)).toBe(false); + }); + + it("allows any host when set to true", () => { + expect(isHostAllowed("evil.com:6767", true)).toBe(true); + }); + + it("supports leading-dot patterns", () => { + const allowed = [".example.com"]; + expect(isHostAllowed("example.com:6767", allowed)).toBe(true); + expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true); + expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true); + expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false); + }); + + it("merges arrays (append + de-dupe) and short-circuits on true", () => { + expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]); + expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true); + }); + + it("parses env var values", () => { + expect(parseAllowedHostsEnv(undefined)).toBeUndefined(); + expect(parseAllowedHostsEnv("")).toBeUndefined(); + expect(parseAllowedHostsEnv("true")).toBe(true); + expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); + }); +}); + diff --git a/packages/server/src/server/allowed-hosts.ts b/packages/server/src/server/allowed-hosts.ts new file mode 100644 index 000000000..5e1f67a14 --- /dev/null +++ b/packages/server/src/server/allowed-hosts.ts @@ -0,0 +1,104 @@ +import net from "node:net"; + +export type AllowedHostsConfig = true | string[] | undefined; + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase(); +} + +function parseHostnameFromHostHeader(hostHeader: string): string | null { + const trimmed = hostHeader.trim(); + if (!trimmed) return null; + + // IPv6 in brackets: [::1]:6767 + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + if (end === -1) return null; + return normalizeHostname(trimmed.slice(1, end)); + } + + // IPv4/hostname with optional port: localhost:6767 + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) { + return normalizeHostname(trimmed); + } + return normalizeHostname(trimmed.slice(0, colonIndex)); +} + +function matchesAllowedHostPattern(hostname: string, pattern: string): boolean { + const normalizedPattern = normalizeHostname(pattern); + if (!normalizedPattern) return false; + + if (normalizedPattern.startsWith(".")) { + const base = normalizedPattern.slice(1); + if (!base) return false; + return hostname === base || hostname.endsWith(`.${base}`); + } + + return hostname === normalizedPattern; +} + +function isDefaultAllowedHostname(hostname: string): boolean { + // Vite-style defaults: localhost, *.localhost, and all IP addresses. + if (hostname === "localhost") return true; + if (hostname.endsWith(".localhost")) return true; + if (net.isIP(hostname) !== 0) return true; + return false; +} + +/** + * Vite-style allowed hosts check, adapted to raw Host headers. + * + * Semantics: + * - `allowedHosts === true` => allow any host. + * - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs. + * - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults. + */ +export function isHostAllowed( + hostHeader: string | undefined, + allowedHosts: AllowedHostsConfig +): boolean { + const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null; + if (!hostname) return false; + + if (allowedHosts === true) return true; + + // Defaults are always allowed. + if (isDefaultAllowedHostname(hostname)) return true; + + const patterns = allowedHosts ?? []; + for (const pattern of patterns) { + if (matchesAllowedHostPattern(hostname, pattern)) return true; + } + return false; +} + +export function mergeAllowedHosts( + values: Array +): AllowedHostsConfig { + let merged: string[] = []; + for (const value of values) { + if (value === true) return true; + if (!value) continue; + merged = merged.concat(value); + } + + const deduped = Array.from( + new Set(merged.map((v) => v.trim()).filter((v) => v.length > 0)) + ); + return deduped; +} + +export function parseAllowedHostsEnv( + raw: string | undefined +): AllowedHostsConfig { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + if (trimmed.toLowerCase() === "true") return true; + return trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f9a86edd5..0bf218bdb 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -60,6 +60,7 @@ import type { AgentProvider, } from "./agent/agent-sdk-types.js"; import { acquirePidLock, releasePidLock } from "./pid-lock.js"; +import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; type AgentMcpTransportMap = Map; @@ -73,8 +74,8 @@ export type PaseoDaemonConfig = { listen: string; paseoHome: string; corsAllowedOrigins: string[]; - agentMcpRoute: string; - agentMcpAllowedHosts: string[]; + allowedHosts?: AllowedHostsConfig; + mcpEnabled?: boolean; staticDir: string; mcpDebug: boolean; agentClients: Partial>; @@ -83,6 +84,8 @@ export type PaseoDaemonConfig = { relayEndpoint?: string; appBaseUrl?: string; openai?: PaseoOpenAIConfig; + openrouterApiKey?: string | null; + voiceLlmModel?: string | null; dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; }; @@ -104,7 +107,6 @@ export async function createPaseoDaemon( const connectionSessionId = randomUUID(); let relayTransport: RelayTransportController | null = null; - const agentMcpRoute = config.agentMcpRoute; const staticDir = config.staticDir; const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; @@ -114,6 +116,19 @@ export async function createPaseoDaemon( const app = express(); + // Host allowlist / DNS rebinding protection (vite-like semantics). + // For non-TCP (unix sockets), skip host validation. + if (listenTarget.type === "tcp") { + app.use((req, res, next) => { + const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined; + if (!isHostAllowed(hostHeader, config.allowedHosts)) { + res.status(403).json({ error: "Invalid Host header" }); + return; + } + next(); + }); + } + // CORS - allow same-origin + configured origins const allowedOrigins = new Set([ ...config.corsAllowedOrigins, @@ -227,45 +242,6 @@ export async function createPaseoDaemon( `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` ); - const agentMcpTransports: AgentMcpTransportMap = new Map(); - const allowedHosts = config.agentMcpAllowedHosts; - - const createAgentMcpTransport = async (callerAgentId?: string) => { - const agentMcpServer = await createAgentMcpServer({ - agentManager, - agentStorage, - paseoHome: config.paseoHome, - callerAgentId, - logger, - }); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId) => { - agentMcpTransports.set(sessionId, transport); - logger.debug({ sessionId }, "Agent MCP session initialized"); - }, - onsessionclosed: (sessionId) => { - agentMcpTransports.delete(sessionId); - logger.debug({ sessionId }, "Agent MCP session closed"); - }, - enableDnsRebindingProtection: true, - allowedHosts, - }); - transport.onclose = () => { - if (transport.sessionId) { - agentMcpTransports.delete(transport.sessionId); - } - }; - transport.onerror = (err) => { - logger.error({ err }, "Agent MCP transport error"); - }; - - await agentMcpServer.connect(transport); - - return transport; - }; - // Create in-memory transport for Session's Agent MCP client (voice assistant tools) const createInMemoryAgentMcpTransport = async (): Promise => { const agentMcpServer = await createAgentMcpServer({ @@ -282,76 +258,121 @@ export async function createPaseoDaemon( return clientTransport; }; - const handleAgentMcpRequest: express.RequestHandler = async (req, res) => { - if (config.mcpDebug) { - logger.debug( - { - method: req.method, - url: req.originalUrl, - sessionId: req.header("mcp-session-id"), - authorization: req.header("authorization"), - body: req.body, + const mcpEnabled = config.mcpEnabled ?? true; + if (mcpEnabled) { + const agentMcpRoute = "/mcp/agents"; + const agentMcpTransports: AgentMcpTransportMap = new Map(); + + const createAgentMcpTransport = async (callerAgentId?: string) => { + const agentMcpServer = await createAgentMcpServer({ + agentManager, + agentStorage, + paseoHome: config.paseoHome, + callerAgentId, + logger, + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + agentMcpTransports.set(sessionId, transport); + logger.debug({ sessionId }, "Agent MCP session initialized"); }, - "Agent MCP request" - ); - } - try { - const sessionId = req.header("mcp-session-id"); - let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined; + onsessionclosed: (sessionId) => { + agentMcpTransports.delete(sessionId); + logger.debug({ sessionId }, "Agent MCP session closed"); + }, + // NOTE: We enforce a Vite-like host allowlist at the app/websocket layer. + // StreamableHTTPServerTransport's built-in check requires exact Host header matches. + enableDnsRebindingProtection: false, + }); - if (!transport) { - if (req.method !== "POST") { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Missing or invalid MCP session", - }, - id: null, - }); - return; + transport.onclose = () => { + if (transport.sessionId) { + agentMcpTransports.delete(transport.sessionId); } - if (!isInitializeRequest(req.body)) { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Initialization request expected", - }, - id: null, - }); - return; - } - const callerAgentIdRaw = req.query.callerAgentId; - const callerAgentId = - typeof callerAgentIdRaw === "string" - ? callerAgentIdRaw - : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string" - ? callerAgentIdRaw[0] - : undefined; - transport = await createAgentMcpTransport(callerAgentId); - } + }; + transport.onerror = (err) => { + logger.error({ err }, "Agent MCP transport error"); + }; - await transport.handleRequest(req as any, res as any, req.body); - } catch (err) { - logger.error({ err }, "Failed to handle Agent MCP request"); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { - code: -32603, - message: "Internal MCP server error", + await agentMcpServer.connect(transport); + return transport; + }; + + const handleAgentMcpRequest: express.RequestHandler = async (req, res) => { + if (config.mcpDebug) { + logger.debug( + { + method: req.method, + url: req.originalUrl, + sessionId: req.header("mcp-session-id"), + authorization: req.header("authorization"), + body: req.body, }, - id: null, - }); + "Agent MCP request" + ); } - } - }; + try { + const sessionId = req.header("mcp-session-id"); + let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined; - app.post(agentMcpRoute, handleAgentMcpRequest); - app.get(agentMcpRoute, handleAgentMcpRequest); - app.delete(agentMcpRoute, handleAgentMcpRequest); - logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app"); + if (!transport) { + if (req.method !== "POST") { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Missing or invalid MCP session", + }, + id: null, + }); + return; + } + if (!isInitializeRequest(req.body)) { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Initialization request expected", + }, + id: null, + }); + return; + } + const callerAgentIdRaw = req.query.callerAgentId; + const callerAgentId = + typeof callerAgentIdRaw === "string" + ? callerAgentIdRaw + : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string" + ? callerAgentIdRaw[0] + : undefined; + transport = await createAgentMcpTransport(callerAgentId); + } + + await transport.handleRequest(req as any, res as any, req.body); + } catch (err) { + logger.error({ err }, "Failed to handle Agent MCP request"); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal MCP server error", + }, + id: null, + }); + } + } + }; + + app.post(agentMcpRoute, handleAgentMcpRequest); + app.get(agentMcpRoute, handleAgentMcpRequest); + app.delete(agentMcpRoute, handleAgentMcpRequest); + logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app"); + } else { + logger.info("Agent MCP HTTP endpoint disabled"); + } let sttService: OpenAISTT | null = null; @@ -400,9 +421,13 @@ export async function createPaseoDaemon( downloadTokenStore, config.paseoHome, createInMemoryAgentMcpTransport, - { allowedOrigins }, + { allowedOrigins, allowedHosts: config.allowedHosts }, { stt: sttService, tts: ttsService }, terminalManager, + { + openrouterApiKey: config.openrouterApiKey ?? null, + voiceLlmModel: config.voiceLlmModel ?? null, + }, { openaiApiKey: config.openai?.apiKey ?? null, finalTimeoutMs: config.dictationFinalTimeoutMs, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7a0fe71b3..fed9776c8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -4,9 +4,13 @@ import type { PaseoDaemonConfig } from "./bootstrap.js"; import type { STTConfig } from "./agent/stt-openai.js"; import type { TTSConfig } from "./agent/tts-openai.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { + mergeAllowedHosts, + parseAllowedHostsEnv, + type AllowedHostsConfig, +} from "./allowed-hosts.js"; const DEFAULT_PORT = 6767; -const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents"; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; @@ -15,15 +19,35 @@ function getDefaultListen(): string { return `127.0.0.1:${DEFAULT_PORT}`; } +export type CliConfigOverrides = Partial<{ + listen: string; + relayEnabled: boolean; + mcpEnabled: boolean; + allowedHosts: AllowedHostsConfig; +}>; -function parseOpenAIConfig(env: NodeJS.ProcessEnv) { - const apiKey = env.OPENAI_API_KEY; +function parseOpenAIConfig( + env: NodeJS.ProcessEnv, + configApiKey: string | undefined, + config: { + dictationSttModel?: string; + dictationSttConfidenceThreshold?: number; + voiceSttModel?: string; + voiceTtsVoice?: TTSConfig["voice"]; + voiceTtsModel?: TTSConfig["model"]; + } +) { + const apiKey = env.OPENAI_API_KEY ?? configApiKey; if (!apiKey) return undefined; const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD ? parseFloat(env.STT_CONFIDENCE_THRESHOLD) - : undefined; - const sttModel = env.STT_MODEL as STTConfig["model"]; + : config.dictationSttConfidenceThreshold; + const sttModel = ( + env.STT_MODEL ?? + config.voiceSttModel ?? + config.dictationSttModel + ) as STTConfig["model"] | undefined; const ttsVoice = (env.TTS_VOICE || "alloy") as | "alloy" | "echo" @@ -31,7 +55,10 @@ function parseOpenAIConfig(env: NodeJS.ProcessEnv) { | "onyx" | "nova" | "shimmer"; - const ttsModel = (env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd"; + const ttsModel = (env.TTS_MODEL || config.voiceTtsModel || "tts-1") as + | "tts-1" + | "tts-1-hd"; + const configuredVoice = config.voiceTtsVoice; return { apiKey, @@ -42,34 +69,21 @@ function parseOpenAIConfig(env: NodeJS.ProcessEnv) { }, tts: { apiKey, - voice: ttsVoice, + voice: configuredVoice ?? ttsVoice, model: ttsModel, responseFormat: "pcm" as TTSConfig["responseFormat"], }, }; } -function getListenForMcp(listen: string): string { - // For Unix sockets, MCP still needs HTTP - use localhost - if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) { - return "127.0.0.1:6767"; - } - if (listen.startsWith("unix://")) { - return "127.0.0.1:6767"; - } - // TCP: extract host:port - if (listen.includes(":")) { - const [host, port] = listen.split(":"); - return `${host || "127.0.0.1"}:${port}`; - } - // Just port - return `127.0.0.1:${listen}`; -} - export function loadConfig( paseoHome: string, - env: NodeJS.ProcessEnv = process.env + options?: { + env?: NodeJS.ProcessEnv; + cli?: CliConfigOverrides; + } ): PaseoDaemonConfig { + const env = options?.env ?? process.env; const persisted = loadPersistedConfig(paseoHome); // PASEO_LISTEN can be: @@ -77,26 +91,68 @@ export function loadConfig( // - /path/to/socket (Unix socket) // - unix:///path/to/socket (Unix socket) // Default is TCP at 127.0.0.1:6767 - const listen = env.PASEO_LISTEN ?? persisted.listen ?? getDefaultListen(); - const mcpListen = getListenForMcp(listen); + const listen = + options?.cli?.listen ?? + env.PASEO_LISTEN ?? + persisted.daemon?.listen ?? + getDefaultListen(); const envCorsOrigins = env.PASEO_CORS_ORIGINS ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim()) : []; + const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; + + const allowedHosts = mergeAllowedHosts([ + persisted.daemon?.allowedHosts, + parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS), + options?.cli?.allowedHosts, + ]); + + const mcpEnabled = + options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; + + const relayEnabled = + options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true; + + const relayEndpoint = + env.PASEO_RELAY_ENDPOINT ?? + persisted.daemon?.relay?.endpoint ?? + DEFAULT_RELAY_ENDPOINT; + + const appBaseUrl = + env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL; + + const openai = parseOpenAIConfig(env, persisted.providers?.openai?.apiKey, { + dictationSttModel: persisted.features?.dictation?.stt?.model, + dictationSttConfidenceThreshold: + persisted.features?.dictation?.stt?.confidenceThreshold, + voiceSttModel: persisted.features?.voiceMode?.stt?.model, + voiceTtsModel: persisted.features?.voiceMode?.tts?.model, + voiceTtsVoice: persisted.features?.voiceMode?.tts?.voice, + }); + + const openrouterApiKey = + env.OPENROUTER_API_KEY ?? persisted.providers?.openrouter?.apiKey ?? null; + const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null; + return { listen, paseoHome, - corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins], - agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE, - agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`], + corsAllowedOrigins: Array.from( + new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)) + ), + allowedHosts, + mcpEnabled, mcpDebug: env.MCP_DEBUG === "1", agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, - relayEnabled: true, - relayEndpoint: env.PASEO_RELAY_ENDPOINT ?? DEFAULT_RELAY_ENDPOINT, - appBaseUrl: env.PASEO_APP_BASE_URL ?? DEFAULT_APP_BASE_URL, - openai: parseOpenAIConfig(env), + relayEnabled, + relayEndpoint, + appBaseUrl, + openai, + openrouterApiKey, + voiceLlmModel, }; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 1153ce84e..02b9c533b 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -1,6 +1,6 @@ // CLI exports for @paseo/server export { createPaseoDaemon, type PaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; -export { loadConfig } from "./config.js"; +export { loadConfig, type CliConfigOverrides } from "./config.js"; export { resolvePaseoHome } from "./paseo-home.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 78a2c1e90..edcfdd2b0 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -12,14 +12,27 @@ import { loadPersistedConfig } from "./persisted-config.js"; import { PidLockError } from "./pid-lock.js"; async function main() { - const paseoHome = resolvePaseoHome(); - const persistedConfig = loadPersistedConfig(paseoHome); - const logger = createRootLogger(persistedConfig); - const config = loadConfig(paseoHome); + let paseoHome: string; + let logger: ReturnType; + let config: ReturnType; + + try { + paseoHome = resolvePaseoHome(); + const persistedConfig = loadPersistedConfig(paseoHome); + logger = createRootLogger(persistedConfig); + config = loadConfig(paseoHome); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`${message}\n`); + process.exit(1); + } if (process.argv.includes("--no-relay")) { config.relayEnabled = false; } + if (process.argv.includes("--no-mcp")) { + config.mcpEnabled = false; + } const daemon = await createPaseoDaemon(config, logger); try { @@ -63,10 +76,10 @@ async function main() { } main().catch((err) => { - // Logger might not be initialized yet, so we need to handle this specially - const paseoHome = resolvePaseoHome(); - const persistedConfig = loadPersistedConfig(paseoHome); - const logger = createRootLogger(persistedConfig); - logger.error({ err }, "Failed to start server"); + if (process.env.PASEO_DEBUG === "1") { + process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`); + } else { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + } process.exit(1); }); diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts index 02e159ec0..41d6bcfea 100644 --- a/packages/server/src/server/paseo-home.ts +++ b/packages/server/src/server/paseo-home.ts @@ -13,7 +13,7 @@ function expandHomeDir(input: string): string { } export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { - const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo"; + const raw = env.PASEO_HOME ?? "~/.paseo"; const resolved = path.resolve(expandHomeDir(raw)); mkdirSync(resolved, { recursive: true }); return resolved; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index ded1c6332..89996aa54 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -2,22 +2,120 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; -const PersistedConfigSchema = z.object({ - listen: z.string().optional(), - cors: z - .object({ - allowedOrigins: z.array(z.string()).default([]), - }) - .default({}), - log: z - .object({ - level: z - .enum(["trace", "debug", "info", "warn", "error", "fatal"]) - .optional(), - format: z.enum(["pretty", "json"]).optional(), - }) - .optional(), -}); +const LogConfigSchema = z + .object({ + level: z + .enum(["trace", "debug", "info", "warn", "error", "fatal"]) + .optional(), + format: z.enum(["pretty", "json"]).optional(), + }) + .strict(); + +const ProviderCredentialsSchema = z + .object({ + apiKey: z.string().min(1).optional(), + }) + .strict(); + +const ProvidersSchema = z + .object({ + openai: ProviderCredentialsSchema.optional(), + openrouter: ProviderCredentialsSchema.optional(), + }) + .strict(); + +const FeatureDictationSchema = z + .object({ + stt: z + .object({ + provider: z.enum(["openai"]).optional(), + model: z.string().min(1).optional(), + confidenceThreshold: z.number().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const FeatureVoiceModeSchema = z + .object({ + llm: z + .object({ + provider: z.enum(["openrouter"]).optional(), + model: z.string().min(1).optional(), + }) + .strict() + .optional(), + stt: z + .object({ + provider: z.enum(["openai"]).optional(), + model: z.string().min(1).optional(), + }) + .strict() + .optional(), + tts: z + .object({ + provider: z.enum(["openai"]).optional(), + model: z.enum(["tts-1", "tts-1-hd"]).optional(), + voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export const PersistedConfigSchema = z + .object({ + // v1 schema marker + version: z.literal(1).optional(), + + // v1 config layout + daemon: z + .object({ + listen: z.string().optional(), + allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), + mcp: z + .object({ + enabled: z.boolean().optional(), + }) + .strict() + .optional(), + cors: z + .object({ + allowedOrigins: z.array(z.string()).optional(), + }) + .strict() + .optional(), + relay: z + .object({ + enabled: z.boolean().optional(), + endpoint: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + + app: z + .object({ + baseUrl: z.string().optional(), + }) + .strict() + .optional(), + + providers: ProvidersSchema.optional(), + features: z + .object({ + dictation: FeatureDictationSchema.optional(), + voiceMode: FeatureVoiceModeSchema.optional(), + }) + .strict() + .optional(), + + log: LogConfigSchema.optional(), + }) + .strict(); export type PersistedConfig = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e51eb04a0..4474912aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -312,6 +312,8 @@ export class Session { } | null = null; private readonly terminalManager: TerminalManager | null; private terminalSubscriptions: Map void> = new Map(); + private readonly openrouterApiKey: string | null; + private readonly voiceLlmModel: string | null; constructor( clientId: string, @@ -327,6 +329,10 @@ export class Session { tts: OpenAITTS | null, terminalManager: TerminalManager | null, voiceConversationStore: VoiceConversationStore, + voice?: { + openrouterApiKey?: string | null; + voiceLlmModel?: string | null; + }, dictation?: { openaiApiKey?: string | null; finalTimeoutMs?: number; @@ -343,6 +349,8 @@ export class Session { this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; this.voiceConversationStore = voiceConversationStore; + this.openrouterApiKey = voice?.openrouterApiKey ?? null; + this.voiceLlmModel = voice?.voiceLlmModel ?? null; this.abortController = new AbortController(); this.sessionLogger = logger.child({ module: "session", @@ -4356,13 +4364,15 @@ export class Session { // Debug: dump conversation before LLM call await this.dumpConversation(); + const openrouterApiKey = + this.openrouterApiKey ?? process.env.OPENROUTER_API_KEY ?? null; invariant( - process.env.OPENROUTER_API_KEY, - "OPENROUTER_API_KEY is required" + openrouterApiKey, + "OpenRouter API key is required (set providers.openrouter.apiKey in config.json or OPENROUTER_API_KEY)" ); const openrouter = createOpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, + apiKey: openrouterApiKey, }); // Wait for agent MCP to initialize if needed @@ -4380,7 +4390,7 @@ export class Session { const allTools = getAllTools(this.agentTools ?? undefined); const result = await streamText({ - model: openrouter("anthropic/claude-haiku-4.5"), + model: openrouter(this.voiceLlmModel ?? "anthropic/claude-haiku-4.5"), system: getSystemPrompt(), providerOptions: { openrouter: { diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 85f1cedd0..89028cde6 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -66,8 +66,8 @@ export async function createTestPaseoDaemon( listen: `${listenHost}:${port}`, paseoHome, corsAllowedOrigins: options.corsAllowedOrigins ?? [], - agentMcpRoute: "/mcp/agents", - agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`], + allowedHosts: true, + mcpEnabled: true, staticDir, mcpDebug: false, agentClients: options.agentClients ?? createTestAgentClients(), @@ -76,6 +76,7 @@ export async function createTestPaseoDaemon( relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", appBaseUrl: "https://app.paseo.sh", openai: options.openai, + openrouterApiKey: null, dictationFinalTimeoutMs: options.dictationFinalTimeoutMs, downloadTokenTtlMs: options.downloadTokenTtlMs, }; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 002501b91..f830e5403 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -10,11 +10,14 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { WSOutboundMessage } from "./messages.js"; import { WebSocketSessionBridge } from "./websocket-session-bridge.js"; +import type { AllowedHostsConfig } from "./allowed-hosts.js"; +import { isHostAllowed } from "./allowed-hosts.js"; export type AgentMcpTransportFactory = () => Promise; type WebSocketServerConfig = { allowedOrigins: Set; + allowedHosts?: AllowedHostsConfig; }; /** @@ -36,6 +39,10 @@ export class VoiceAssistantWebSocketServer { wsConfig: WebSocketServerConfig, speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null }, terminalManager?: TerminalManager | null, + voice?: { + openrouterApiKey?: string | null; + voiceLlmModel?: string | null; + }, dictation?: { openaiApiKey?: string | null; finalTimeoutMs?: number; @@ -51,16 +58,22 @@ export class VoiceAssistantWebSocketServer { createAgentMcpTransport, speech, terminalManager, + voice, dictation ); - const { allowedOrigins } = wsConfig; + const { allowedOrigins, allowedHosts } = wsConfig; this.wss = new WebSocketServer({ server, path: "/ws", verifyClient: ({ req }, callback) => { const origin = req.headers.origin; const requestHost = typeof req.headers.host === "string" ? req.headers.host : null; + if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { + this.logger.warn({ host: requestHost }, "Rejected connection from disallowed host"); + callback(false, 403, "Host not allowed"); + return; + } const sameOrigin = !!origin && !!requestHost && diff --git a/packages/server/src/server/websocket-session-bridge.ts b/packages/server/src/server/websocket-session-bridge.ts index b6e7cbc58..0219311b3 100644 --- a/packages/server/src/server/websocket-session-bridge.ts +++ b/packages/server/src/server/websocket-session-bridge.ts @@ -41,6 +41,10 @@ export class WebSocketSessionBridge { openaiApiKey?: string | null; finalTimeoutMs?: number; } | null; + private readonly voice: { + openrouterApiKey?: string | null; + voiceLlmModel?: string | null; + } | null; constructor( logger: pino.Logger, @@ -51,6 +55,10 @@ export class WebSocketSessionBridge { createAgentMcpTransport: AgentMcpTransportFactory, speech?: { stt: OpenAISTT | null; tts: OpenAITTS | null }, terminalManager?: TerminalManager | null, + voice?: { + openrouterApiKey?: string | null; + voiceLlmModel?: string | null; + }, dictation?: { openaiApiKey?: string | null; finalTimeoutMs?: number; @@ -68,6 +76,7 @@ export class WebSocketSessionBridge { this.voiceConversationStore = new VoiceConversationStore( join(paseoHome, "voice-conversations") ); + this.voice = voice ?? null; this.dictation = dictation ?? null; const pushLogger = this.logger.child({ module: "push" }); @@ -106,6 +115,7 @@ export class WebSocketSessionBridge { this.tts, this.terminalManager, this.voiceConversationStore, + this.voice ?? undefined, this.dictation ?? undefined ); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json new file mode 100644 index 000000000..14a45f390 --- /dev/null +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -0,0 +1,223 @@ +{ + "$ref": "#/definitions/PaseoConfigV1", + "definitions": { + "PaseoConfigV1": { + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 1 + }, + "daemon": { + "type": "object", + "properties": { + "listen": { + "type": "string" + }, + "allowedHosts": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mcp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "cors": { + "type": "object", + "properties": { + "allowedOrigins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "relay": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "providers": { + "type": "object", + "properties": { + "openai": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "openrouter": { + "$ref": "#/definitions/PaseoConfigV1/properties/providers/properties/openai" + } + }, + "additionalProperties": false + }, + "features": { + "type": "object", + "properties": { + "dictation": { + "type": "object", + "properties": { + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "openai" + ] + }, + "model": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "voiceMode": { + "type": "object", + "properties": { + "llm": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "openrouter" + ] + }, + "model": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "stt": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "openai" + ] + }, + "model": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "tts": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "openai" + ] + }, + "model": { + "type": "string", + "enum": [ + "tts-1", + "tts-1-hd" + ] + }, + "voice": { + "type": "string", + "enum": [ + "alloy", + "echo", + "fable", + "onyx", + "nova", + "shimmer" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "log": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal" + ] + }, + "format": { + "type": "string", + "enum": [ + "pretty", + "json" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index f1c69c7ab..375c8779b 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -13,6 +13,8 @@ import { Route as DocsRouteImport } from './routes/docs' import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' +import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' +import { Route as DocsSecurityRouteImport } from './routes/docs/security' const DocsRoute = DocsRouteImport.update({ id: '/docs', @@ -34,16 +36,30 @@ const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({ path: '/best-practices', getParentRoute: () => DocsRoute, } as any) +const DocsConfigurationRoute = DocsConfigurationRouteImport.update({ + id: '/configuration', + path: '/configuration', + getParentRoute: () => DocsRoute, +} as any) +const DocsSecurityRoute = DocsSecurityRouteImport.update({ + id: '/security', + path: '/security', + getParentRoute: () => DocsRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/docs': typeof DocsRouteWithChildren '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs/configuration': typeof DocsConfigurationRoute + '/docs/security': typeof DocsSecurityRoute '/docs/': typeof DocsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs/configuration': typeof DocsConfigurationRoute + '/docs/security': typeof DocsSecurityRoute '/docs': typeof DocsIndexRoute } export interface FileRoutesById { @@ -51,14 +67,16 @@ export interface FileRoutesById { '/': typeof IndexRoute '/docs': typeof DocsRouteWithChildren '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs/configuration': typeof DocsConfigurationRoute + '/docs/security': typeof DocsSecurityRoute '/docs/': typeof DocsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/docs' | '/docs/best-practices' | '/docs/' + fullPaths: '/' | '/docs' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/docs/best-practices' | '/docs' - id: '__root__' | '/' | '/docs' | '/docs/best-practices' | '/docs/' + to: '/' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs' + id: '__root__' | '/' | '/docs' | '/docs/best-practices' | '/docs/configuration' | '/docs/security' | '/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -96,16 +114,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsBestPracticesRouteImport parentRoute: typeof DocsRoute } + '/docs/configuration': { + id: '/docs/configuration' + path: '/configuration' + fullPath: '/docs/configuration' + preLoaderRoute: typeof DocsConfigurationRouteImport + parentRoute: typeof DocsRoute + } + '/docs/security': { + id: '/docs/security' + path: '/security' + fullPath: '/docs/security' + preLoaderRoute: typeof DocsSecurityRouteImport + parentRoute: typeof DocsRoute + } } } interface DocsRouteChildren { DocsBestPracticesRoute: typeof DocsBestPracticesRoute + DocsConfigurationRoute: typeof DocsConfigurationRoute + DocsSecurityRoute: typeof DocsSecurityRoute DocsIndexRoute: typeof DocsIndexRoute } const DocsRouteChildren: DocsRouteChildren = { DocsBestPracticesRoute: DocsBestPracticesRoute, + DocsConfigurationRoute: DocsConfigurationRoute, + DocsSecurityRoute: DocsSecurityRoute, DocsIndexRoute: DocsIndexRoute, } diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx index 2e659eb35..42d46928b 100644 --- a/packages/website/src/routes/docs.tsx +++ b/packages/website/src/routes/docs.tsx @@ -7,6 +7,8 @@ export const Route = createFileRoute('/docs')({ const navigation = [ { name: 'Getting started', href: '/docs' }, + { name: 'Configuration', href: '/docs/configuration' }, + { name: 'Security', href: '/docs/security' }, { name: 'Best practices', href: '/docs/best-practices' }, ]