mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(config): enforce default-free persistence in write path
This commit is contained in:
@@ -13,6 +13,7 @@ export * from "./types.js";
|
||||
export {
|
||||
validateConfigObject,
|
||||
validateConfigObjectRaw,
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "./validation.js";
|
||||
export { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||
import { loadDotEnv } from "../infra/dotenv.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 { 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 { validateConfigObjectWithPlugins } from "./validation.js";
|
||||
import {
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "./validation.js";
|
||||
import { compareOpenClawVersions } from "./version.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -92,6 +97,49 @@ function coerceConfig(value: unknown): 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> {
|
||||
if (CONFIG_BACKUP_COUNT <= 1) {
|
||||
return;
|
||||
@@ -502,7 +550,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
|
||||
async function writeConfigFile(cfg: OpenClawConfig) {
|
||||
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) {
|
||||
const issue = validated.issues[0];
|
||||
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 });
|
||||
// Do NOT apply runtime defaults when writing — user config should only contain
|
||||
// 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(
|
||||
dir,
|
||||
|
||||
47
src/config/io.write-config.test.ts
Normal file
47
src/config/io.write-config.test.ts
Normal file
@@ -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[];
|
||||
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) {
|
||||
return { ok: false, issues: base.issues, warnings: [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user