Files
openclaw/src/config/io.ts
2026-02-22 22:43:18 +01:00

1308 lines
41 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import JSON5 from "json5";
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
import { loadDotEnv } from "../infra/dotenv.js";
import { normalizeSafeBinProfileFixtures } from "../infra/exec-safe-bin-policy.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import {
loadShellEnvFallback,
resolveShellEnvFallbackTimeoutMs,
shouldDeferShellEnvFallback,
shouldEnableShellEnvFallback,
} from "../infra/shell-env.js";
import { VERSION } from "../version.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
import { rotateConfigBackups } from "./backup-rotation.js";
import {
applyCompactionDefaults,
applyContextPruningDefaults,
applyAgentDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
import { restoreEnvVarRefs } from "./env-preserve.js";
import {
MissingEnvVarError,
containsEnvVarReference,
resolveConfigEnvVars,
} from "./env-substitution.js";
import { applyConfigEnvVars } from "./env-vars.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { applyMergePatch } from "./merge-patch.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
import { compareOpenClawVersions } 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",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
];
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
const loggedInvalidConfigs = new Set<string>();
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
type ConfigWriteAuditRecord = {
ts: string;
source: "config-io";
event: "config.write";
result: ConfigWriteAuditResult;
configPath: string;
pid: number;
ppid: number;
cwd: string;
argv: string[];
execArgv: string[];
watchMode: boolean;
watchSession: string | null;
watchCommand: string | null;
existsBefore: boolean;
previousHash: string | null;
nextHash: string | null;
previousBytes: number | null;
nextBytes: number | null;
changedPathCount: number | null;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
suspicious: string[];
errorCode?: string;
errorMessage?: string;
};
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
export type ConfigWriteOptions = {
/**
* Read-time env snapshot used to validate `${VAR}` restoration decisions.
* If omitted, write falls back to current process env.
*/
envSnapshotForRestore?: Record<string, string | undefined>;
/**
* Optional safety check: only use envSnapshotForRestore when writing the
* same config file path that produced the snapshot.
*/
expectedConfigPath?: string;
/**
* Paths that must be explicitly removed from the persisted file payload,
* even if schema/default normalization reintroduces them.
*/
unsetPaths?: string[][];
};
export type ReadConfigFileSnapshotForWriteResult = {
snapshot: ConfigFileSnapshot;
writeOptions: ConfigWriteOptions;
};
function hashConfigRaw(raw: string | null): string {
return crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
}
function isNumericPathSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function unsetPathForWrite(root: Record<string, unknown>, pathSegments: string[]): boolean {
if (pathSegments.length === 0) {
return false;
}
const traversal: Array<{ container: unknown; key: string | number }> = [];
let cursor: unknown = root;
for (let i = 0; i < pathSegments.length - 1; i += 1) {
const segment = pathSegments[i];
if (Array.isArray(cursor)) {
if (!isNumericPathSegment(segment)) {
return false;
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= cursor.length) {
return false;
}
traversal.push({ container: cursor, key: index });
cursor = cursor[index];
continue;
}
if (!isWritePlainObject(cursor) || !(segment in cursor)) {
return false;
}
traversal.push({ container: cursor, key: segment });
cursor = cursor[segment];
}
const leaf = pathSegments[pathSegments.length - 1];
if (Array.isArray(cursor)) {
if (!isNumericPathSegment(leaf)) {
return false;
}
const index = Number.parseInt(leaf, 10);
if (!Number.isFinite(index) || index < 0 || index >= cursor.length) {
return false;
}
cursor.splice(index, 1);
} else {
if (!isWritePlainObject(cursor) || !(leaf in cursor)) {
return false;
}
delete cursor[leaf];
}
// Prune now-empty object branches after unsetting to avoid dead config scaffolding.
for (let i = traversal.length - 1; i >= 0; i -= 1) {
const { container, key } = traversal[i];
let child: unknown;
if (Array.isArray(container)) {
child = typeof key === "number" ? container[key] : undefined;
} else if (isWritePlainObject(container)) {
child = container[String(key)];
} else {
break;
}
if (!isWritePlainObject(child) || Object.keys(child).length > 0) {
break;
}
if (Array.isArray(container) && typeof key === "number") {
if (key >= 0 && key < container.length) {
container.splice(key, 1);
}
} else if (isWritePlainObject(container)) {
delete container[String(key)];
}
}
return true;
}
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): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasConfigMeta(value: unknown): boolean {
if (!isPlainObject(value)) {
return false;
}
const meta = value.meta;
return isPlainObject(meta);
}
function resolveGatewayMode(value: unknown): string | null {
if (!isPlainObject(value)) {
return null;
}
const gateway = value.gateway;
if (!isPlainObject(gateway) || typeof gateway.mode !== "string") {
return null;
}
const trimmed = gateway.mode.trim();
return trimmed.length > 0 ? trimmed : null;
}
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
function createMergePatch(base: unknown, target: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(target)) {
return cloneUnknown(target);
}
const patch: Record<string, unknown> = {};
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget) {
patch[key] = null;
continue;
}
const targetValue = target[key];
if (!hasBase) {
patch[key] = cloneUnknown(targetValue);
continue;
}
const baseValue = base[key];
if (isPlainObject(baseValue) && isPlainObject(targetValue)) {
const childPatch = createMergePatch(baseValue, targetValue);
if (isPlainObject(childPatch) && Object.keys(childPatch).length === 0) {
continue;
}
patch[key] = childPatch;
continue;
}
if (!isDeepStrictEqual(baseValue, targetValue)) {
patch[key] = cloneUnknown(targetValue);
}
}
return patch;
}
function collectEnvRefPaths(value: unknown, path: string, output: Map<string, string>): void {
if (typeof value === "string") {
if (containsEnvVarReference(value)) {
output.set(path, value);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
collectEnvRefPaths(item, `${path}[${index}]`, output);
});
return;
}
if (isPlainObject(value)) {
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
collectEnvRefPaths(child, childPath, output);
}
}
}
function collectChangedPaths(
base: unknown,
target: unknown,
path: string,
output: Set<string>,
): void {
if (Array.isArray(base) && Array.isArray(target)) {
const max = Math.max(base.length, target.length);
for (let index = 0; index < max; index += 1) {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
if (index >= base.length || index >= target.length) {
output.add(childPath);
continue;
}
collectChangedPaths(base[index], target[index], childPath, output);
}
return;
}
if (isPlainObject(base) && isPlainObject(target)) {
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const childPath = path ? `${path}.${key}` : key;
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget || !hasBase) {
output.add(childPath);
continue;
}
collectChangedPaths(base[key], target[key], childPath, output);
}
return;
}
if (!isDeepStrictEqual(base, target)) {
output.add(path);
}
}
function parentPath(value: string): string {
if (!value) {
return "";
}
if (value.endsWith("]")) {
const index = value.lastIndexOf("[");
return index > 0 ? value.slice(0, index) : "";
}
const index = value.lastIndexOf(".");
return index >= 0 ? value.slice(0, index) : "";
}
function isPathChanged(path: string, changedPaths: Set<string>): boolean {
if (changedPaths.has(path)) {
return true;
}
let current = parentPath(path);
while (current) {
if (changedPaths.has(current)) {
return true;
}
current = parentPath(current);
}
return changedPaths.has("");
}
function restoreEnvRefsFromMap(
value: unknown,
path: string,
envRefMap: Map<string, string>,
changedPaths: Set<string>,
): unknown {
if (typeof value === "string") {
if (!isPathChanged(path, changedPaths)) {
const original = envRefMap.get(path);
if (original !== undefined) {
return original;
}
}
return value;
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item, index) => {
const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths);
if (updated !== item) {
changed = true;
}
return updated;
});
return changed ? next : value;
}
if (isPlainObject(value)) {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths);
if (updated !== child) {
changed = true;
}
next[key] = updated;
}
return changed ? next : value;
}
return value;
}
function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
}
function resolveConfigWriteSuspiciousReasons(params: {
existsBefore: boolean;
previousBytes: number | null;
nextBytes: number | null;
hasMetaBefore: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
}): string[] {
const reasons: string[] = [];
if (!params.existsBefore) {
return reasons;
}
if (
typeof params.previousBytes === "number" &&
typeof params.nextBytes === "number" &&
params.previousBytes >= 512 &&
params.nextBytes < Math.floor(params.previousBytes * 0.5)
) {
reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`);
}
if (!params.hasMetaBefore) {
reasons.push("missing-meta-before-write");
}
if (params.gatewayModeBefore && !params.gatewayModeAfter) {
reasons.push("gateway-mode-removed");
}
return reasons;
}
async function appendConfigWriteAuditRecord(
deps: Required<ConfigIoDeps>,
record: ConfigWriteAuditRecord,
): Promise<void> {
try {
const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
await deps.fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
await deps.fs.promises.appendFile(auditPath, `${JSON.stringify(record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
export type ConfigIoDeps = {
fs?: typeof fs;
json5?: typeof JSON5;
env?: NodeJS.ProcessEnv;
homedir?: () => string;
configPath?: string;
logger?: Pick<typeof console, "error" | "warn">;
};
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
if (!raw || typeof raw !== "object") {
return;
}
const gateway = (raw as Record<string, unknown>).gateway;
if (!gateway || typeof gateway !== "object") {
return;
}
if ("token" in (gateway as Record<string, unknown>)) {
logger.warn(
'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.',
);
}
}
function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
const now = new Date().toISOString();
return {
...cfg,
meta: {
...cfg.meta,
lastTouchedVersion: VERSION,
lastTouchedAt: now,
},
};
}
function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console, "warn">): void {
const touched = cfg.meta?.lastTouchedVersion;
if (!touched) {
return;
}
const cmp = compareOpenClawVersions(VERSION, touched);
if (cmp === null) {
return;
}
if (cmp < 0) {
logger.warn(
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
);
}
}
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
if (deps.configPath) {
return deps.configPath;
}
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
}
function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
return {
fs: overrides.fs ?? fs,
json5: overrides.json5 ?? JSON5,
env: overrides.env ?? process.env,
homedir:
overrides.homedir ?? (() => resolveRequiredHomeDir(overrides.env ?? process.env, os.homedir)),
configPath: overrides.configPath ?? "",
logger: overrides.logger ?? console,
};
}
function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
// Only hydrate dotenv for the real process env. Callers using injected env
// objects (tests/diagnostics) should stay isolated.
if (env !== process.env) {
return;
}
loadDotEnv({ quiet: true });
}
function normalizeExecSafeBinProfilesInConfig(cfg: OpenClawConfig): void {
const normalizeTrustedDirs = (entries?: readonly string[]) => {
if (!Array.isArray(entries)) {
return undefined;
}
const normalized = entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
};
const normalizeExec = (exec: unknown) => {
if (!exec || typeof exec !== "object" || Array.isArray(exec)) {
return;
}
const typedExec = exec as {
safeBinProfiles?: Record<string, unknown>;
safeBinTrustedDirs?: string[];
};
const normalized = normalizeSafeBinProfileFixtures(
typedExec.safeBinProfiles as Record<
string,
{
minPositional?: number;
maxPositional?: number;
allowedValueFlags?: readonly string[];
deniedFlags?: readonly string[];
}
>,
);
typedExec.safeBinProfiles = Object.keys(normalized).length > 0 ? normalized : undefined;
typedExec.safeBinTrustedDirs = normalizeTrustedDirs(typedExec.safeBinTrustedDirs);
};
normalizeExec(cfg.tools?.exec);
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
for (const agent of agents) {
normalizeExec(agent?.tools?.exec);
}
}
export function parseConfigJson5(
raw: string,
json5: { parse: (value: string) => unknown } = JSON5,
): ParseConfigJson5Result {
try {
return { ok: true, parsed: json5.parse(raw) };
} catch (err) {
return { ok: false, error: String(err) };
}
}
type ConfigReadResolution = {
resolvedConfigRaw: unknown;
envSnapshotForRestore: Record<string, string | undefined>;
};
function resolveConfigIncludesForRead(
parsed: unknown,
configPath: string,
deps: Required<ConfigIoDeps>,
): unknown {
return resolveConfigIncludes(parsed, configPath, {
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
parseJson: (raw) => deps.json5.parse(raw),
});
}
function resolveConfigForRead(
resolvedIncludes: unknown,
env: NodeJS.ProcessEnv,
): ConfigReadResolution {
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars.
if (resolvedIncludes && typeof resolvedIncludes === "object" && "env" in resolvedIncludes) {
applyConfigEnvVars(resolvedIncludes as OpenClawConfig, env);
}
return {
resolvedConfigRaw: resolveConfigEnvVars(resolvedIncludes, env),
// Capture env snapshot after substitution for write-time ${VAR} restoration.
envSnapshotForRestore: { ...env } as Record<string, string | undefined>,
};
}
type ReadConfigFileSnapshotInternalResult = {
snapshot: ConfigFileSnapshot;
envSnapshotForRestore?: Record<string, string | undefined>;
};
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(): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
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);
const { resolvedConfigRaw: resolvedConfig } = resolveConfigForRead(
resolveConfigIncludesForRead(parsed, configPath, deps),
deps.env,
);
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
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 || "<root>"}: ${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 || "<root>"}: ${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);
normalizeExecSafeBinProfilesInConfig(cfg);
const duplicates = findDuplicateAgentDirs(cfg, {
env: deps.env,
homedir: deps.homedir,
});
if (duplicates.length > 0) {
throw new DuplicateAgentDirError(duplicates);
}
applyConfigEnvVars(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),
});
}
const pendingSecret = AUTO_OWNER_DISPLAY_SECRET_BY_PATH.get(configPath);
const ownerDisplaySecretResolution = ensureOwnerDisplaySecret(
cfg,
() => pendingSecret ?? crypto.randomBytes(32).toString("hex"),
);
const cfgWithOwnerDisplaySecret = ownerDisplaySecretResolution.config;
if (ownerDisplaySecretResolution.generatedSecret) {
AUTO_OWNER_DISPLAY_SECRET_BY_PATH.set(
configPath,
ownerDisplaySecretResolution.generatedSecret,
);
if (!AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.has(configPath)) {
AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.add(configPath);
void writeConfigFile(cfgWithOwnerDisplaySecret, { expectedConfigPath: configPath })
.then(() => {
AUTO_OWNER_DISPLAY_SECRET_BY_PATH.delete(configPath);
AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.delete(configPath);
})
.catch((err) => {
if (!AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.has(configPath)) {
AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.add(configPath);
deps.logger.warn(
`Failed to persist auto-generated commands.ownerDisplaySecret at ${configPath}: ${String(err)}`,
);
}
})
.finally(() => {
AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT.delete(configPath);
});
}
} else {
AUTO_OWNER_DISPLAY_SECRET_BY_PATH.delete(configPath);
AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED.delete(configPath);
}
return applyConfigOverrides(cfgWithOwnerDisplaySecret);
} 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 readConfigFileSnapshotInternal(): Promise<ReadConfigFileSnapshotInternalResult> {
maybeLoadDotEnvForConfig(deps.env);
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 {
snapshot: {
path: configPath,
exists: false,
raw: null,
parsed: {},
resolved: {},
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 {
snapshot: {
path: configPath,
exists: true,
raw,
parsed: {},
resolved: {},
valid: false,
config: {},
hash,
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
warnings: [],
legacyIssues: [],
},
};
}
// Resolve $include directives
let resolved: unknown;
try {
resolved = resolveConfigIncludesForRead(parsedRes.parsed, configPath, deps);
} catch (err) {
const message =
err instanceof ConfigIncludeError
? err.message
: `Include resolution failed: ${String(err)}`;
return {
snapshot: {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
resolved: coerceConfig(parsedRes.parsed),
valid: false,
config: coerceConfig(parsedRes.parsed),
hash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
},
};
}
let readResolution: ConfigReadResolution;
try {
readResolution = resolveConfigForRead(resolved, deps.env);
} catch (err) {
const message =
err instanceof MissingEnvVarError
? err.message
: `Env var substitution failed: ${String(err)}`;
return {
snapshot: {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
resolved: coerceConfig(resolved),
valid: false,
config: coerceConfig(resolved),
hash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
},
};
}
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
if (!validated.ok) {
return {
snapshot: {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
resolved: coerceConfig(resolvedConfigRaw),
valid: false,
config: coerceConfig(resolvedConfigRaw),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues,
},
};
}
warnIfConfigFromFuture(validated.config, deps.logger);
const snapshotConfig = normalizeConfigPaths(
applyTalkApiKey(
applyModelDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
),
);
normalizeExecSafeBinProfilesInConfig(snapshotConfig);
return {
snapshot: {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
// for config set/unset operations (issue #6070)
resolved: coerceConfig(resolvedConfigRaw),
valid: true,
config: snapshotConfig,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues,
},
envSnapshotForRestore: readResolution.envSnapshotForRestore,
};
} catch (err) {
return {
snapshot: {
path: configPath,
exists: true,
raw: null,
parsed: {},
resolved: {},
valid: false,
config: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }],
warnings: [],
legacyIssues: [],
},
};
}
}
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const result = await readConfigFileSnapshotInternal();
return result.snapshot;
}
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const result = await readConfigFileSnapshotInternal();
return {
snapshot: result.snapshot,
writeOptions: {
envSnapshotForRestore: result.envSnapshotForRestore,
expectedConfigPath: configPath,
},
};
}
async function writeConfigFile(cfg: OpenClawConfig, options: ConfigWriteOptions = {}) {
clearConfigCache();
let persistCandidate: unknown = cfg;
const { snapshot } = await readConfigFileSnapshotInternal();
let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {
const patch = createMergePatch(snapshot.config, cfg);
persistCandidate = applyMergePatch(snapshot.resolved, patch);
try {
const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, {
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
parseJson: (raw) => deps.json5.parse(raw),
});
const collected = new Map<string, string>();
collectEnvRefPaths(resolvedIncludes, "", collected);
if (collected.size > 0) {
envRefMap = collected;
changedPaths = new Set<string>();
collectChangedPaths(snapshot.config, cfg, "", changedPaths);
}
} catch {
envRefMap = null;
}
}
const validated = validateConfigObjectRawWithPlugins(persistCandidate);
if (!validated.ok) {
const issue = validated.issues[0];
const pathLabel = issue?.path ? issue.path : "<root>";
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}`);
}
// Restore ${VAR} env var references that were resolved during config loading.
// Read the current file (pre-substitution) and restore any references whose
// resolved values match the incoming config — so we don't overwrite
// "${ANTHROPIC_API_KEY}" with "sk-ant-..." when the caller didn't change it.
//
// We use only the root file's parsed content (no $include resolution) to avoid
// pulling values from included files into the root config on write-back.
// Apply env restoration to validated.config (which has runtime defaults stripped
// per issue #6070) rather than the raw caller input.
let cfgToWrite = validated.config;
try {
if (deps.fs.existsSync(configPath)) {
const currentRaw = await deps.fs.promises.readFile(configPath, "utf-8");
const parsedRes = parseConfigJson5(currentRaw, deps.json5);
if (parsedRes.ok) {
// Use env snapshot from when config was loaded (if available) to avoid
// TOCTOU issues where env changes between load and write. Falls back to
// live env if no snapshot exists (e.g., first write before any load).
const envForRestore = options.envSnapshotForRestore ?? deps.env;
cfgToWrite = restoreEnvVarRefs(
cfgToWrite,
parsedRes.parsed,
envForRestore,
) as OpenClawConfig;
}
}
} catch {
// If reading the current file fails, write cfg as-is (no env restoration)
}
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const outputConfig =
envRefMap && changedPaths
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
: cfgToWrite;
if (options.unsetPaths?.length) {
for (const unsetPath of options.unsetPaths) {
if (!Array.isArray(unsetPath) || unsetPath.length === 0) {
continue;
}
unsetPathForWrite(outputConfig as Record<string, unknown>, unsetPath);
}
}
// Do NOT apply runtime defaults when writing — user config should only contain
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
const stampedOutputConfig = stampConfigVersion(outputConfig);
const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
const nextHash = hashConfigRaw(json);
const previousHash = resolveConfigSnapshotHash(snapshot);
const changedPathCount = changedPaths?.size;
const previousBytes =
typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
const nextBytes = Buffer.byteLength(json, "utf-8");
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig);
const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
existsBefore: snapshot.exists,
previousBytes,
nextBytes,
hasMetaBefore,
gatewayModeBefore,
gatewayModeAfter,
});
const logConfigOverwrite = () => {
if (!snapshot.exists) {
return;
}
const isVitest = deps.env.VITEST === "true";
const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_OVERWRITE_LOG === "1";
if (isVitest && !shouldLogInVitest) {
return;
}
const changeSummary =
typeof changedPathCount === "number" ? `, changedPaths=${changedPathCount}` : "";
deps.logger.warn(
`Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`,
);
};
const logConfigWriteAnomalies = () => {
if (suspiciousReasons.length === 0) {
return;
}
// Tests often write minimal configs (missing meta, etc); keep output quiet unless requested.
const isVitest = deps.env.VITEST === "true";
const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1";
if (isVitest && !shouldLogInVitest) {
return;
}
deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
};
const auditRecordBase = {
ts: new Date().toISOString(),
source: "config-io" as const,
event: "config.write" as const,
configPath,
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
watchMode: deps.env.OPENCLAW_WATCH_MODE === "1",
watchSession:
typeof deps.env.OPENCLAW_WATCH_SESSION === "string" &&
deps.env.OPENCLAW_WATCH_SESSION.trim().length > 0
? deps.env.OPENCLAW_WATCH_SESSION.trim()
: null,
watchCommand:
typeof deps.env.OPENCLAW_WATCH_COMMAND === "string" &&
deps.env.OPENCLAW_WATCH_COMMAND.trim().length > 0
? deps.env.OPENCLAW_WATCH_COMMAND.trim()
: null,
existsBefore: snapshot.exists,
previousHash: previousHash ?? null,
nextHash,
previousBytes,
nextBytes,
changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
hasMetaBefore,
hasMetaAfter,
gatewayModeBefore,
gatewayModeAfter,
suspicious: suspiciousReasons,
};
const appendWriteAudit = async (result: ConfigWriteAuditResult, err?: unknown) => {
const errorCode =
err && typeof err === "object" && "code" in err && typeof err.code === "string"
? err.code
: undefined;
const errorMessage =
err && typeof err === "object" && "message" in err && typeof err.message === "string"
? err.message
: undefined;
await appendConfigWriteAuditRecord(deps, {
...auditRecordBase,
result,
nextHash: result === "failed" ? null : auditRecordBase.nextHash,
nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
errorCode,
errorMessage,
});
};
const tmp = path.join(
dir,
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
try {
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
});
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("copy-fallback");
return;
}
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
});
throw err;
}
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("rename");
} catch (err) {
await appendWriteAudit("failed", err);
throw err;
}
}
return {
configPath,
loadConfig,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
writeConfigFile,
};
}
// NOTE: These wrappers intentionally do *not* cache the resolved config path at
// module scope. `OPENCLAW_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;
const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map<string, string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set<string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set<string>();
let configCache: {
configPath: string;
expiresAt: number;
config: OpenClawConfig;
} | null = null;
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_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.OPENCLAW_DISABLE_CONFIG_CACHE?.trim()) {
return false;
}
return resolveConfigCacheMs(env) > 0;
}
export function clearConfigCache(): void {
configCache = null;
}
export function loadConfig(): OpenClawConfig {
const io = createConfigIO();
const configPath = io.configPath;
const now = Date.now();
if (shouldUseConfigCache(process.env)) {
const cached = configCache;
if (cached && cached.configPath === configPath && cached.expiresAt > now) {
return cached.config;
}
}
const config = io.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<ConfigFileSnapshot> {
return await createConfigIO().readConfigFileSnapshot();
}
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
return await createConfigIO().readConfigFileSnapshotForWrite();
}
export async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<void> {
const io = createConfigIO();
const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
await io.writeConfigFile(cfg, {
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
unsetPaths: options.unsetPaths,
});
}