Files
paseo/packages/server/src/server/config.ts
T
Mohamed Boudraandgithub-actions[bot] 088a6c14c0 feat: direct TCP URI with SSL toggle and optional password auth (#635)
* feat: direct TCP URI with SSL toggle and optional password auth

Replaces the heuristic-driven direct-connection model with a user-controlled
one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and
masked Password fields that compose the canonical
`tcp://host:port?ssl=true&password=xxx` URI used as the storage form.

The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on
HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade
(browser WebSocket can't set custom headers). Configured via config.json
`auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep
working unchanged.

The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag
drives scheme selection at every call site.

* fix: stabilize direct tcp auth ci checks

* fix: restore fetch stub in bootstrap smoke test

* fix(app): collapse Advanced section in add-host modal

* feat(server): hash daemon password in config

* fix: update lockfile signatures and Nix hash

* Improve direct TCP auth failures

* Fix workspace cwd updates after rebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 22:05:49 +08:00

276 lines
8.2 KiB
TypeScript

import path from "node:path";
import { resolvePaseoNodeEnv } from "./paseo-env.js";
import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import { loadPersistedConfig } from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { hashDaemonPassword } from "./auth.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
export type CliConfigOverrides = Partial<{
listen: string;
relayEnabled: boolean;
mcpEnabled: boolean;
mcpInjectIntoAgents: boolean;
hostnames: HostnamesConfig;
}>;
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
typeof value === "string" ? value.trim().toLowerCase() : null,
)
.pipe(z.union([AgentProviderSchema, z.null()]));
function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
const parsed = OptionalVoiceLlmProviderSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function extractProviderOverrides(
providers: Record<string, unknown> | undefined,
): Record<string, ProviderOverride> | undefined {
if (!providers) {
return undefined;
}
const providerOverrides = Object.entries(providers).flatMap(([providerId, provider]) => {
const parsed = ProviderOverrideSchema.safeParse(provider);
return parsed.success ? [[providerId, parsed.data] as const] : [];
});
return providerOverrides.length > 0 ? Object.fromEntries(providerOverrides) : undefined;
}
function extractAgentProviderSettings(
providerOverrides: Record<string, ProviderOverride> | undefined,
): AgentProviderRuntimeSettingsMap | undefined {
if (!providerOverrides) {
return undefined;
}
const runtimeSettings = Object.entries(providerOverrides).flatMap(([providerId, provider]) => {
const parsedProviderId = AgentProviderSchema.safeParse(providerId);
if (!parsedProviderId.success || (!provider.command && !provider.env)) {
return [];
}
return [
[
parsedProviderId.data,
{
command: provider.command
? {
mode: "replace" as const,
argv: provider.command,
}
: undefined,
env: provider.env,
},
] as const,
];
});
return runtimeSettings.length > 0
? (Object.fromEntries(runtimeSettings) as AgentProviderRuntimeSettingsMap)
: undefined;
}
interface ResolveRelayInput {
env: NodeJS.ProcessEnv;
persisted: ReturnType<typeof loadPersistedConfig>;
cliRelayEnabled: boolean | undefined;
}
interface ResolvedRelay {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const enabled =
input.cliRelayEnabled ??
parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ??
input.persisted.daemon?.relay?.enabled ??
true;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
const publicEndpoint =
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ??
endpoint;
return { enabled, endpoint, publicEndpoint };
}
interface ResolvedVoiceLlm {
provider: AgentProvider | null;
providerExplicit: boolean;
model: string | null;
}
function resolveVoiceLlmConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedVoiceLlm {
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
persisted.features?.voiceMode?.llm?.provider,
);
return {
provider: envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null,
providerExplicit: envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null,
model: persisted.features?.voiceMode?.llm?.model ?? null,
};
}
function resolveCorsAllowedOrigins(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): string[] {
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
: [];
const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? [];
return Array.from(
new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)),
);
}
// PASEO_LISTEN can be:
// - host:port (TCP)
// - /path/to/socket (Unix socket)
// - unix:///path/to/socket (Unix socket)
// Default is TCP at 127.0.0.1:6767
function resolveListenAddress(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
persisted: ReturnType<typeof loadPersistedConfig>,
): string {
return (
cli?.listen ??
env.PASEO_LISTEN ??
persisted.daemon?.listen ??
`127.0.0.1:${env.PORT ?? DEFAULT_PORT}`
);
}
function resolveAuthConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PaseoDaemonConfig["auth"] {
const envPassword = env.PASEO_PASSWORD?.trim();
if (envPassword) {
return { password: hashDaemonPassword(envPassword) };
}
return persisted.daemon?.auth?.password
? { password: persisted.daemon.auth.password }
: undefined;
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
persisted: ReturnType<typeof loadPersistedConfig>,
) {
return {
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
mcpInjectIntoAgents:
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
cli?.hostnames,
]),
appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL,
};
}
export function loadConfig(
paseoHome: string,
options?: {
env?: NodeJS.ProcessEnv;
cli?: CliConfigOverrides;
},
): PaseoDaemonConfig {
const env = options?.env ?? process.env;
const persisted = loadPersistedConfig(paseoHome);
const listen = resolveListenAddress(env, options?.cli, persisted);
const { mcpEnabled, mcpInjectIntoAgents, hostnames, appBaseUrl } =
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const relay = resolveRelayConfig({
env,
persisted,
cliRelayEnabled: options?.cli?.relayEnabled,
});
const { openai, speech } = resolveSpeechConfig({
paseoHome,
env,
persisted,
});
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
const providerOverrides = extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
return {
listen,
paseoHome,
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
mcpEnabled,
mcpInjectIntoAgents,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public",
agentClients: {},
relayEnabled: relay.enabled,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,
speech,
voiceLlmProvider: voiceLlm.provider,
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides,
};
}