mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user