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 { applyCompactionDefaults, applyContextPruningDefaults, applyAgentDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; import { VERSION } from "../version.js"; import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"; import { collectConfigEnvVars } from "./env-vars.js"; import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { findLegacyConfigIssues } from "./legacy.js"; import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { MoltbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; import { compareMoltbotVersions } 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; const loggedInvalidConfigs = new Set(); 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): MoltbotConfig { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } return value as MoltbotConfig; } 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 stampConfigVersion(cfg: MoltbotConfig): MoltbotConfig { const now = new Date().toISOString(); return { ...cfg, meta: { ...cfg.meta, lastTouchedVersion: VERSION, lastTouchedAt: now, }, }; } function warnIfConfigFromFuture(cfg: MoltbotConfig, logger: Pick): void { const touched = cfg.meta?.lastTouchedVersion; if (!touched) return; const cmp = compareMoltbotVersions(VERSION, touched); if (cmp === null) return; if (cmp < 0) { logger.warn( `Config was last written by a newer Moltbot (${touched}); current version is ${VERSION}.`, ); } } function applyConfigEnv(cfg: MoltbotConfig, env: NodeJS.ProcessEnv): void { const entries = collectConfigEnvVars(cfg); 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 requestedConfigPath = resolveConfigPathForDeps(deps); const candidatePaths = deps.configPath ? [requestedConfigPath] : resolveDefaultConfigCandidates(deps.env, deps.homedir); const configPath = candidatePaths.find((candidate) => deps.fs.existsSync(candidate)) ?? requestedConfigPath; function loadConfig(): MoltbotConfig { 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), }); // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars if (resolved && typeof resolved === "object" && "env" in resolved) { applyConfigEnv(resolved as MoltbotConfig, deps.env); } // Substitute ${VAR} env var references const substituted = resolveConfigEnvVars(resolved, deps.env); const resolvedConfig = substituted; warnOnConfigMiskeys(resolvedConfig, deps.logger); if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {}; const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as MoltbotConfig, { env: deps.env, homedir: deps.homedir, }); if (preValidationDuplicates.length > 0) { throw new DuplicateAgentDirError(preValidationDuplicates); } const validated = validateConfigObjectWithPlugins(resolvedConfig); if (!validated.ok) { const details = validated.issues .map((iss) => `- ${iss.path || ""}: ${iss.message}`) .join("\n"); if (!loggedInvalidConfigs.has(configPath)) { loggedInvalidConfigs.add(configPath); deps.logger.error(`Invalid config at ${configPath}:\\n${details}`); } const error = new Error("Invalid config"); (error as { code?: string; details?: string }).code = "INVALID_CONFIG"; (error as { code?: string; details?: string }).details = details; throw error; } if (validated.warnings.length > 0) { const details = validated.warnings .map((iss) => `- ${iss.path || ""}: ${iss.message}`) .join("\n"); deps.logger.warn(`Config warnings:\\n${details}`); } warnIfConfigFromFuture(validated.config, deps.logger); const cfg = applyModelDefaults( applyCompactionDefaults( applyContextPruningDefaults( applyAgentDefaults( applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), ), ), ), ); 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; } const error = err as { code?: string }; if (error?.code === "INVALID_CONFIG") { return {}; } 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( applyCompactionDefaults( applyContextPruningDefaults( applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))), ), ), ), ); const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, raw: null, parsed: {}, valid: true, config, hash, issues: [], warnings: [], 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}` }], warnings: [], 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 }], warnings: [], legacyIssues: [], }; } // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars if (resolved && typeof resolved === "object" && "env" in resolved) { applyConfigEnv(resolved as MoltbotConfig, deps.env); } // 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 }], warnings: [], legacyIssues: [], }; } const resolvedConfigRaw = substituted; const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const validated = validateConfigObjectWithPlugins(resolvedConfigRaw); if (!validated.ok) { return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: false, config: coerceConfig(resolvedConfigRaw), hash, issues: validated.issues, warnings: validated.warnings, legacyIssues, }; } warnIfConfigFromFuture(validated.config, deps.logger); return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: true, config: normalizeConfigPaths( applyTalkApiKey( applyModelDefaults( applyAgentDefaults( applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), ), ), ), ), hash, issues: [], warnings: validated.warnings, legacyIssues, }; } catch (err) { return { path: configPath, exists: true, raw: null, parsed: {}, valid: false, config: {}, hash: hashConfigRaw(null), issues: [{ path: "", message: `read failed: ${String(err)}` }], warnings: [], legacyIssues: [], }; } } async function writeConfigFile(cfg: MoltbotConfig) { clearConfigCache(); const validated = validateConfigObjectWithPlugins(cfg); if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : ""; throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`); } if (validated.warnings.length > 0) { const details = validated.warnings .map((warning) => `- ${warning.path}: ${warning.message}`) .join("\n"); deps.logger.warn(`Config warnings:\n${details}`); } 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: MoltbotConfig; } | 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(): MoltbotConfig { 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: MoltbotConfig): Promise { clearConfigCache(); await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg); }