
























@@ -1,10 +1,20 @@
1+import crypto from "node:crypto";
2+import fs from "node:fs/promises";
3+import path from "node:path";
4+import { isDeepStrictEqual } from "node:util";
5+import { isPathInside } from "../security/scan-paths.js";
6+import { isRecord } from "../utils.js";
7+import { maintainConfigBackups } from "./backup-rotation.js";
8+import { INCLUDE_KEY } from "./includes.js";
9+import { createInvalidConfigError, formatInvalidConfigDetails } from "./io.invalid-config.js";
110import {
211readConfigFileSnapshotForWrite,
312resolveConfigSnapshotHash,
413writeConfigFile,
514type ConfigWriteOptions,
615} from "./io.js";
716import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
17+import { validateConfigObjectWithPlugins } from "./validation.js";
818919export type ConfigMutationBase = "runtime" | "source";
1020@@ -35,6 +45,97 @@ function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: stri
3545return currentHash;
3646}
374748+function getChangedTopLevelKeys(base: unknown, next: unknown): string[] {
49+if (!isRecord(base) || !isRecord(next)) {
50+return isDeepStrictEqual(base, next) ? [] : ["<root>"];
51+}
52+const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
53+return [...keys].filter((key) => !isDeepStrictEqual(base[key], next[key]));
54+}
55+56+function getSingleTopLevelIncludeTarget(params: {
57+snapshot: ConfigFileSnapshot;
58+key: string;
59+}): string | null {
60+if (!isRecord(params.snapshot.parsed)) {
61+return null;
62+}
63+const authoredSection = params.snapshot.parsed[params.key];
64+if (!isRecord(authoredSection)) {
65+return null;
66+}
67+const keys = Object.keys(authoredSection);
68+const includeValue = authoredSection[INCLUDE_KEY];
69+if (keys.length !== 1 || typeof includeValue !== "string") {
70+return null;
71+}
72+73+const rootDir = path.dirname(params.snapshot.path);
74+const resolved = path.normalize(
75+path.isAbsolute(includeValue) ? includeValue : path.resolve(rootDir, includeValue),
76+);
77+if (!isPathInside(rootDir, resolved)) {
78+return null;
79+}
80+return resolved;
81+}
82+83+async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
84+const dir = path.dirname(filePath);
85+const tmp = path.join(
86+dir,
87+`${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
88+);
89+try {
90+await fs.mkdir(dir, { recursive: true });
91+await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
92+encoding: "utf-8",
93+mode: 0o600,
94+});
95+await fs.access(filePath).then(
96+async () => await maintainConfigBackups(filePath, fs),
97+() => undefined,
98+);
99+await fs.rename(tmp, filePath);
100+await fs.chmod(filePath, 0o600).catch(() => {
101+// best-effort
102+});
103+} catch (err) {
104+await fs.unlink(tmp).catch(() => {
105+// best-effort
106+});
107+throw err;
108+}
109+}
110+111+async function tryWriteSingleTopLevelIncludeMutation(params: {
112+snapshot: ConfigFileSnapshot;
113+nextConfig: OpenClawConfig;
114+}): Promise<boolean> {
115+const changedKeys = getChangedTopLevelKeys(params.snapshot.sourceConfig, params.nextConfig);
116+if (changedKeys.length !== 1 || changedKeys[0] === "<root>") {
117+return false;
118+}
119+120+const key = changedKeys[0];
121+const includePath = getSingleTopLevelIncludeTarget({ snapshot: params.snapshot, key });
122+if (!includePath || !isRecord(params.nextConfig) || !(key in params.nextConfig)) {
123+return false;
124+}
125+const nextConfigRecord = params.nextConfig as Record<string, unknown>;
126+127+const validated = validateConfigObjectWithPlugins(params.nextConfig);
128+if (!validated.ok) {
129+throw createInvalidConfigError(
130+params.snapshot.path,
131+formatInvalidConfigDetails(validated.issues),
132+);
133+}
134+135+await writeJsonFileAtomic(includePath, nextConfigRecord[key]);
136+return true;
137+}
138+38139export async function replaceConfigFile(params: {
39140nextConfig: OpenClawConfig;
40141baseHash?: string;
@@ -47,11 +148,17 @@ export async function replaceConfigFile(params: {
47148 : await readConfigFileSnapshotForWrite();
48149const { snapshot, writeOptions } = prepared;
49150const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
50-await writeConfigFile(params.nextConfig, {
51-baseSnapshot: snapshot,
52- ...writeOptions,
53- ...params.writeOptions,
151+const wroteInclude = await tryWriteSingleTopLevelIncludeMutation({
152+ snapshot,
153+nextConfig: params.nextConfig,
54154});
155+if (!wroteInclude) {
156+await writeConfigFile(params.nextConfig, {
157+baseSnapshot: snapshot,
158+ ...writeOptions,
159+ ...params.writeOptions,
160+});
161+}
55162return {
56163path: snapshot.path,
57164 previousHash,
@@ -74,10 +181,16 @@ export async function mutateConfigFile<T = void>(params: {
74181const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig;
75182const draft = structuredClone(baseConfig) as OpenClawConfig;
76183const result = (await params.mutate(draft, { snapshot, previousHash })) as T | undefined;
77-await writeConfigFile(draft, {
78-...writeOptions,
79-...params.writeOptions,
184+const wroteInclude = await tryWriteSingleTopLevelIncludeMutation({
185+snapshot,
186+nextConfig: draft,
80187});
188+if (!wroteInclude) {
189+await writeConfigFile(draft, {
190+ ...writeOptions,
191+ ...params.writeOptions,
192+});
193+}
81194return {
82195path: snapshot.path,
83196 previousHash,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。