diff --git a/src/config/io.ts b/src/config/io.ts index 4bd93754515..574b52ee293 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -39,6 +39,7 @@ import { applyMergePatch } from "./merge-patch.js"; import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js"; import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; +import { isBlockedObjectKey } from "./prototype-keys.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { @@ -143,6 +144,10 @@ function isWritePlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function hasOwnObjectKey(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object"); type UnsetPathWriteResult = { @@ -187,7 +192,11 @@ function unsetPathForWriteAt( return { changed: true, value: next }; } - if (!isWritePlainObject(value) || !(segment in value)) { + if ( + isBlockedObjectKey(segment) || + !isWritePlainObject(value) || + !hasOwnObjectKey(value, segment) + ) { return { changed: false, value }; } if (isLeaf) { @@ -216,9 +225,9 @@ function unsetPathForWriteAt( } function unsetPathForWrite( - root: Record, + root: OpenClawConfig, pathSegments: string[], -): { changed: boolean; next: Record } { +): { changed: boolean; next: OpenClawConfig } { if (pathSegments.length === 0) { return { changed: false, next: root }; } @@ -230,7 +239,7 @@ function unsetPathForWrite( return { changed: true, next: {} }; } if (isWritePlainObject(result.value)) { - return { changed: true, next: result.value }; + return { changed: true, next: coerceConfig(result.value) }; } return { changed: false, next: root }; } @@ -1041,9 +1050,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!Array.isArray(unsetPath) || unsetPath.length === 0) { continue; } - const unsetResult = unsetPathForWrite(outputConfig as Record, unsetPath); + const unsetResult = unsetPathForWrite(outputConfig, unsetPath); if (unsetResult.changed) { - outputConfig = unsetResult.next as OpenClawConfig; + outputConfig = unsetResult.next; } } } diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 6b4b7f229d3..17f1951de33 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -218,6 +218,36 @@ describe("config io write", () => { }); }); + it("ignores blocked prototype-key unset path segments", async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const { configPath, io } = await writeConfigAndCreateIo({ + home, + initialConfig: { + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + }, + }); + + const input: Record = { + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + }; + await io.writeConfigFile(input, { + unsetPaths: [ + ["commands", "__proto__"], + ["commands", "constructor"], + ["commands", "prototype"], + ], + }); + + expect((input.commands as Record).ownerDisplay).toBe("hash"); + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + commands?: Record; + }; + expect(persisted.commands?.ownerDisplay).toBe("hash"); + }); + }); + it("preserves env var references when writing", async () => { await withTempHome("openclaw-config-io-", async (home) => { const { configPath, io, snapshot } = await writeConfigAndCreateIo({