mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
packages/server/scripts/generate-config-schema.ts
Normal file
27
packages/server/scripts/generate-config-schema.ts
Normal 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();
|
||||
@@ -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" }));
|
||||
|
||||
46
packages/server/src/server/allowed-hosts.test.ts
Normal file
46
packages/server/src/server/allowed-hosts.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
||||
104
packages/server/src/server/allowed-hosts.ts
Normal file
104
packages/server/src/server/allowed-hosts.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
223
packages/website/public/schemas/paseo.config.v1.json
Normal file
223
packages/website/public/schemas/paseo.config.v1.json
Normal 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#"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user