Update files

This commit is contained in:
Mohamed Boudra
2026-02-04 10:18:51 +07:00
parent 7c15b26329
commit 0aa2fede08
20 changed files with 880 additions and 198 deletions

View File

@@ -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");

View File

@@ -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 <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.option('--allowed-hosts <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 <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<void> {
if (options.home) {
process.env.PASEO_HOME = options.home
}
let paseoHome: string
let logger: ReturnType<typeof createRootLogger>
let config: ReturnType<typeof loadConfig>
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

View File

@@ -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",

View File

@@ -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();

View File

@@ -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" }));

View File

@@ -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"]);
});
});

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);
}

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,

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,
};
}

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";

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);
});

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;

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>;

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: {

View File

@@ -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,
};

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 &&

View File

@@ -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
);

View File

@@ -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#"
}

View File

@@ -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,
}

View File

@@ -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' },
]