mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
* Load config.json saved with a UTF-8 byte order mark Windows Notepad saves UTF-8 with a BOM. Both config readers passed the text straight to JSON.parse, so the daemon refused to start and CLI config commands failed with "Unexpected token". Closes #4683 * Strip the config BOM with an anchored replace --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
573 lines
18 KiB
TypeScript
573 lines
18 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
AgentProviderRuntimeSettingsMapSchema,
|
|
migrateProviderSettings,
|
|
ProviderOverridesSchema,
|
|
} from "./agent/provider-launch-config.js";
|
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
|
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
|
import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile";
|
|
import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config";
|
|
import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile";
|
|
import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
|
|
|
|
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
|
|
export const LogFormatSchema = z.enum(["pretty", "json"]);
|
|
|
|
const LogConfigSchema = z
|
|
.object({
|
|
// Legacy global log settings (kept for backwards compatibility).
|
|
level: LogLevelSchema.optional(),
|
|
format: LogFormatSchema.optional(),
|
|
|
|
console: z
|
|
.object({
|
|
level: LogLevelSchema.optional(),
|
|
format: LogFormatSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
|
|
file: z
|
|
.object({
|
|
level: LogLevelSchema.optional(),
|
|
path: z.string().min(1).optional(),
|
|
rotate: z
|
|
.object({
|
|
maxSize: z.string().min(1).optional(),
|
|
maxFiles: z.number().int().positive().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const OpenAiSpeechEndpointSchema = z
|
|
.object({
|
|
apiKey: z.string().trim().min(1).optional(),
|
|
baseUrl: z.string().trim().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const OpenAiProviderSchema = z
|
|
.object({
|
|
apiKey: z.string().min(1).optional(),
|
|
baseUrl: z.string().trim().min(1).optional(),
|
|
stt: OpenAiSpeechEndpointSchema.optional(),
|
|
tts: OpenAiSpeechEndpointSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const LocalSpeechProviderSchema = z
|
|
.object({
|
|
modelsDir: z.string().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const ProvidersSchema = z
|
|
.object({
|
|
openai: OpenAiProviderSchema.optional(),
|
|
local: LocalSpeechProviderSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const WorktreesConfigSchema = z
|
|
.object({
|
|
root: z.string().min(1).optional(),
|
|
servicePorts: PaseoServicePortAllocationSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
|
|
message: "Expected a bcrypt hash",
|
|
});
|
|
|
|
const DaemonAuthSchema = z
|
|
.object({
|
|
password: BcryptHashSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const SpeechProviderIdSchema = z
|
|
.string()
|
|
.trim()
|
|
.toLowerCase()
|
|
.pipe(z.enum(["openai", "local"]));
|
|
|
|
const FeatureDictationSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
stt: z
|
|
.object({
|
|
provider: SpeechProviderIdSchema.optional(),
|
|
model: z.string().min(1).optional(),
|
|
language: z.string().trim().min(1).optional(),
|
|
confidenceThreshold: z.number().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const FeatureVoiceModeSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
llm: z
|
|
.object({
|
|
provider: z.string().optional(),
|
|
model: z.string().min(1).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
stt: z
|
|
.object({
|
|
provider: SpeechProviderIdSchema.optional(),
|
|
model: z.string().min(1).optional(),
|
|
language: z.string().trim().min(1).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
turnDetection: z
|
|
.object({
|
|
provider: SpeechProviderIdSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
tts: z
|
|
.object({
|
|
provider: SpeechProviderIdSchema.optional(),
|
|
model: z.string().min(1).optional(),
|
|
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
|
|
speakerId: z.number().int().optional(),
|
|
speed: z.number().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
|
|
const FeatureWebUiSchema = z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
distDir: z.string().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const StructuredGenerationProviderConfigSchema = z
|
|
.object({
|
|
provider: z.string().min(1),
|
|
model: z.string().min(1).optional(),
|
|
thinkingOptionId: z.string().min(1).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const AgentMetadataGenerationSchema = z
|
|
.object({
|
|
providers: z.array(StructuredGenerationProviderConfigSchema).optional(),
|
|
})
|
|
.strict();
|
|
|
|
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi", "omp"] as const;
|
|
|
|
function isLegacyProviderEntry(value: unknown): boolean {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
|
|
const command = (value as Record<string, unknown>).command;
|
|
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
return false;
|
|
}
|
|
|
|
return typeof (command as Record<string, unknown>).mode === "string";
|
|
}
|
|
|
|
function normalizeAgentProviders(value: unknown): unknown {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
|
|
const rawProviders = value as Record<string, unknown>;
|
|
const hasLegacyEntries = Object.values(rawProviders).some((entry) =>
|
|
isLegacyProviderEntry(entry),
|
|
);
|
|
if (!hasLegacyEntries) {
|
|
return value;
|
|
}
|
|
|
|
const legacyEntries: Record<string, unknown> = {};
|
|
const normalizedEntries: Record<string, unknown> = {};
|
|
|
|
for (const [providerId, providerValue] of Object.entries(rawProviders)) {
|
|
if (isLegacyProviderEntry(providerValue)) {
|
|
legacyEntries[providerId] = providerValue;
|
|
continue;
|
|
}
|
|
normalizedEntries[providerId] = providerValue;
|
|
}
|
|
|
|
const parsedLegacyEntries = AgentProviderRuntimeSettingsMapSchema.safeParse(legacyEntries);
|
|
if (!parsedLegacyEntries.success) {
|
|
return value;
|
|
}
|
|
|
|
return {
|
|
...normalizedEntries,
|
|
...migrateProviderSettings(parsedLegacyEntries.data, [...BUILTIN_PROVIDER_IDS]),
|
|
};
|
|
}
|
|
|
|
export const PersistedConfigSchema = z
|
|
.object({
|
|
$schema: z.string().optional(),
|
|
|
|
// v1 schema marker
|
|
version: z.literal(1).optional(),
|
|
|
|
// v1 config layout
|
|
daemon: z
|
|
.object({
|
|
listen: z.string().optional(),
|
|
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
|
|
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
|
|
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
|
|
mcp: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
injectIntoAgents: z.boolean().optional(),
|
|
})
|
|
.passthrough()
|
|
.optional(),
|
|
browserTools: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.passthrough()
|
|
.optional(),
|
|
git: z
|
|
.object({
|
|
maxProcessesPerSecond: z.number().int().positive().optional(),
|
|
maxProcessConcurrency: z.number().int().positive().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
autoArchiveAfterMerge: z.boolean().optional(),
|
|
enableTerminalAgentHooks: z.boolean().optional(),
|
|
appendSystemPrompt: z.string().optional(),
|
|
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
|
agentProfiles: z.array(AgentProfileSchema).optional(),
|
|
cors: z
|
|
.object({
|
|
allowedOrigins: z.array(z.string()).optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
relay: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
endpoint: z.string().optional(),
|
|
publicEndpoint: z.string().optional(),
|
|
useTls: z.boolean().optional(),
|
|
publicUseTls: z.boolean().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
serviceProxy: z
|
|
.object({
|
|
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
|
|
// Parsed only to suppress optional public/listen layers for old configs;
|
|
// localhost service proxying remains always enabled.
|
|
enabled: z.boolean().optional(),
|
|
listen: z.string().optional(),
|
|
publicBaseUrl: z.url().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
auth: DaemonAuthSchema.optional(),
|
|
})
|
|
.strict()
|
|
.transform(({ allowedHosts, ...daemon }) => {
|
|
const hostnames = daemon.hostnames ?? allowedHosts;
|
|
return hostnames === undefined ? daemon : { ...daemon, hostnames };
|
|
})
|
|
.optional(),
|
|
|
|
app: z
|
|
.object({
|
|
baseUrl: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
|
|
providers: ProvidersSchema.optional(),
|
|
pluginsEnabled: z.boolean().optional(),
|
|
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
|
worktrees: WorktreesConfigSchema.optional(),
|
|
agents: z
|
|
.object({
|
|
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
|
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
|
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
|
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
features: z
|
|
.object({
|
|
dictation: FeatureDictationSchema.optional(),
|
|
voiceMode: FeatureVoiceModeSchema.optional(),
|
|
webUi: FeatureWebUiSchema.optional(),
|
|
})
|
|
.strict()
|
|
.optional(),
|
|
|
|
log: LogConfigSchema.optional(),
|
|
})
|
|
.strict();
|
|
|
|
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;
|
|
|
|
export type PersistedConfig = Omit<PersistedConfigSchemaOutput, "agents"> & {
|
|
agents?: Omit<NonNullable<PersistedConfigSchemaOutput["agents"]>, "providers"> & {
|
|
providers?: AgentProviderRuntimeSettingsMap;
|
|
};
|
|
};
|
|
|
|
const CONFIG_FILENAME = "config.json";
|
|
const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
|
|
version: 1,
|
|
daemon: {
|
|
listen: "127.0.0.1:6767",
|
|
cors: {
|
|
allowedOrigins: ["https://app.paseo.sh"],
|
|
},
|
|
relay: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
app: {
|
|
baseUrl: "https://app.paseo.sh",
|
|
},
|
|
}) as PersistedConfig;
|
|
|
|
interface LoggerLike {
|
|
child(bindings: Record<string, unknown>): LoggerLike;
|
|
info(...args: unknown[]): void;
|
|
}
|
|
|
|
function getConfigPath(paseoHome: string): string {
|
|
return path.join(paseoHome, CONFIG_FILENAME);
|
|
}
|
|
|
|
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
|
return logger?.child({ module: "config" });
|
|
}
|
|
|
|
// Removed config fields are stripped before parsing so the strict schema does not
|
|
// reject a config written by an older release. The stripped values are discarded,
|
|
// not migrated — there is no back-compat for the removed `providers.openai.voice`
|
|
// block (use `providers.openai.stt` / `providers.openai.tts`).
|
|
function stripRemovedConfigFields(parsed: unknown): unknown {
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
|
|
const root = { ...(parsed as Record<string, unknown>) };
|
|
const providers = root.providers;
|
|
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
|
|
return root;
|
|
}
|
|
|
|
const providersRecord = { ...(providers as Record<string, unknown>) };
|
|
|
|
const local = providersRecord.local;
|
|
if (local && typeof local === "object" && !Array.isArray(local)) {
|
|
const localRecord = { ...(local as Record<string, unknown>) };
|
|
delete localRecord.autoDownload;
|
|
providersRecord.local = localRecord;
|
|
}
|
|
|
|
const openai = providersRecord.openai;
|
|
if (openai && typeof openai === "object" && !Array.isArray(openai)) {
|
|
const openaiRecord = { ...(openai as Record<string, unknown>) };
|
|
// COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30.
|
|
// Drop a `providers.openai.voice` block left by an older release so the strict
|
|
// schema doesn't reject it. The value is discarded, not migrated — there is no
|
|
// back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead.
|
|
delete openaiRecord.voice;
|
|
providersRecord.openai = openaiRecord;
|
|
}
|
|
|
|
root.providers = providersRecord;
|
|
return root;
|
|
}
|
|
|
|
export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig {
|
|
const log = getLogger(logger);
|
|
const configPath = getConfigPath(paseoHome);
|
|
|
|
if (!existsSync(configPath)) {
|
|
try {
|
|
writePrivateFileAtomicSync(
|
|
configPath,
|
|
JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n",
|
|
);
|
|
log?.info(`Initialized config file at ${configPath}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`[Config] Failed to initialize ${configPath}: ${message}`, { cause: err });
|
|
}
|
|
}
|
|
|
|
let raw: string;
|
|
try {
|
|
ensurePrivateFile(configPath);
|
|
raw = readFileSync(configPath, "utf-8");
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`[Config] Failed to read ${configPath}: ${message}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = parseConfigText(raw);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
|
|
const migrated = stripRemovedConfigFields(parsed);
|
|
const result = PersistedConfigSchema.safeParse(migrated);
|
|
if (!result.success) {
|
|
const issues = result.error.issues
|
|
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
|
.join("\n");
|
|
throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
|
|
}
|
|
|
|
log?.info(`Loaded from ${configPath}`);
|
|
return result.data as PersistedConfig;
|
|
}
|
|
|
|
/** Observe the file without initializing a home, identity, or default configuration. */
|
|
export function readPersistedConfig(
|
|
paseoHome: string,
|
|
options: { defaultsIfMissing?: boolean } = {},
|
|
): PersistedConfig {
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(getConfigPath(paseoHome), "utf8");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {};
|
|
throw error;
|
|
}
|
|
return PersistedConfigSchema.parse(
|
|
stripRemovedConfigFields(parseConfigText(raw)),
|
|
) as PersistedConfig;
|
|
}
|
|
|
|
/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */
|
|
function parseConfigText(raw: string): unknown {
|
|
return JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
}
|
|
|
|
function configPathParts(field: string): string[] {
|
|
const parts = field.split(".");
|
|
let schema: z.ZodType = PersistedConfigSchema;
|
|
for (const part of parts) {
|
|
if (!part || ["__proto__", "prototype", "constructor"].includes(part)) {
|
|
throw new Error(`Invalid configuration path: ${field}`);
|
|
}
|
|
while (
|
|
schema instanceof z.ZodOptional ||
|
|
schema instanceof z.ZodDefault ||
|
|
schema instanceof z.ZodPipe
|
|
)
|
|
schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType;
|
|
if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) {
|
|
throw new Error(
|
|
`Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`,
|
|
);
|
|
}
|
|
schema = schema.shape[part];
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
export function getPersistedConfigValue(config: PersistedConfig, field: string): unknown {
|
|
return configPathParts(field).reduce<unknown>(
|
|
(value, part) =>
|
|
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined,
|
|
config,
|
|
);
|
|
}
|
|
|
|
export function editPersistedConfig(
|
|
paseoHome: string,
|
|
field: string,
|
|
edit: { value: unknown } | { unset: true },
|
|
): PersistedConfig {
|
|
const parts = configPathParts(field);
|
|
if (field === "daemon.auth" || field.startsWith("daemon.auth.")) {
|
|
throw new Error("Use daemon set-password to change the daemon password.");
|
|
}
|
|
const config = readPersistedConfig(paseoHome, { defaultsIfMissing: true });
|
|
let object = config as Record<string, unknown>;
|
|
for (const part of parts.slice(0, -1)) {
|
|
object[part] ??= {};
|
|
object = object[part] as Record<string, unknown>;
|
|
}
|
|
const key = parts.at(-1)!;
|
|
if (field === "daemon") {
|
|
const previousAuth = config.daemon?.auth;
|
|
const nextAuth =
|
|
"value" in edit && edit.value && typeof edit.value === "object"
|
|
? (edit.value as Record<string, unknown>).auth
|
|
: undefined;
|
|
if (JSON.stringify(previousAuth) !== JSON.stringify(nextAuth))
|
|
throw new Error("Use daemon set-password to change the daemon password.");
|
|
}
|
|
if ("unset" in edit) delete object[key];
|
|
else object[key] = edit.value;
|
|
savePersistedConfig(paseoHome, config);
|
|
return config;
|
|
}
|
|
|
|
export function savePersistedConfig(
|
|
paseoHome: string,
|
|
config: PersistedConfig,
|
|
logger?: LoggerLike,
|
|
): void {
|
|
const log = getLogger(logger);
|
|
const configPath = getConfigPath(paseoHome);
|
|
|
|
const result = PersistedConfigSchema.safeParse(config);
|
|
if (!result.success) {
|
|
const issues = result.error.issues
|
|
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
|
.join("\n");
|
|
throw new Error(`[Config] Invalid config to save:\n${issues}`);
|
|
}
|
|
|
|
try {
|
|
writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
|
|
log?.info(`Saved to ${configPath}`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`[Config] Failed to write ${configPath}: ${message}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
}
|