diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index f64c84bf7c83..9d3063c307bb 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -774,6 +774,46 @@ describe("config io write", () => { }); }); + it("drops keys that exist only on the next-config prototype", async () => { + await withSuiteHome(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: { mode: "local", port: 18789 }, + commands: { ownerDisplay: "hash" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const io = createConfigIO({ + configPath, + env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const nextConfig = Object.assign( + Object.create({ commands: { ownerDisplay: "raw" } }) as Record, + { gateway: { mode: "local", port: 19001 } }, + ); + + await io.writeConfigFile(nextConfig as OpenClawConfig); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record< + string, + unknown + >; + expect(persisted.gateway).toEqual({ mode: "local", port: 19001 }); + expect(Object.hasOwn(persisted, "commands")).toBe(false); + }); + }); + it("does not log an overwrite audit entry when creating config for the first time", async () => { await withSuiteHome(async (home) => { const warn = vi.fn(); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index a91c9467d01d..b6f1820f9498 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { collectChangedPaths, - formatConfigValidationFailure, applyUnsetPathsForWrite, + createMergePatch, + formatConfigValidationFailure, restoreEnvRefsFromMap, resolvePersistCandidateForWrite, resolveWriteEnvSnapshotForPath, @@ -12,6 +13,25 @@ import { import type { OpenClawConfig } from "./types.js"; describe("config io write prepare", () => { + it("ignores prototype-chain keys when building merge patches", () => { + // Discriminating fixture: `collision` is own on base and only inherited on + // target. With `key in target` the old code treated the inherited value as + // present and emitted it in the patch; Object.hasOwn deletes the own key. + const base = { + safe: { mode: "local" }, + collision: { mode: "owned-base" }, + }; + const target = Object.create({ + collision: { mode: "inherited-target" }, + }) as Record; + target.safe = { mode: "cloud" }; + + expect(createMergePatch(base, target)).toEqual({ + safe: { mode: "cloud" }, + collision: null, + }); + }); + it("persists caller changes onto resolved config without leaking runtime defaults", () => { const persisted = resolvePersistCandidateForWrite({ runtimeConfig: { @@ -1046,6 +1066,25 @@ describe("config io write prepare", () => { }); }); + it("ignores prototype-chain keys when collecting changed paths", () => { + // Same one-sided collision as the merge-patch fixture: own on base, only + // inherited on target. Old `in` checks walked into collision.mode; hasOwn + // reports the top-level own-key removal instead. + const base = { + safe: { mode: "local" }, + collision: { mode: "owned-base" }, + }; + const target = Object.create({ + collision: { mode: "inherited-target" }, + }) as Record; + target.safe = { mode: "cloud" }; + + const changedPaths = new Set(); + collectChangedPaths(base, target, "", changedPaths); + + expect([...changedPaths].toSorted()).toEqual(["collision", "safe.mode"]); + }); + it("does not overwrite identity-restored escaped refs with positional map entries", () => { const restored = restoreEnvRefsFromMap( { diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 8a8b378d045c..1e00f33570bc 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -1160,8 +1160,8 @@ export function collectChangedPaths( const keys = new Set([...Object.keys(base), ...Object.keys(target)]); for (const key of keys) { const childPath = path ? `${path}.${key}` : key; - const hasBase = key in base; - const hasTarget = key in target; + const hasBase = Object.hasOwn(base, key); + const hasTarget = Object.hasOwn(target, key); if (!hasTarget || !hasBase) { output.add(childPath); continue;