Files
openclaw/src/config/mutate.ts
Dallin Romney c2fc7aa28a refactor: consolidate exact keyed async queues (#99691)
* refactor: consolidate keyed async queues

* chore: refresh merge-ref CI

* refactor: encapsulate keyed queue ownership
2026-07-04 16:45:07 -07:00

1129 lines
38 KiB
TypeScript

// Applies scoped config mutations while preserving IO and observer state.
import { AsyncLocalStorage } from "node:async_hooks";
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { formatErrorMessage } from "../infra/errors.js";
import { withFileLock } from "../infra/file-lock.js";
import { root as createFsRoot, type Root as FsSafeRoot } from "../infra/fs-safe.js";
import { isPathInside } from "../security/scan-paths.js";
import { isRecord } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { maintainConfigBackups } from "./backup-rotation.js";
import { restoreEnvVarRefs } from "./env-preserve.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
import {
ConfigIncludeError,
hashConfigIncludeRaw,
INCLUDE_KEY,
resolveConfigIncludeWritePath,
} from "./includes.js";
import { createInvalidConfigError, formatInvalidConfigDetails } from "./io.invalid-config.js";
import {
createConfigIO,
readConfigFileSnapshotForWrite,
restoreEnvChangesIfUnchanged,
resolveConfigSnapshotHash,
writeConfigFile,
type ConfigWriteOptions,
type ConfigWriteResult,
} from "./io.js";
import {
applyUnsetPathsForWrite,
resolveManagedUnsetPathsForWrite,
resolveWriteEnvSnapshotForPath,
} from "./io.write-prepare.js";
import { ConfigMutationConflictError } from "./mutation-conflict.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import { resolveConfigPath } from "./paths.js";
import {
createRuntimeConfigWriteNotification,
finalizeRuntimeSnapshotWrite,
getRuntimeConfigSnapshot,
getRuntimeConfigSnapshotRefreshHandler,
getRuntimeConfigSourceSnapshot,
notifyRuntimeConfigWriteListeners,
preflightRuntimeSnapshotWrite,
resolveConfigWriteAfterWrite,
resolveConfigWriteFollowUp,
type ConfigWriteAfterWrite,
type ConfigWriteFollowUp,
} from "./runtime-snapshot.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
/** Selects whether a mutation starts from runtime or source config shape. */
export type ConfigMutationBase = "runtime" | "source";
const CONFIG_MUTATION_LOCK_OPTIONS = {
retries: {
retries: 80,
factor: 1.2,
minTimeout: 25,
maxTimeout: 250,
randomize: true,
},
stale: 30_000,
} as const;
const DEFAULT_CONFIG_MUTATION_RETRY_ATTEMPTS = 5;
const activeConfigMutationLocks = new AsyncLocalStorage<Set<string>>();
const configMutationQueue = new KeyedAsyncQueue();
export { ConfigMutationConflictError } from "./mutation-conflict.js";
export type ConfigReplaceResult = {
path: string;
previousHash: string | null;
snapshot: ConfigFileSnapshot;
nextConfig: OpenClawConfig;
persistedHash: string | null;
afterWrite: ConfigWriteAfterWrite;
followUp: ConfigWriteFollowUp;
};
export type ConfigMutationIO = {
env?: NodeJS.ProcessEnv;
readConfigFileSnapshotForWrite: typeof readConfigFileSnapshotForWrite;
writeConfigFile: (
cfg: OpenClawConfig,
options?: ConfigWriteOptions,
) => Promise<ConfigWriteResult | void>;
};
export type ConfigMutationContext = {
snapshot: ConfigFileSnapshot;
previousHash: string | null;
attempt: number;
};
export type ConfigTransformResult<T> = {
nextConfig: OpenClawConfig;
result?: T;
};
export type ConfigMutationCommitParams = {
nextConfig: OpenClawConfig;
snapshot: ConfigFileSnapshot;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
afterWrite: ConfigWriteAfterWrite;
io?: ConfigMutationIO;
};
export type ConfigMutationCommitResult = {
config: OpenClawConfig;
persistedHash: string | null;
afterWrite?: ConfigWriteAfterWrite;
};
export type ConfigMutationCommit = (
params: ConfigMutationCommitParams,
) => Promise<ConfigMutationCommitResult>;
export type TransformConfigFileParams<T> = {
base?: ConfigMutationBase;
baseHash?: string;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
commit?: ConfigMutationCommit;
transform: (
currentConfig: OpenClawConfig,
context: ConfigMutationContext,
) => Promise<ConfigTransformResult<T>> | ConfigTransformResult<T>;
};
export type TransformConfigFileWithRetryParams<T> = TransformConfigFileParams<T> & {
maxAttempts?: number;
};
export type ConfigMutationResult<T> = ConfigReplaceResult & {
result: T | undefined;
attempts: number;
};
type ConfigMutationOwnership = {
initialized: boolean;
expectedConfigPath: string;
ownedConfigPathForWrite?: string;
assertConfigPathForWrite?: () => void;
};
function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null {
const currentHash = resolveConfigSnapshotHash(snapshot) ?? null;
if (expectedHash !== undefined && expectedHash !== currentHash) {
throw new ConfigMutationConflictError("config changed since last load", {
currentHash,
});
}
return currentHash;
}
function assertExpectedConfigPathMatches(
snapshot: ConfigFileSnapshot,
expectedConfigPath?: string,
): void {
if (expectedConfigPath !== undefined && expectedConfigPath !== snapshot.path) {
throw new ConfigMutationConflictError("config path changed since last load", {
currentHash: resolveConfigSnapshotHash(snapshot) ?? null,
retryable: false,
});
}
}
async function withConfigMutationLock<T>(
params: { io?: ConfigMutationIO; lockPath?: string },
fn: () => Promise<T>,
): Promise<T> {
if (params.io) {
return await fn();
}
const configPath = path.resolve(params.lockPath ?? resolveConfigPath());
const activeLocks = activeConfigMutationLocks.getStore();
if (activeLocks?.has(configPath)) {
return await fn();
}
assertConfigWriteAllowedInCurrentMode({ configPath });
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
const nextActiveLocks = new Set(activeLocks ?? []);
nextActiveLocks.add(configPath);
return await configMutationQueue.enqueue(configPath, () =>
activeConfigMutationLocks.run(
nextActiveLocks,
async () => await withFileLock(configPath, CONFIG_MUTATION_LOCK_OPTIONS, fn),
),
);
}
function markActiveConfigMutationPath(configPath: string): void {
activeConfigMutationLocks.getStore()?.add(path.resolve(configPath));
}
async function readConfigSnapshotForMutation(params: {
ownedConfigPathForWrite?: string;
io?: ConfigMutationIO;
writeOptions?: ConfigWriteOptions;
}): Promise<{
snapshot: ConfigFileSnapshot;
writeOptions: ConfigWriteOptions;
}> {
const options = params.writeOptions?.skipPluginValidation ? { skipPluginValidation: true } : {};
if (params.io) {
return await params.io.readConfigFileSnapshotForWrite(options);
}
if (params.ownedConfigPathForWrite) {
return await createConfigIO({
configPath: params.ownedConfigPathForWrite,
...(params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" as const } : {}),
}).readConfigFileSnapshotForWrite();
}
return await readConfigFileSnapshotForWrite(options);
}
function createConfigMutationOwnership(
prepared: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>,
writeOptions?: ConfigWriteOptions,
): ConfigMutationOwnership {
const mergedWriteOptions = {
...prepared.writeOptions,
...writeOptions,
};
return {
initialized: true,
expectedConfigPath: mergedWriteOptions.expectedConfigPath ?? prepared.snapshot.path,
ownedConfigPathForWrite: mergedWriteOptions.ownedConfigPathForWrite,
assertConfigPathForWrite: mergedWriteOptions.assertConfigPathForWrite,
};
}
async function withConfigMutationSnapshotLock<T>(
params: { writeOptions?: ConfigWriteOptions },
fn: (prepared: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>) => Promise<T>,
): Promise<T> {
let lockPath = path.resolve(params.writeOptions?.ownedConfigPathForWrite ?? resolveConfigPath());
for (let attempt = 0; attempt < 3; attempt += 1) {
const outcome = await withConfigMutationLock({ lockPath }, async () => {
const prepared = await readConfigSnapshotForMutation({
...(params.writeOptions?.ownedConfigPathForWrite
? { ownedConfigPathForWrite: params.writeOptions.ownedConfigPathForWrite }
: {}),
writeOptions: params.writeOptions,
});
const preparedPath = path.resolve(prepared.snapshot.path);
if (preparedPath !== lockPath) {
return { done: false as const, lockPath: preparedPath };
}
return { done: true as const, value: await fn(prepared) };
});
if (outcome.done) {
return outcome.value;
}
lockPath = outcome.lockPath;
}
throw new ConfigMutationConflictError("config path changed repeatedly while acquiring lock", {
currentHash: null,
retryable: false,
});
}
function getChangedTopLevelKeys(base: unknown, next: unknown): string[] {
if (!isRecord(base) || !isRecord(next)) {
return isDeepStrictEqual(base, next) ? [] : ["<root>"];
}
const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
return [...keys].filter((key) => !isDeepStrictEqual(base[key], next[key]));
}
function getSingleTopLevelIncludeTarget(params: {
snapshot: ConfigFileSnapshot;
key: string;
}): string | null {
if (!isRecord(params.snapshot.parsed)) {
return null;
}
const authoredSection = params.snapshot.parsed[params.key];
if (!isRecord(authoredSection)) {
return null;
}
const keys = Object.keys(authoredSection);
const includeValue = authoredSection[INCLUDE_KEY];
if (keys.length !== 1 || typeof includeValue !== "string") {
return null;
}
const rootDir = path.dirname(params.snapshot.path);
return path.normalize(
path.isAbsolute(includeValue) ? includeValue : path.resolve(rootDir, includeValue),
);
}
function containsConfigIncludeDirective(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some((item) => containsConfigIncludeDirective(item));
}
if (!isRecord(value)) {
return false;
}
return (
Object.hasOwn(value, INCLUDE_KEY) ||
Object.values(value).some((item) => containsConfigIncludeDirective(item))
);
}
function snapshotProvesBrokenInclude(snapshot: ConfigFileSnapshot, includePath: string): boolean {
return (
!snapshot.valid &&
snapshot.issues.some(
(issue) =>
/Failed to (?:read|parse) include file:/.test(issue.message) &&
issue.message.includes(includePath),
)
);
}
function formatJsonFileValue(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
type RootBoundIncludeFile = {
absolutePath: string;
relativePath: string;
root: FsSafeRoot;
};
function isMissingFileError(error: unknown): boolean {
const code = (error as { code?: unknown } | null)?.code;
return code === "ENOENT" || code === "not-found";
}
function resolveRootBoundRelativePath(target: RootBoundIncludeFile, absolutePath: string): string {
const relativePath = path.relative(target.root.rootReal, path.resolve(absolutePath));
const firstSegment = relativePath.split(path.sep)[0];
if (path.isAbsolute(relativePath) || firstSegment === "..") {
throw new Error(`Config include backup path escaped its approved root: ${absolutePath}`);
}
return relativePath;
}
async function resolveRootBoundIncludeFile(params: {
configPath: string;
includePath: string;
allowedRoots: readonly string[];
}): Promise<RootBoundIncludeFile> {
const absolutePath = resolveConfigIncludeWritePath(params);
const candidateRoots = [path.dirname(params.configPath), ...params.allowedRoots];
for (const candidateRoot of candidateRoots) {
const rootReal = await fs.realpath(candidateRoot).catch(() => null);
if (!rootReal || !isPathInside(rootReal, absolutePath)) {
continue;
}
const relativePath = path.relative(rootReal, absolutePath);
if (
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.split(path.sep)[0] === ".."
) {
continue;
}
return {
absolutePath,
relativePath,
root: await createFsRoot(rootReal, {
hardlinks: "reject",
mkdir: true,
mode: 0o600,
symlinks: "reject",
}),
};
}
throw new Error(`Config include write path has no approved existing root: ${absolutePath}`);
}
async function resolveExpectedRootBoundIncludeFile(params: {
configPath: string;
includePath: string;
allowedRoots: readonly string[];
expectedAbsolutePath: string;
}): Promise<RootBoundIncludeFile> {
let target: RootBoundIncludeFile;
try {
target = await resolveRootBoundIncludeFile(params);
} catch (error) {
if (
error instanceof ConfigIncludeError ||
(error instanceof Error &&
error.message.startsWith("Config include write path has no approved existing root:"))
) {
throw new ConfigMutationConflictError("included config target changed since last load", {
currentHash: null,
});
}
throw error;
}
if (path.normalize(target.absolutePath) !== path.normalize(params.expectedAbsolutePath)) {
throw new ConfigMutationConflictError("included config target changed since last load", {
currentHash: null,
});
}
return target;
}
async function readRootBoundFileRawIfExists(target: RootBoundIncludeFile): Promise<string | null> {
try {
return await target.root.readText(target.relativePath);
} catch (error) {
if (isMissingFileError(error)) {
return null;
}
throw error;
}
}
async function assertRootConfigStillMatchesSnapshot(snapshot: ConfigFileSnapshot): Promise<void> {
let currentRaw: string | null = null;
try {
currentRaw = await fs.readFile(snapshot.path, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw error;
}
}
const currentHash = hashConfigIncludeRaw(currentRaw);
const expectedHash = hashConfigIncludeRaw(snapshot.exists ? (snapshot.raw ?? null) : null);
if (currentHash !== expectedHash) {
throw new ConfigMutationConflictError("config changed while preparing include write", {
currentHash,
});
}
}
async function rollbackJsonFileWriteIfUnchanged(params: {
target: RootBoundIncludeFile;
previousRaw: string | null;
committedHash: string;
}): Promise<boolean> {
const currentRaw = await readRootBoundFileRawIfExists(params.target);
if (hashConfigIncludeRaw(currentRaw) !== params.committedHash) {
return false;
}
if (params.previousRaw !== null) {
await params.target.root.write(params.target.relativePath, params.previousRaw, {
mkdir: true,
mode: 0o600,
overwrite: true,
});
return true;
}
try {
await params.target.root.remove(params.target.relativePath);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
return true;
}
function createRootBoundBackupFs(target: RootBoundIncludeFile) {
return {
chmod: async (filePath: string, mode: number) => {
const opened = await target.root.open(resolveRootBoundRelativePath(target, filePath));
try {
await opened.handle.chmod(mode);
} finally {
await opened[Symbol.asyncDispose]();
}
},
copyFile: async (from: string, to: string) => {
const content = await target.root.readBytes(resolveRootBoundRelativePath(target, from));
await target.root.write(resolveRootBoundRelativePath(target, to), content, {
mkdir: true,
mode: 0o600,
overwrite: true,
});
},
readdir: async (dir: string) =>
await target.root.list(resolveRootBoundRelativePath(target, dir)),
rename: async (from: string, to: string) => {
await target.root.move(
resolveRootBoundRelativePath(target, from),
resolveRootBoundRelativePath(target, to),
{ overwrite: true },
);
},
unlink: async (filePath: string) => {
await target.root.remove(resolveRootBoundRelativePath(target, filePath));
},
};
}
async function writeRootBoundJsonFile(params: {
configPath: string;
includePath: string;
allowedRoots: readonly string[];
expectedTargetPath: string;
value: unknown;
expectedRaw: string | null;
rootSnapshot: ConfigFileSnapshot;
assertConfigPathForWrite: () => void;
}): Promise<void> {
params.assertConfigPathForWrite();
const targetBeforeBackup = await resolveExpectedRootBoundIncludeFile({
configPath: params.configPath,
includePath: params.includePath,
allowedRoots: params.allowedRoots,
expectedAbsolutePath: params.expectedTargetPath,
});
if (await targetBeforeBackup.root.exists(targetBeforeBackup.relativePath)) {
await maintainConfigBackups(
targetBeforeBackup.absolutePath,
createRootBoundBackupFs(targetBeforeBackup),
);
}
const targetAtCommit = await resolveExpectedRootBoundIncludeFile({
configPath: params.configPath,
includePath: params.includePath,
allowedRoots: params.allowedRoots,
expectedAbsolutePath: params.expectedTargetPath,
});
params.assertConfigPathForWrite();
await assertRootConfigStillMatchesSnapshot(params.rootSnapshot);
const currentRaw = await readRootBoundFileRawIfExists(targetAtCommit);
const currentHash = hashConfigIncludeRaw(currentRaw);
if (currentHash !== hashConfigIncludeRaw(params.expectedRaw)) {
throw new ConfigMutationConflictError("included config changed while preparing write", {
currentHash,
});
}
params.assertConfigPathForWrite();
const content = formatJsonFileValue(params.value);
await targetAtCommit.root.write(targetAtCommit.relativePath, content, {
mkdir: true,
mode: 0o600,
overwrite: true,
});
try {
params.assertConfigPathForWrite();
} catch (error) {
await rollbackJsonFileWriteIfUnchanged({
target: targetAtCommit,
previousRaw: currentRaw,
committedHash: hashConfigIncludeRaw(content),
});
throw error;
}
}
async function tryWriteSingleTopLevelIncludeMutation(params: {
snapshot: ConfigFileSnapshot;
nextConfig: OpenClawConfig;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
}): Promise<{ persistedHash: string | null; persistedConfig: OpenClawConfig } | null> {
const nextConfig = applyUnsetPathsForWrite(
params.nextConfig,
resolveManagedUnsetPathsForWrite(params.writeOptions?.unsetPaths),
);
const changedKeys = getChangedTopLevelKeys(params.snapshot.sourceConfig, nextConfig);
if (changedKeys.length !== 1 || changedKeys[0] === "<root>") {
return null;
}
const key = changedKeys[0];
const includePath = getSingleTopLevelIncludeTarget({ snapshot: params.snapshot, key });
if (!includePath || !isRecord(nextConfig) || !(key in nextConfig)) {
return null;
}
const nextConfigRecord = nextConfig as Record<string, unknown>;
const writeEnv = params.io?.env ?? process.env;
const allowedRoots: readonly string[] = [];
const expectedIncludeTarget = params.writeOptions?.includeFileTargetsForWrite?.[includePath];
if (!expectedIncludeTarget) {
throw new ConfigMutationConflictError("included config target changed since last load", {
currentHash: null,
});
}
const assertConfigPathForWrite = params.writeOptions?.assertConfigPathForWrite;
if (!assertConfigPathForWrite) {
return null;
}
assertConfigPathForWrite();
const configRoot = await fs.realpath(path.dirname(params.snapshot.path));
if (!isPathInside(configRoot, expectedIncludeTarget)) {
throw new Error(
`Config mutation cannot update external $include target ${includePath}; edit the included file directly or move it under the config directory.`,
);
}
const includeTarget = await resolveExpectedRootBoundIncludeFile({
configPath: params.snapshot.path,
includePath,
allowedRoots,
expectedAbsolutePath: expectedIncludeTarget,
});
const previousIncludeRaw = await readRootBoundFileRawIfExists(includeTarget);
const previousIncludeHash = hashConfigIncludeRaw(previousIncludeRaw);
const expectedIncludeHash = params.writeOptions?.includeFileHashesForWrite?.[includePath];
if (expectedIncludeHash !== undefined && expectedIncludeHash !== previousIncludeHash) {
throw new ConfigMutationConflictError("included config changed since last load", {
currentHash: previousIncludeHash,
});
}
const envForRestore =
resolveWriteEnvSnapshotForPath({
actualConfigPath: params.snapshot.path,
expectedConfigPath: params.writeOptions?.expectedConfigPath,
envSnapshotForRestore: params.writeOptions?.envSnapshotForRestore,
}) ??
params.io?.env ??
process.env;
const snapshotHasBrokenInclude = snapshotProvesBrokenInclude(params.snapshot, includePath);
if (
previousIncludeRaw === null &&
(!snapshotHasBrokenInclude || expectedIncludeHash === undefined)
) {
throw new ConfigMutationConflictError("included config changed since last load", {
currentHash: previousIncludeHash,
});
}
let includedValueToWrite = nextConfigRecord[key];
if (previousIncludeRaw !== null) {
let authoredIncludeValue: unknown;
let parsedInclude = false;
try {
authoredIncludeValue = parseJsonWithJson5Fallback(previousIncludeRaw);
parsedInclude = true;
} catch {
// A validated replacement is the repair path for a malformed include.
if (!snapshotHasBrokenInclude || expectedIncludeHash === undefined) {
throw new ConfigMutationConflictError("included config changed since last load", {
currentHash: previousIncludeHash,
});
}
}
if (parsedInclude) {
if (containsConfigIncludeDirective(authoredIncludeValue)) {
return null;
}
const currentIncludedValue = resolveConfigEnvVars(authoredIncludeValue, envForRestore, {
onMissing: () => {},
});
const snapshotIncludedValue = (params.snapshot.sourceConfig as Record<string, unknown>)[key];
if (!isDeepStrictEqual(currentIncludedValue, snapshotIncludedValue)) {
throw new ConfigMutationConflictError("included config changed since last load", {
currentHash: previousIncludeHash,
});
}
includedValueToWrite = restoreEnvVarRefs(
includedValueToWrite,
authoredIncludeValue,
envForRestore,
);
}
}
const runtimeConfigToWrite = {
...nextConfig,
[key]: resolveConfigEnvVars(includedValueToWrite, writeEnv, { onMissing: () => {} }),
} as OpenClawConfig;
const validated = validateConfigObjectWithPlugins(
runtimeConfigToWrite,
params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" } : undefined,
);
if (!validated.ok) {
throw createInvalidConfigError(
params.snapshot.path,
formatInvalidConfigDetails(validated.issues),
);
}
const runtimeConfigSnapshot = getRuntimeConfigSnapshot();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshot();
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
const runtimePreflightResult = await preflightRuntimeSnapshotWrite({
nextSourceConfig: runtimeConfigToWrite,
refreshOptions: params.writeOptions?.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
`Config write blocked before committing ${includePath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
const committedIncludeRaw = formatJsonFileValue(includedValueToWrite);
const committedIncludeHash = hashConfigIncludeRaw(committedIncludeRaw);
assertConfigPathForWrite();
await assertRootConfigStillMatchesSnapshot(params.snapshot);
const includeRawAtCommit = await readRootBoundFileRawIfExists(includeTarget);
if (hashConfigIncludeRaw(includeRawAtCommit) !== hashConfigIncludeRaw(previousIncludeRaw)) {
throw new ConfigMutationConflictError("included config changed while preparing write", {
currentHash: hashConfigIncludeRaw(includeRawAtCommit),
});
}
await writeRootBoundJsonFile({
configPath: params.snapshot.path,
includePath,
allowedRoots,
expectedTargetPath: expectedIncludeTarget,
value: includedValueToWrite,
expectedRaw: includeRawAtCommit,
rootSnapshot: params.snapshot,
assertConfigPathForWrite,
});
const envBeforePostWriteRead = { ...writeEnv };
let envAfterPostWriteRead = envBeforePostWriteRead;
try {
if (
params.writeOptions?.skipRuntimeSnapshotRefresh &&
!hadRuntimeSnapshot &&
!getRuntimeConfigSnapshotRefreshHandler()
) {
return { persistedHash: null, persistedConfig: runtimeConfigToWrite };
}
let refreshed: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>;
try {
refreshed = await readConfigSnapshotForMutation({
ownedConfigPathForWrite: params.snapshot.path,
io: params.io,
writeOptions: params.writeOptions,
});
} finally {
envAfterPostWriteRead = { ...writeEnv };
}
const refreshedSnapshot = refreshed.snapshot;
assertConfigPathForWrite();
assertExpectedConfigPathMatches(refreshedSnapshot, params.snapshot.path);
const persistedHash = resolveConfigSnapshotHash(refreshedSnapshot);
if (!refreshedSnapshot.valid) {
throw createInvalidConfigError(
params.snapshot.path,
formatInvalidConfigDetails(refreshedSnapshot.issues),
);
}
if (!persistedHash) {
throw new Error(
`Config was written to ${params.snapshot.path}, but no persisted hash was available.`,
);
}
const notifyCommittedWrite = () => {
const currentRuntimeConfig = getRuntimeConfigSnapshot();
if (!currentRuntimeConfig) {
return;
}
notifyRuntimeConfigWriteListeners(
createRuntimeConfigWriteNotification({
configPath: params.snapshot.path,
sourceConfig: refreshedSnapshot.sourceConfig,
runtimeConfig: currentRuntimeConfig,
persistedHash,
afterWrite: params.afterWrite ?? params.writeOptions?.afterWrite,
}),
);
};
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: refreshedSnapshot.sourceConfig,
refreshOptions: params.writeOptions?.runtimeRefresh,
hadRuntimeSnapshot,
hadBothSnapshots,
loadFreshConfig: () => refreshedSnapshot.runtimeConfig,
notifyCommittedWrite,
preflightResult: runtimePreflightResult,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
`Config was written to ${params.snapshot.path}, but runtime snapshot refresh failed: ${detail}`,
{ cause },
),
});
return { persistedHash, persistedConfig: refreshedSnapshot.sourceConfig };
} catch (error) {
try {
const rolledBack = await rollbackJsonFileWriteIfUnchanged({
target: includeTarget,
previousRaw: includeRawAtCommit,
committedHash: committedIncludeHash,
});
if (rolledBack) {
restoreEnvChangesIfUnchanged({
env: writeEnv,
before: envBeforePostWriteRead,
after: envAfterPostWriteRead,
});
}
} catch (rollbackError) {
throw new Error(
`${formatErrorMessage(error)} Rollback failed: ${formatErrorMessage(rollbackError)}`,
{ cause: rollbackError },
);
}
throw error;
}
}
function resolveConfigWriteResult(
result: ConfigWriteResult | void,
fallbackConfig: OpenClawConfig,
): { persistedHash: string | null; persistedConfig: OpenClawConfig } {
if (result) {
return {
persistedHash: result.persistedHash,
persistedConfig: result.persistedConfig,
};
}
return { persistedHash: null, persistedConfig: fallbackConfig };
}
export async function replaceConfigFile(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
snapshot?: ConfigFileSnapshot;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
}): Promise<ConfigReplaceResult> {
if (!params.snapshot && !params.io) {
return await withConfigMutationSnapshotLock(
{ writeOptions: params.writeOptions },
async (prepared) =>
await replaceConfigFileUnlocked({
...params,
snapshot: prepared.snapshot,
writeOptions: {
...prepared.writeOptions,
...params.writeOptions,
},
}),
);
}
return await withConfigMutationLock(
{ io: params.io, lockPath: params.snapshot?.path },
async () => await replaceConfigFileUnlocked(params),
);
}
async function replaceConfigFileUnlocked(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
snapshot?: ConfigFileSnapshot;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
}): Promise<ConfigReplaceResult> {
const prepared = params.snapshot
? { snapshot: params.snapshot, writeOptions: params.writeOptions ?? {} }
: await readConfigSnapshotForMutation({
io: params.io,
writeOptions: params.writeOptions,
});
const { snapshot, writeOptions } = prepared;
const mergedWriteOptions = {
...writeOptions,
...params.writeOptions,
};
mergedWriteOptions.assertConfigPathForWrite?.();
assertExpectedConfigPathMatches(snapshot, mergedWriteOptions.expectedConfigPath);
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
markActiveConfigMutationPath(snapshot.path);
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const afterWrite = resolveConfigWriteAfterWrite(
params.afterWrite ?? params.writeOptions?.afterWrite,
);
let writeResult = await tryWriteSingleTopLevelIncludeMutation({
snapshot,
nextConfig: params.nextConfig,
afterWrite,
writeOptions: mergedWriteOptions,
io: params.io,
});
if (!writeResult) {
const fallbackWriteOptions: ConfigWriteOptions = {
baseSnapshot: snapshot,
...mergedWriteOptions,
afterWrite,
};
const ioPreCommitRuntimePreflight = params.io
? fallbackWriteOptions.preCommitRuntimePreflight
: undefined;
if (params.io) {
fallbackWriteOptions.preCommitRuntimePreflight = async (sourceConfig) => {
await ioPreCommitRuntimePreflight?.(sourceConfig);
await preflightRuntimeSnapshotWrite({
nextSourceConfig: sourceConfig,
refreshOptions: fallbackWriteOptions.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
`Config write blocked before committing ${snapshot.path}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
};
}
writeResult = resolveConfigWriteResult(
await (params.io?.writeConfigFile ?? writeConfigFile)(
params.nextConfig,
fallbackWriteOptions,
),
params.nextConfig,
);
}
return {
path: snapshot.path,
previousHash,
snapshot,
nextConfig: writeResult.persistedConfig,
persistedHash: writeResult.persistedHash,
afterWrite,
followUp: resolveConfigWriteFollowUp(afterWrite),
};
}
async function commitPreparedConfigMutation(
params: ConfigMutationCommitParams,
): Promise<ConfigMutationCommitResult> {
const result = await replaceConfigFileUnlocked({
nextConfig: params.nextConfig,
snapshot: params.snapshot,
baseHash: params.baseHash,
writeOptions: {
...params.writeOptions,
afterWrite: params.afterWrite,
},
io: params.io,
});
return {
config: result.nextConfig,
persistedHash: result.persistedHash,
afterWrite: result.afterWrite,
};
}
async function transformConfigFileAttempt<T>(
params: TransformConfigFileParams<T>,
attempt: number,
ownership?: ConfigMutationOwnership,
prepared?: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>,
): Promise<ConfigMutationResult<T>> {
ownership?.assertConfigPathForWrite?.();
const { snapshot, writeOptions } =
prepared ??
(await readConfigSnapshotForMutation({
...(ownership?.ownedConfigPathForWrite
? { ownedConfigPathForWrite: ownership.ownedConfigPathForWrite }
: {}),
io: params.io,
writeOptions: params.writeOptions,
}));
let mergedWriteOptions: ConfigWriteOptions = {
...writeOptions,
...params.writeOptions,
};
if (ownership) {
if (!ownership.initialized) {
ownership.initialized = true;
ownership.expectedConfigPath = mergedWriteOptions.expectedConfigPath ?? snapshot.path;
ownership.ownedConfigPathForWrite = mergedWriteOptions.ownedConfigPathForWrite;
ownership.assertConfigPathForWrite = mergedWriteOptions.assertConfigPathForWrite;
}
mergedWriteOptions = {
...mergedWriteOptions,
expectedConfigPath: ownership.expectedConfigPath,
...(ownership.ownedConfigPathForWrite
? { ownedConfigPathForWrite: ownership.ownedConfigPathForWrite }
: {}),
...(ownership.assertConfigPathForWrite
? { assertConfigPathForWrite: ownership.assertConfigPathForWrite }
: {}),
};
}
mergedWriteOptions.assertConfigPathForWrite?.();
assertExpectedConfigPathMatches(snapshot, mergedWriteOptions.expectedConfigPath);
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
markActiveConfigMutationPath(snapshot.path);
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig;
const afterWrite = resolveConfigWriteAfterWrite(
params.afterWrite ?? params.writeOptions?.afterWrite,
);
const transformed = await params.transform(baseConfig, { snapshot, previousHash, attempt });
const committed = await (params.commit ?? commitPreparedConfigMutation)({
nextConfig: transformed.nextConfig,
snapshot,
...(previousHash !== null ? { baseHash: previousHash } : {}),
writeOptions: mergedWriteOptions,
afterWrite,
io: params.io,
});
const committedAfterWrite = committed.afterWrite ?? afterWrite;
return {
path: snapshot.path,
previousHash,
snapshot,
nextConfig: committed.config,
persistedHash: committed.persistedHash,
result: transformed.result,
attempts: attempt + 1,
afterWrite: committedAfterWrite,
followUp: resolveConfigWriteFollowUp(committedAfterWrite),
};
}
export async function transformConfigFile<T = void>(
params: TransformConfigFileParams<T>,
): Promise<ConfigMutationResult<T>> {
if (!params.io) {
return await withConfigMutationSnapshotLock(
{ writeOptions: params.writeOptions },
async (prepared) =>
await transformConfigFileAttempt(
params,
0,
createConfigMutationOwnership(prepared, params.writeOptions),
prepared,
),
);
}
return await withConfigMutationLock(
{ io: params.io },
async () => await transformConfigFileAttempt(params, 0),
);
}
export async function transformConfigFileWithRetry<T = void>(
params: TransformConfigFileWithRetryParams<T>,
): Promise<ConfigMutationResult<T>> {
const maxAttempts = params.maxAttempts ?? DEFAULT_CONFIG_MUTATION_RETRY_ATTEMPTS;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new Error("Config mutation maxAttempts must be a positive integer.");
}
const runWithPrepared = async (
prepared?: Awaited<ReturnType<typeof readConfigSnapshotForMutation>>,
) => {
const ownership = prepared
? createConfigMutationOwnership(prepared, params.writeOptions)
: {
initialized: false,
expectedConfigPath: "",
};
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await transformConfigFileAttempt(
params,
attempt,
ownership,
attempt === 0 ? prepared : undefined,
);
} catch (err) {
if (
err instanceof ConfigMutationConflictError &&
err.retryable &&
attempt < maxAttempts - 1
) {
continue;
}
throw err;
}
}
throw new Error("Config mutation retry loop exhausted unexpectedly.");
};
if (!params.io) {
return await withConfigMutationSnapshotLock(
{ writeOptions: params.writeOptions },
runWithPrepared,
);
}
return await withConfigMutationLock({ io: params.io }, async () => await runWithPrepared());
}
export async function mutateConfigFile<T = void>(params: {
base?: ConfigMutationBase;
baseHash?: string;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
mutate: (draft: OpenClawConfig, context: ConfigMutationContext) => Promise<T | void> | T | void;
}): Promise<ConfigMutationResult<T>> {
return await transformConfigFile<T>({
base: params.base,
baseHash: params.baseHash,
afterWrite: params.afterWrite,
writeOptions: params.writeOptions,
io: params.io,
transform: async (currentConfig, context) => {
const draft = structuredClone(currentConfig);
const result = (await params.mutate(draft, context)) as T | undefined;
return { nextConfig: draft, result };
},
});
}
export async function mutateConfigFileWithRetry<T = void>(params: {
base?: ConfigMutationBase;
baseHash?: string;
maxAttempts?: number;
afterWrite?: ConfigWriteOptions["afterWrite"];
writeOptions?: ConfigWriteOptions;
io?: ConfigMutationIO;
mutate: (draft: OpenClawConfig, context: ConfigMutationContext) => Promise<T | void> | T | void;
}): Promise<ConfigMutationResult<T>> {
return await transformConfigFileWithRetry<T>({
base: params.base,
baseHash: params.baseHash,
maxAttempts: params.maxAttempts,
afterWrite: params.afterWrite,
writeOptions: params.writeOptions,
io: params.io,
transform: async (currentConfig, context) => {
const draft = structuredClone(currentConfig);
const result = (await params.mutate(draft, context)) as T | undefined;
return { nextConfig: draft, result };
},
});
}