mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
* 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>
276 lines
8.2 KiB
TypeScript
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,
|
|
};
|
|
}
|