Files
paseo/packages/server/src/server/persisted-config.ts
T
paseo-bot[bot] c356394bfa Load config.json saved with a UTF-8 byte order mark (#5315)
* 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>
2026-09-24 03:48:52 +02:00

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