Files
openclaw/src/config/io.write.ts
T
Peter Steinberger b4d14d7848 refactor(config): move model normalization to input owners (#115965)
* refactor(config): move model normalization to input owners

* fix(config): merge canonical doctor model rows

* fix(config): align touched model map identities

* ci(env): lower production variable budget

* chore: leave changelog to release automation
2026-07-29 12:04:07 -04:00

553 lines
20 KiB
TypeScript

import type fs from "node:fs";
import path from "node:path";
import { isVerbose } from "../global-state.js";
import { isVitestRuntimeEnv } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { replaceFileAtomic } from "../infra/replace-file.js";
import { maintainConfigBackups } from "./backup-rotation.js";
import { collectChangedPaths } from "./config-change-paths.js";
import {
configSnapshotAuditRecordMatchesPath,
fingerprintConfigSnapshotAuthoredConfig,
readLatestConfigSnapshotAuditRecord,
restoreConfigSnapshotAuditRecord,
upsertConfigSnapshotAuditRecord,
} from "./config-journal-snapshot.js";
import {
applyUnsetPathsForWrite,
resolveManagedUnsetPathsForWrite,
} from "./config-path-mutation.js";
import {
EnvRefArrayMutationError,
restoreEnvRefsFromMap,
restoreEnvVarRefs,
} from "./env-preserve.js";
import { INCLUDE_KEY, readConfigIncludeFileWithGuards, resolveConfigIncludes } from "./includes.js";
import {
appendConfigAuditRecord,
capConfigAuditIssues,
capConfigAuditPaths,
createConfigWriteAuditRecordBase,
finalizeConfigWriteAuditRecord,
formatConfigOverwriteLogMessage,
type ConfigWriteAuditResult,
} from "./io.audit.js";
import type { ConfigIoContext } from "./io.context.js";
import { recordConfigWriteMetadata } from "./io.meta.js";
import {
collectEnvRefPaths,
containsConfigIncludeDirective,
hashConfigRaw,
hasConfigMeta,
parseConfigJson5,
resolveConfigSnapshotHash,
resolveGatewayMode,
restoreAuthoredTildePathsForWrite,
} from "./io.read-helpers.js";
import { loggedConfigWarningFingerprints, setBoundedConfigIoWarningEntry } from "./io.state.js";
import type {
ConfigWriteOptions,
InternalConfigWriteResult,
ReadConfigFileSnapshotInternalResult,
} from "./io.types.js";
import { ConfigRuntimeRefreshError, configWritePostCommitRollback } from "./io.types.js";
import { logConfigWarningsOnce } from "./io.warnings.js";
import { formatConfigValidationFailure } from "./io.write-errors.js";
import {
preserveIncludeOwnedConfigForWrite,
resolvePersistCandidateForWrite,
} from "./io.write-prepare.js";
import {
assertBaseSnapshotStillCurrent,
formatConfigArtifactTimestamp,
resolveConfigSizeBaselineBytes,
resolveConfigStatMetadata,
resolveConfigWriteBlockingReasons,
resolveConfigWriteSuspiciousReasons,
rollbackConfigFileWriteIfUnchanged,
stampConfigVersion,
tightenStateDirPermissionsIfNeeded,
} from "./io.write-safety.js";
import { formatConfigIssueLines } from "./issue-format.js";
import { warnIfJSON5CommentsWillBeStripped } from "./json5-comments.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import { resolveIncludeRoots } from "./paths.js";
import { preflightRuntimeSnapshotWrite } from "./runtime-snapshot.js";
import type { OpenClawConfig } from "./types.js";
import { validateConfigObjectRawWithPlugins } from "./validation.js";
function hasOwnIncludeDirective(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && Object.hasOwn(value, INCLUDE_KEY);
}
function hasIncludedGatewayModeOwner(value: unknown): boolean {
if (hasOwnIncludeDirective(value)) {
return true;
}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const gateway = (value as Record<string, unknown>).gateway;
if (hasOwnIncludeDirective(gateway)) {
return true;
}
if (gateway === null || typeof gateway !== "object" || Array.isArray(gateway)) {
return false;
}
return hasOwnIncludeDirective((gateway as Record<string, unknown>).mode);
}
export async function writeConfigFileFromContext(
context: ConfigIoContext,
cfg: OpenClawConfig,
options: ConfigWriteOptions,
readSnapshot: () => Promise<ReadConfigFileSnapshotInternalResult>,
): Promise<InternalConfigWriteResult> {
const { deps, configPath } = context;
options.assertConfigPathForWrite?.();
assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env });
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
let persistCandidate: unknown = cfg;
const snapshotRead = options.baseSnapshot
? {
snapshot: options.baseSnapshot,
pluginMetadataSnapshot: options.basePluginMetadataSnapshot,
}
: await readSnapshot();
const snapshot = snapshotRead.snapshot;
if (options.baseSnapshot) {
assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs);
}
let envRefMap: Map<string, string> | null = null;
const changedPaths = new Set<string>();
collectChangedPaths(snapshot.config, cfg, "", changedPaths);
for (const changedPath of [...(options.explicitSetPaths ?? []), ...(options.unsetPaths ?? [])]) {
const normalizedPath = changedPath.filter((segment) => segment.length > 0).join(".");
if (normalizedPath) {
changedPaths.add(normalizedPath);
}
}
const identityRestoredPaths = new Set<string>();
const hasAuthoredIncludes = containsConfigIncludeDirective(snapshot.parsed);
const hasResolvedAuthoredIncludes =
hasAuthoredIncludes && !containsConfigIncludeDirective(snapshot.sourceConfig);
// Missing snapshots still need runtime-to-authored projection. Callers authoring an
// exact bootstrap roster mark that intent through explicitSetPaths.
if (snapshot.valid) {
persistCandidate = resolvePersistCandidateForWrite({
runtimeConfig: snapshot.config,
sourceConfig: snapshot.resolved,
sourceConfigBeforeMigrations: snapshot.sourceConfigBeforeMigrations,
nextConfig: cfg,
rootAuthoredConfig: snapshot.parsed,
agentRosterIncludeOwned: snapshot.agentRosterIncludeOwned,
unsetPaths,
explicitSetPaths: options.explicitSetPaths,
explicitSetValueSource: options.explicitSetValueSource,
allowedAgentRosterRemovals: options.allowedAgentRosterRemovals,
allowIncludeAncestorExplicitSetPaths: options.allowIncludeAncestorExplicitSetPaths,
});
} else if (snapshot.exists && hasAuthoredIncludes) {
persistCandidate = preserveIncludeOwnedConfigForWrite({
runtimeConfig: snapshot.config,
sourceConfig: snapshot.resolved,
nextConfig: cfg,
rootAuthoredConfig: snapshot.parsed,
});
}
if (snapshot.exists && (snapshot.valid || hasResolvedAuthoredIncludes)) {
try {
const resolvedIncludes = resolveConfigIncludes(
snapshot.parsed,
configPath,
{
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) =>
readConfigIncludeFileWithGuards({
includePath,
resolvedPath,
rootRealDir,
ioFs: deps.fs,
}),
parseJson: (raw) => deps.json5.parse(raw),
},
{ allowedRoots: resolveIncludeRoots(deps.env, deps.homedir) },
);
const collected = new Map<string, string>();
collectEnvRefPaths(resolvedIncludes, "", collected);
if (collected.size > 0) {
envRefMap = collected;
}
} catch {
envRefMap = null;
}
}
persistCandidate = applyUnsetPathsForWrite(persistCandidate as OpenClawConfig, unsetPaths);
const envForRestore = options.envSnapshotForRestore ?? deps.env;
const validationSourceCandidate = containsConfigIncludeDirective(persistCandidate)
? restoreEnvVarRefs(persistCandidate, snapshot.parsed, envForRestore)
: persistCandidate;
const validationCandidate = containsConfigIncludeDirective(validationSourceCandidate)
? context.resolveRuntimePreflightSourceConfig(validationSourceCandidate as OpenClawConfig)
: validationSourceCandidate;
const validated = validateConfigObjectRawWithPlugins(validationCandidate, {
env: deps.env,
pluginValidation: options.skipPluginValidation ? "skip" : "full",
preservedLegacyRootKeys: options.preservedLegacyRootKeys,
});
if (!validated.ok) {
const issue = validated.issues[0];
throw new Error(
formatConfigValidationFailure(issue?.path || "<root>", issue?.message ?? "invalid"),
);
}
const previousWarningFingerprint = loggedConfigWarningFingerprints.get(configPath);
// Capture before commit so rollback cannot restore a watcher-updated slot.
const priorSnapshotAuditRecord = readLatestConfigSnapshotAuditRecord({
env: deps.env,
homedir: deps.homedir,
});
let cfgToWrite = persistCandidate as OpenClawConfig;
try {
if (deps.fs.existsSync(configPath)) {
const currentRaw = await deps.fs.promises.readFile(configPath, "utf-8");
const parsed = parseConfigJson5(currentRaw, deps.json5);
if (parsed.ok) {
const beforeIdentityRestore = cfgToWrite;
cfgToWrite = restoreEnvVarRefs(cfgToWrite, parsed.parsed, envForRestore) as OpenClawConfig;
collectChangedPaths(beforeIdentityRestore, cfgToWrite, "", identityRestoredPaths);
}
}
} catch (error) {
if (error instanceof EnvRefArrayMutationError) {
throw error;
}
// A failed current-file reread leaves the already validated candidate unchanged.
}
await deps.fs.promises.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
await tightenStateDirPermissionsIfNeeded({
configPath,
env: deps.env,
homedir: deps.homedir,
fsModule: deps.fs,
});
const outputConfigBase = envRefMap
? (restoreEnvRefsFromMap(
cfgToWrite,
"",
envRefMap,
changedPaths,
identityRestoredPaths,
) as OpenClawConfig)
: cfgToWrite;
const tildeRestoredOutputConfig = restoreAuthoredTildePathsForWrite(
outputConfigBase,
snapshot.parsed,
undefined,
deps.homedir(),
) as OpenClawConfig;
const outputConfig = applyUnsetPathsForWrite(tildeRestoredOutputConfig, unsetPaths);
const stampedOutputConfig = stampConfigVersion(
outputConfig,
options.lastTouchedVersionOverride,
snapshot.exists ? snapshot.parsed : null,
);
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 sizeBaselineBytes = resolveConfigSizeBaselineBytes({
raw: snapshot.raw,
json5: deps.json5,
lastTouchedVersionOverride: options.lastTouchedVersionOverride,
});
const nextBytes = Buffer.byteLength(json, "utf-8");
const previousStat = snapshot.exists
? await deps.fs.promises.stat(configPath).catch(() => null)
: null;
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
const authoredGateway = (snapshot.parsed as { gateway?: unknown }).gateway;
const authoredGatewayMode =
authoredGateway !== null &&
typeof authoredGateway === "object" &&
!Array.isArray(authoredGateway)
? (authoredGateway as Record<string, unknown>).mode
: undefined;
const gatewayModeAuthoredLocally =
authoredGateway !== null &&
typeof authoredGateway === "object" &&
!Array.isArray(authoredGateway) &&
Object.hasOwn(authoredGateway, "mode") &&
!hasOwnIncludeDirective(authoredGatewayMode);
const preservesIncludedGatewayMode =
options.allowIncludeAncestorExplicitSetPaths === true &&
gatewayModeBefore != null &&
!gatewayModeAuthoredLocally &&
hasIncludedGatewayModeOwner(stampedOutputConfig) &&
!options.explicitSetPaths?.some((explicitPath) => explicitPath[0] === "gateway");
const gatewayModeAfter =
resolveGatewayMode(stampedOutputConfig) ??
(preservesIncludedGatewayMode ? gatewayModeBefore : null) ??
null;
const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
existsBefore: snapshot.exists,
unreadableBefore: snapshot.readError != null,
sizeBaselineBytes,
nextBytes,
hasMetaBefore,
gatewayModeBefore,
gatewayModeAfter,
});
const readTestLogFlag = (name: string) => isVitestRuntimeEnv(deps.env) && deps.env[name] === "1";
const logConfigOverwrite = () => {
if (
!snapshot.exists ||
options.skipOutputLogs ||
(isVitestRuntimeEnv(deps.env) && !readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG"))
) {
return;
}
const testLog = readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG");
if (!isVerbose() && deps.env.OPENCLAW_CONFIG_OVERWRITE_LOG !== "1" && !testLog) {
return;
}
deps.logger.warn(
formatConfigOverwriteLogMessage({
configPath,
previousHash: previousHash ?? null,
nextHash,
changedPathCount,
}),
);
};
const logConfigWriteAnomalies = () => {
const testLog = readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG");
if (
suspiciousReasons.length === 0 ||
options.skipOutputLogs ||
(isVitestRuntimeEnv(deps.env) && !testLog)
) {
return;
}
const showMissingMeta =
isVerbose() || deps.env.OPENCLAW_CONFIG_WRITE_ANOMALY_LOG === "1" || testLog;
const visibleReasons = showMissingMeta
? suspiciousReasons
: suspiciousReasons.filter((reason) => reason !== "missing-meta-before-write");
if (visibleReasons.length > 0) {
deps.logger.warn(`Config write anomaly: ${configPath} (${visibleReasons.join(", ")})`);
}
};
const auditRecordBase = createConfigWriteAuditRecordBase({
configPath,
env: deps.env,
existsBefore: snapshot.exists,
previousHash: previousHash ?? null,
nextHash,
previousBytes,
nextBytes,
previousMetadata: resolveConfigStatMetadata(previousStat),
changedPathCount,
changedPaths: [...changedPaths],
origin: options.auditOrigin,
hasMetaBefore,
hasMetaAfter,
gatewayModeBefore,
gatewayModeAfter,
suspicious: suspiciousReasons,
});
const appendWriteAudit = async (
result: ConfigWriteAuditResult,
error?: unknown,
nextStat?: fs.Stats | null,
) => {
await appendConfigAuditRecord({
env: deps.env,
homedir: deps.homedir,
record: finalizeConfigWriteAuditRecord({
base: auditRecordBase,
result,
err: error,
nextMetadata: resolveConfigStatMetadata(nextStat ?? null),
}),
});
};
const blockingReasons = resolveConfigWriteBlockingReasons(suspiciousReasons, options);
if (blockingReasons.length > 0 && options.allowDestructiveWrite !== true) {
const rejectedPath = `${configPath}.rejected.${formatConfigArtifactTimestamp(new Date().toISOString())}`;
await deps.fs.promises
.writeFile(rejectedPath, json, { encoding: "utf-8", mode: 0o600, flag: "wx" })
.catch(() => {});
const message = `Config write rejected: ${configPath} (${blockingReasons.join(", ")}). Rejected payload saved to ${rejectedPath}.`;
const error = Object.assign(new Error(message), {
code: "CONFIG_WRITE_REJECTED",
rejectedPath,
reasons: blockingReasons,
});
deps.logger.warn(message);
await appendWriteAudit("rejected", error);
throw error;
}
const preCommitRuntimePreflight =
options.preCommitRuntimePreflight ??
(async (sourceConfig: OpenClawConfig) => {
await preflightRuntimeSnapshotWrite({
nextSourceConfig: sourceConfig,
refreshOptions: options.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new ConfigRuntimeRefreshError(
`Config write blocked before committing ${configPath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
});
const sourceConfigForPreflight = context.resolveRuntimePreflightSourceConfig(stampedOutputConfig);
await preCommitRuntimePreflight(sourceConfigForPreflight);
try {
const result = await replaceFileAtomic({
filePath: configPath,
content: json,
dirMode: 0o700,
mode: 0o600,
tempPrefix: path.basename(configPath),
copyFallbackOnPermissionError: true,
fileSystem: deps.fs,
beforeRename: async () => {
options.assertConfigPathForWrite?.();
if (options.baseSnapshot) {
assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs);
}
if (deps.fs.existsSync(configPath)) {
await maintainConfigBackups(configPath, deps.fs.promises);
}
if (options.baseSnapshot) {
assertBaseSnapshotStillCurrent(snapshot, configPath, deps.fs);
}
options.assertConfigPathForWrite?.();
// Warn only after final guards pass, with no later await before rename.
warnIfJSON5CommentsWillBeStripped({
raw: snapshot.raw,
filePath: configPath,
warn: (message) => deps.logger.warn(message),
skipOutputLogs: options.skipOutputLogs,
});
},
});
try {
options.assertConfigPathForWrite?.();
} catch (error) {
try {
await rollbackConfigFileWriteIfUnchanged({
configPath,
previousSnapshot: snapshot,
committedHash: nextHash,
fsModule: deps.fs,
});
} catch (rollbackError) {
throw new ConfigRuntimeRefreshError(
`${formatErrorMessage(error)} Rollback failed: ${formatErrorMessage(rollbackError)}`,
{ cause: error },
);
}
throw error;
}
try {
recordConfigWriteMetadata(new Date().toISOString(), options.lastTouchedVersionOverride);
} catch (error) {
deps.logger.warn(`Config metadata state update failed: ${formatErrorMessage(error)}`);
}
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit(
result.method,
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
if (
configSnapshotAuditRecordMatchesPath(priorSnapshotAuditRecord, configPath) &&
priorSnapshotAuditRecord.rawHash !== previousHash
) {
const offlineChangedPaths = new Set<string>();
collectChangedPaths(
priorSnapshotAuditRecord.fingerprintedAuthoredConfig,
fingerprintConfigSnapshotAuthoredConfig(snapshot.parsed, {
env: deps.env,
homedir: deps.homedir,
}),
"",
offlineChangedPaths,
);
await appendConfigAuditRecord({
env: deps.env,
homedir: deps.homedir,
record: {
ts: new Date().toISOString(),
source: "config-io",
event: "config.external",
detectedBy: "write",
configPath,
previousHash: priorSnapshotAuditRecord.rawHash,
nextHash: previousHash ?? null,
valid: snapshot.valid,
...(snapshot.valid
? offlineChangedPaths.size > 0
? { changedPaths: capConfigAuditPaths([...offlineChangedPaths]) }
: { opaqueChange: true }
: {
issues: capConfigAuditIssues(
formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }),
),
}),
},
});
}
const writtenSnapshotAuditRecord = upsertConfigSnapshotAuditRecord({
env: deps.env,
homedir: deps.homedir,
configPath,
rawHash: nextHash,
authoredConfig: stampedOutputConfig,
expectedSnapshot: priorSnapshotAuditRecord,
});
if (!options.skipPluginValidation) {
logConfigWarningsOnce({ configPath, warnings: validated.warnings, logger: deps.logger });
}
return {
persistedHash: nextHash,
persistedConfig: stampedOutputConfig,
[configWritePostCommitRollback]: () => {
restoreConfigSnapshotAuditRecord({
env: deps.env,
homedir: deps.homedir,
snapshot: priorSnapshotAuditRecord,
expectedSnapshot: writtenSnapshotAuditRecord,
});
if (previousWarningFingerprint === undefined) {
loggedConfigWarningFingerprints.delete(configPath);
} else {
setBoundedConfigIoWarningEntry(
loggedConfigWarningFingerprints,
configPath,
previousWarningFingerprint,
);
}
},
};
} catch (error) {
await appendWriteAudit("failed", error);
throw error;
}
}