fix(config): enforce own-key semantics in write preparation (#102231)

* fix(config): use Object.hasOwn in write-prepare helpers

* fix(config): enforce own-key change tracking

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NIO
2026-07-13 07:31:24 +08:00
committed by GitHub
parent 1663c8801d
commit b1eb59c0be
3 changed files with 82 additions and 3 deletions
+40
View File
@@ -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<string, unknown>,
{ 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();
+40 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
target.safe = { mode: "cloud" };
const changedPaths = new Set<string>();
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(
{
+2 -2
View File
@@ -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;