mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 13:02:10 +03:00
fix(config): enforce default-free persistence in write path
This commit is contained in:
@@ -13,6 +13,7 @@ export * from "./types.js";
|
|||||||
export {
|
export {
|
||||||
validateConfigObject,
|
validateConfigObject,
|
||||||
validateConfigObjectRaw,
|
validateConfigObjectRaw,
|
||||||
|
validateConfigObjectRawWithPlugins,
|
||||||
validateConfigObjectWithPlugins,
|
validateConfigObjectWithPlugins,
|
||||||
} from "./validation.js";
|
} from "./validation.js";
|
||||||
export { OpenClawSchema } from "./zod-schema.js";
|
export { OpenClawSchema } from "./zod-schema.js";
|
||||||
|
|||||||
+60
-3
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||||
import { loadDotEnv } from "../infra/dotenv.js";
|
import { loadDotEnv } from "../infra/dotenv.js";
|
||||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||||
@@ -28,10 +29,14 @@ import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"
|
|||||||
import { collectConfigEnvVars } from "./env-vars.js";
|
import { collectConfigEnvVars } from "./env-vars.js";
|
||||||
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
|
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
|
||||||
import { findLegacyConfigIssues } from "./legacy.js";
|
import { findLegacyConfigIssues } from "./legacy.js";
|
||||||
|
import { applyMergePatch } from "./merge-patch.js";
|
||||||
import { normalizeConfigPaths } from "./normalize-paths.js";
|
import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||||
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
||||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||||
import { validateConfigObjectWithPlugins } from "./validation.js";
|
import {
|
||||||
|
validateConfigObjectRawWithPlugins,
|
||||||
|
validateConfigObjectWithPlugins,
|
||||||
|
} from "./validation.js";
|
||||||
import { compareOpenClawVersions } from "./version.js";
|
import { compareOpenClawVersions } from "./version.js";
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@@ -92,6 +97,49 @@ function coerceConfig(value: unknown): OpenClawConfig {
|
|||||||
return value as OpenClawConfig;
|
return value as OpenClawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise<void> {
|
async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise<void> {
|
||||||
if (CONFIG_BACKUP_COUNT <= 1) {
|
if (CONFIG_BACKUP_COUNT <= 1) {
|
||||||
return;
|
return;
|
||||||
@@ -502,7 +550,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
|
|
||||||
async function writeConfigFile(cfg: OpenClawConfig) {
|
async function writeConfigFile(cfg: OpenClawConfig) {
|
||||||
clearConfigCache();
|
clearConfigCache();
|
||||||
const validated = validateConfigObjectWithPlugins(cfg);
|
let persistCandidate: unknown = cfg;
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (snapshot.valid && snapshot.exists) {
|
||||||
|
const patch = createMergePatch(snapshot.config, cfg);
|
||||||
|
persistCandidate = applyMergePatch(snapshot.resolved, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateConfigObjectRawWithPlugins(persistCandidate);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
const issue = validated.issues[0];
|
const issue = validated.issues[0];
|
||||||
const pathLabel = issue?.path ? issue.path : "<root>";
|
const pathLabel = issue?.path ? issue.path : "<root>";
|
||||||
@@ -518,7 +573,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
// Do NOT apply runtime defaults when writing — user config should only contain
|
// Do NOT apply runtime defaults when writing — user config should only contain
|
||||||
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
||||||
const json = JSON.stringify(stampConfigVersion(cfg), null, 2).trimEnd().concat("\n");
|
const json = JSON.stringify(stampConfigVersion(validated.config), null, 2)
|
||||||
|
.trimEnd()
|
||||||
|
.concat("\n");
|
||||||
|
|
||||||
const tmp = path.join(
|
const tmp = path.join(
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createConfigIO } from "./io.js";
|
||||||
|
import { withTempHome } from "./test-helpers.js";
|
||||||
|
|
||||||
|
describe("config io write", () => {
|
||||||
|
it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ gateway: { port: 18789 } }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const io = createConfigIO({
|
||||||
|
env: {} as NodeJS.ProcessEnv,
|
||||||
|
homedir: () => home,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await io.readConfigFileSnapshot();
|
||||||
|
expect(snapshot.valid).toBe(true);
|
||||||
|
|
||||||
|
const next = structuredClone(snapshot.config);
|
||||||
|
next.gateway = {
|
||||||
|
...next.gateway,
|
||||||
|
auth: { mode: "token" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await io.writeConfigFile(next);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(persisted.gateway).toEqual({
|
||||||
|
port: 18789,
|
||||||
|
auth: { mode: "token" },
|
||||||
|
});
|
||||||
|
expect(persisted).not.toHaveProperty("agents.defaults");
|
||||||
|
expect(persisted).not.toHaveProperty("messages.ackReaction");
|
||||||
|
expect(persisted).not.toHaveProperty("sessions.persistence");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,7 +156,38 @@ export function validateConfigObjectWithPlugins(raw: unknown):
|
|||||||
issues: ConfigValidationIssue[];
|
issues: ConfigValidationIssue[];
|
||||||
warnings: ConfigValidationIssue[];
|
warnings: ConfigValidationIssue[];
|
||||||
} {
|
} {
|
||||||
const base = validateConfigObject(raw);
|
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConfigObjectRawWithPlugins(raw: unknown):
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
config: OpenClawConfig;
|
||||||
|
warnings: ConfigValidationIssue[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
issues: ConfigValidationIssue[];
|
||||||
|
warnings: ConfigValidationIssue[];
|
||||||
|
} {
|
||||||
|
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfigObjectWithPluginsBase(
|
||||||
|
raw: unknown,
|
||||||
|
opts: { applyDefaults: boolean },
|
||||||
|
):
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
config: OpenClawConfig;
|
||||||
|
warnings: ConfigValidationIssue[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
issues: ConfigValidationIssue[];
|
||||||
|
warnings: ConfigValidationIssue[];
|
||||||
|
} {
|
||||||
|
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
|
||||||
if (!base.ok) {
|
if (!base.ok) {
|
||||||
return { ok: false, issues: base.issues, warnings: [] };
|
return { ok: false, issues: base.issues, warnings: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user