Merge branch 'plan-paseo-config-schema'

This commit is contained in:
Mohamed Boudra
2026-02-04 10:18:58 +07:00
20 changed files with 880 additions and 198 deletions
@@ -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" }));
@@ -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"]);
});
});
+104
View File
@@ -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>
): 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);
}
+131 -106
View File
@@ -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<string, StreamableHTTPServerTransport>;
@@ -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<Record<AgentProvider, AgentClient>>;
@@ -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<InMemoryTransport> => {
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,
+90 -34
View File
@@ -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,
};
}
+1 -1
View File
@@ -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";
+22 -9
View File
@@ -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<typeof createRootLogger>;
let config: ReturnType<typeof loadConfig>;
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);
});
+1 -1
View File
@@ -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;
+114 -16
View File
@@ -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<typeof PersistedConfigSchema>;
+14 -4
View File
@@ -312,6 +312,8 @@ export class Session {
} | null = null;
private readonly terminalManager: TerminalManager | null;
private terminalSubscriptions: Map<string, () => 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: {
@@ -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,
};
+14 -1
View File
@@ -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<Transport>;
type WebSocketServerConfig = {
allowedOrigins: Set<string>;
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 &&
@@ -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
);