import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; import { loadShellEnvFallback, resolveShellEnvFallbackTimeoutMs, shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { applyContextPruningDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; import { VERSION } from "../version.js"; import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"; import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js"; import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObject } from "./validation.js"; import { ClawdbotSchema } from "./zod-schema.js"; import { compareClawdbotVersions } from "./version.js"; // Re-export for backwards compatibility export { CircularIncludeError, ConfigIncludeError } from "./includes.js"; export { MissingEnvVarError } from "./env-substitution.js"; const SHELL_ENV_EXPECTED_KEYS = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "GEMINI_API_KEY", "ZAI_API_KEY", "OPENROUTER_API_KEY", "AI_GATEWAY_API_KEY", "MINIMAX_API_KEY", "SYNTHETIC_API_KEY", "ELEVENLABS_API_KEY", "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_PASSWORD", ]; const CONFIG_BACKUP_COUNT = 5; export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string }; function hashConfigRaw(raw: string | null): string { return crypto .createHash("sha256") .update(raw ?? "") .digest("hex"); } export function resolveConfigSnapshotHash(snapshot: { hash?: string; raw?: string | null; }): string | null { if (typeof snapshot.hash === "string") { const trimmed = snapshot.hash.trim(); if (trimmed) return trimmed; } if (typeof snapshot.raw !== "string") return null; return hashConfigRaw(snapshot.raw); } function coerceConfig(value: unknown): ClawdbotConfig { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } return value as ClawdbotConfig; } function rotateConfigBackupsSync(configPath: string, ioFs: typeof fs): void { if (CONFIG_BACKUP_COUNT <= 1) return; const backupBase = `${configPath}.bak`; const maxIndex = CONFIG_BACKUP_COUNT - 1; try { ioFs.unlinkSync(`${backupBase}.${maxIndex}`); } catch { // best-effort } for (let index = maxIndex - 1; index >= 1; index -= 1) { try { ioFs.renameSync(`${backupBase}.${index}`, `${backupBase}.${index + 1}`); } catch { // best-effort } } try { ioFs.renameSync(backupBase, `${backupBase}.1`); } catch { // best-effort } } async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise { if (CONFIG_BACKUP_COUNT <= 1) return; const backupBase = `${configPath}.bak`; const maxIndex = CONFIG_BACKUP_COUNT - 1; await ioFs.unlink(`${backupBase}.${maxIndex}`).catch(() => { // best-effort }); for (let index = maxIndex - 1; index >= 1; index -= 1) { await ioFs.rename(`${backupBase}.${index}`, `${backupBase}.${index + 1}`).catch(() => { // best-effort }); } await ioFs.rename(backupBase, `${backupBase}.1`).catch(() => { // best-effort }); } export type ConfigIoDeps = { fs?: typeof fs; json5?: typeof JSON5; env?: NodeJS.ProcessEnv; homedir?: () => string; configPath?: string; logger?: Pick; }; function warnOnConfigMiskeys(raw: unknown, logger: Pick): void { if (!raw || typeof raw !== "object") return; const gateway = (raw as Record).gateway; if (!gateway || typeof gateway !== "object") return; if ("token" in (gateway as Record)) { logger.warn( 'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.', ); } } function formatLegacyMigrationLog(changes: string[]): string { return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`; } function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig { const now = new Date().toISOString(); return { ...cfg, meta: { ...cfg.meta, lastTouchedVersion: VERSION, lastTouchedAt: now, }, }; } function warnIfConfigFromFuture(cfg: ClawdbotConfig, logger: Pick): void { const touched = cfg.meta?.lastTouchedVersion; if (!touched) return; const cmp = compareClawdbotVersions(VERSION, touched); if (cmp === null) return; if (cmp < 0) { logger.warn( `Config was last written by a newer Clawdbot (${touched}); current version is ${VERSION}.`, ); } } function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void { const envConfig = cfg.env; if (!envConfig) return; const entries: Record = {}; if (envConfig.vars) { for (const [key, value] of Object.entries(envConfig.vars)) { if (!value) continue; entries[key] = value; } } for (const [key, value] of Object.entries(envConfig)) { if (key === "shellEnv" || key === "vars") continue; if (typeof value !== "string" || !value.trim()) continue; entries[key] = value; } for (const [key, value] of Object.entries(entries)) { if (env[key]?.trim()) continue; env[key] = value; } } function resolveConfigPathForDeps(deps: Required): string { if (deps.configPath) return deps.configPath; return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir)); } function normalizeDeps(overrides: ConfigIoDeps = {}): Required { return { fs: overrides.fs ?? fs, json5: overrides.json5 ?? JSON5, env: overrides.env ?? process.env, homedir: overrides.homedir ?? os.homedir, configPath: overrides.configPath ?? "", logger: overrides.logger ?? console, }; } export function parseConfigJson5( raw: string, json5: { parse: (value: string) => unknown } = JSON5, ): ParseConfigJson5Result { try { return { ok: true, parsed: json5.parse(raw) as unknown }; } catch (err) { return { ok: false, error: String(err) }; } } export function createConfigIO(overrides: ConfigIoDeps = {}) { const deps = normalizeDeps(overrides); const configPath = resolveConfigPathForDeps(deps); const writeConfigFileSync = (cfg: ClawdbotConfig) => { const dir = path.dirname(configPath); deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2) .trimEnd() .concat("\n"); const tmp = path.join( dir, `${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`, ); deps.fs.writeFileSync(tmp, json, { encoding: "utf-8", mode: 0o600 }); if (deps.fs.existsSync(configPath)) { rotateConfigBackupsSync(configPath, deps.fs); try { deps.fs.copyFileSync(configPath, `${configPath}.bak`); } catch { // best-effort } } try { deps.fs.renameSync(tmp, configPath); } catch (err) { const code = (err as { code?: string }).code; if (code === "EPERM" || code === "EEXIST") { deps.fs.copyFileSync(tmp, configPath); try { deps.fs.chmodSync(configPath, 0o600); } catch { // best-effort } try { deps.fs.unlinkSync(tmp); } catch { // best-effort } return; } try { deps.fs.unlinkSync(tmp); } catch { // best-effort } throw err; } }; function loadConfig(): ClawdbotConfig { try { if (!deps.fs.existsSync(configPath)) { if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) { loadShellEnvFallback({ enabled: true, env: deps.env, expectedKeys: SHELL_ENV_EXPECTED_KEYS, logger: deps.logger, timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env), }); } return {}; } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); // Resolve $include directives before validation const resolved = resolveConfigIncludes(parsed, configPath, { readFile: (p) => deps.fs.readFileSync(p, "utf-8"), parseJson: (raw) => deps.json5.parse(raw), }); // Substitute ${VAR} env var references const substituted = resolveConfigEnvVars(resolved, deps.env); const migrated = applyLegacyMigrations(substituted); let resolvedConfig = migrated.next ?? substituted; const autoEnable = applyPluginAutoEnable({ config: coerceConfig(resolvedConfig), env: deps.env, }); resolvedConfig = autoEnable.config; const migrationChanges = [...migrated.changes, ...autoEnable.changes]; warnOnConfigMiskeys(resolvedConfig, deps.logger); if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {}; const validated = ClawdbotSchema.safeParse(resolvedConfig); if (!validated.success) { deps.logger.error("Invalid config:"); for (const iss of validated.error.issues) { deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`); } return {}; } warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger); if (migrationChanges.length > 0) { deps.logger.warn(formatLegacyMigrationLog(migrationChanges)); try { writeConfigFileSync(resolvedConfig as ClawdbotConfig); } catch (err) { deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`); } } const cfg = applyModelDefaults( applyContextPruningDefaults( applySessionDefaults( applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)), ), ), ); normalizeConfigPaths(cfg); const duplicates = findDuplicateAgentDirs(cfg, { env: deps.env, homedir: deps.homedir, }); if (duplicates.length > 0) { throw new DuplicateAgentDirError(duplicates); } applyConfigEnv(cfg, deps.env); const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true; if (enabled && !shouldDeferShellEnvFallback(deps.env)) { loadShellEnvFallback({ enabled: true, env: deps.env, expectedKeys: SHELL_ENV_EXPECTED_KEYS, logger: deps.logger, timeoutMs: cfg.env?.shellEnv?.timeoutMs ?? resolveShellEnvFallbackTimeoutMs(deps.env), }); } return applyConfigOverrides(cfg); } catch (err) { if (err instanceof DuplicateAgentDirError) { deps.logger.error(err.message); throw err; } deps.logger.error(`Failed to read config at ${configPath}`, err); return {}; } } async function readConfigFileSnapshot(): Promise { const exists = deps.fs.existsSync(configPath); if (!exists) { const hash = hashConfigRaw(null); const config = applyTalkApiKey( applyModelDefaults( applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))), ), ); const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, raw: null, parsed: {}, valid: true, config, hash, issues: [], legacyIssues, }; } try { const raw = deps.fs.readFileSync(configPath, "utf-8"); const hash = hashConfigRaw(raw); const parsedRes = parseConfigJson5(raw, deps.json5); if (!parsedRes.ok) { return { path: configPath, exists: true, raw, parsed: {}, valid: false, config: {}, hash, issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], legacyIssues: [], }; } // Resolve $include directives let resolved: unknown; try { resolved = resolveConfigIncludes(parsedRes.parsed, configPath, { readFile: (p) => deps.fs.readFileSync(p, "utf-8"), parseJson: (raw) => deps.json5.parse(raw), }); } catch (err) { const message = err instanceof ConfigIncludeError ? err.message : `Include resolution failed: ${String(err)}`; return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: false, config: coerceConfig(parsedRes.parsed), hash, issues: [{ path: "", message }], legacyIssues: [], }; } // Substitute ${VAR} env var references let substituted: unknown; try { substituted = resolveConfigEnvVars(resolved, deps.env); } catch (err) { const message = err instanceof MissingEnvVarError ? err.message : `Env var substitution failed: ${String(err)}`; return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: false, config: coerceConfig(resolved), hash, issues: [{ path: "", message }], legacyIssues: [], }; } const migrated = applyLegacyMigrations(substituted); let resolvedConfigRaw = migrated.next ?? substituted; const autoEnable = applyPluginAutoEnable({ config: coerceConfig(resolvedConfigRaw), env: deps.env, }); resolvedConfigRaw = autoEnable.config; const migrationChanges = [...migrated.changes, ...autoEnable.changes]; const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const validated = validateConfigObject(resolvedConfigRaw); if (!validated.ok) { return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: false, config: coerceConfig(resolvedConfigRaw), hash, issues: validated.issues, legacyIssues, }; } warnIfConfigFromFuture(validated.config, deps.logger); if (migrationChanges.length > 0) { deps.logger.warn(formatLegacyMigrationLog(migrationChanges)); await writeConfigFile(validated.config).catch((err) => { deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`); }); } return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: true, config: normalizeConfigPaths( applyTalkApiKey( applyModelDefaults( applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), ), ), ), hash, issues: [], legacyIssues, }; } catch (err) { return { path: configPath, exists: true, raw: null, parsed: {}, valid: false, config: {}, hash: hashConfigRaw(null), issues: [{ path: "", message: `read failed: ${String(err)}` }], legacyIssues: [], }; } } async function writeConfigFile(cfg: ClawdbotConfig) { clearConfigCache(); const dir = path.dirname(configPath); await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2) .trimEnd() .concat("\n"); const tmp = path.join( dir, `${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`, ); await deps.fs.promises.writeFile(tmp, json, { encoding: "utf-8", mode: 0o600, }); if (deps.fs.existsSync(configPath)) { await rotateConfigBackups(configPath, deps.fs.promises); await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => { // best-effort }); } try { await deps.fs.promises.rename(tmp, configPath); } catch (err) { const code = (err as { code?: string }).code; // Windows doesn't reliably support atomic replace via rename when dest exists. if (code === "EPERM" || code === "EEXIST") { await deps.fs.promises.copyFile(tmp, configPath); await deps.fs.promises.chmod(configPath, 0o600).catch(() => { // best-effort }); await deps.fs.promises.unlink(tmp).catch(() => { // best-effort }); return; } await deps.fs.promises.unlink(tmp).catch(() => { // best-effort }); throw err; } } return { configPath, loadConfig, readConfigFileSnapshot, writeConfigFile, }; } // NOTE: These wrappers intentionally do *not* cache the resolved config path at // module scope. `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even // when set after the module has been imported (tests, one-off scripts, etc.). const DEFAULT_CONFIG_CACHE_MS = 200; let configCache: { configPath: string; expiresAt: number; config: ClawdbotConfig; } | null = null; function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number { const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim(); if (raw === "" || raw === "0") return 0; if (!raw) return DEFAULT_CONFIG_CACHE_MS; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) return DEFAULT_CONFIG_CACHE_MS; return Math.max(0, parsed); } function shouldUseConfigCache(env: NodeJS.ProcessEnv): boolean { if (env.CLAWDBOT_DISABLE_CONFIG_CACHE?.trim()) return false; return resolveConfigCacheMs(env) > 0; } function clearConfigCache(): void { configCache = null; } export function loadConfig(): ClawdbotConfig { const configPath = resolveConfigPath(); const now = Date.now(); if (shouldUseConfigCache(process.env)) { const cached = configCache; if (cached && cached.configPath === configPath && cached.expiresAt > now) { return cached.config; } } const config = createConfigIO({ configPath }).loadConfig(); if (shouldUseConfigCache(process.env)) { const cacheMs = resolveConfigCacheMs(process.env); if (cacheMs > 0) { configCache = { configPath, expiresAt: now + cacheMs, config, }; } } return config; } export async function readConfigFileSnapshot(): Promise { return await createConfigIO({ configPath: resolveConfigPath(), }).readConfigFileSnapshot(); } export async function writeConfigFile(cfg: ClawdbotConfig): Promise { clearConfigCache(); await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg); }