diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 6a60f3b7fe6..cf31823990b 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -644,8 +644,6 @@ describe("Agent-specific tool filtering", () => { const result = await execTool!.execute("call-implicit-sandbox-default", { command: "echo done", - // CI on slower Windows runners can surface an in-flight status with very low yieldMs. - yieldMs: 1000, }); const details = result?.details as { status?: string } | undefined; expect(details?.status).toBe("completed"); diff --git a/src/config/io.ts b/src/config/io.ts index 2fa4eb6a2a6..4bd93754515 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -143,76 +143,96 @@ function isWritePlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } -function unsetPathForWrite(root: Record, pathSegments: string[]): boolean { - if (pathSegments.length === 0) { - return false; +const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object"); + +type UnsetPathWriteResult = { + changed: boolean; + value: unknown; +}; + +function unsetPathForWriteAt( + value: unknown, + pathSegments: string[], + depth: number, +): UnsetPathWriteResult { + if (depth >= pathSegments.length) { + return { changed: false, value }; } + const segment = pathSegments[depth]; + const isLeaf = depth === pathSegments.length - 1; - const traversal: Array<{ container: unknown; key: string | number }> = []; - let cursor: unknown = root; - - for (let i = 0; i < pathSegments.length - 1; i += 1) { - const segment = pathSegments[i]; - if (Array.isArray(cursor)) { - if (!isNumericPathSegment(segment)) { - return false; - } - const index = Number.parseInt(segment, 10); - if (!Number.isFinite(index) || index < 0 || index >= cursor.length) { - return false; - } - traversal.push({ container: cursor, key: index }); - cursor = cursor[index]; - continue; + if (Array.isArray(value)) { + if (!isNumericPathSegment(segment)) { + return { changed: false, value }; } - if (!isWritePlainObject(cursor) || !(segment in cursor)) { - return false; + const index = Number.parseInt(segment, 10); + if (!Number.isFinite(index) || index < 0 || index >= value.length) { + return { changed: false, value }; } - traversal.push({ container: cursor, key: segment }); - cursor = cursor[segment]; - } - - const leaf = pathSegments[pathSegments.length - 1]; - if (Array.isArray(cursor)) { - if (!isNumericPathSegment(leaf)) { - return false; + if (isLeaf) { + const next = value.slice(); + next.splice(index, 1); + return { changed: true, value: next }; } - const index = Number.parseInt(leaf, 10); - if (!Number.isFinite(index) || index < 0 || index >= cursor.length) { - return false; + const child = unsetPathForWriteAt(value[index], pathSegments, depth + 1); + if (!child.changed) { + return { changed: false, value }; } - cursor.splice(index, 1); - } else { - if (!isWritePlainObject(cursor) || !(leaf in cursor)) { - return false; - } - delete cursor[leaf]; - } - - // Prune now-empty object branches after unsetting to avoid dead config scaffolding. - for (let i = traversal.length - 1; i >= 0; i -= 1) { - const { container, key } = traversal[i]; - let child: unknown; - if (Array.isArray(container)) { - child = typeof key === "number" ? container[key] : undefined; - } else if (isWritePlainObject(container)) { - child = container[String(key)]; + const next = value.slice(); + if (child.value === WRITE_PRUNED_OBJECT) { + next.splice(index, 1); } else { - break; - } - if (!isWritePlainObject(child) || Object.keys(child).length > 0) { - break; - } - if (Array.isArray(container) && typeof key === "number") { - if (key >= 0 && key < container.length) { - container.splice(key, 1); - } - } else if (isWritePlainObject(container)) { - delete container[String(key)]; + next[index] = child.value; } + return { changed: true, value: next }; } - return true; + if (!isWritePlainObject(value) || !(segment in value)) { + return { changed: false, value }; + } + if (isLeaf) { + const next: Record = { ...value }; + delete next[segment]; + return { + changed: true, + value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next, + }; + } + + const child = unsetPathForWriteAt(value[segment], pathSegments, depth + 1); + if (!child.changed) { + return { changed: false, value }; + } + const next: Record = { ...value }; + if (child.value === WRITE_PRUNED_OBJECT) { + delete next[segment]; + } else { + next[segment] = child.value; + } + return { + changed: true, + value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next, + }; +} + +function unsetPathForWrite( + root: Record, + pathSegments: string[], +): { changed: boolean; next: Record } { + if (pathSegments.length === 0) { + return { changed: false, next: root }; + } + const result = unsetPathForWriteAt(root, pathSegments, 0); + if (!result.changed) { + return { changed: false, next: root }; + } + if (result.value === WRITE_PRUNED_OBJECT) { + return { changed: true, next: {} }; + } + if (isWritePlainObject(result.value)) { + return { changed: true, next: result.value }; + } + return { changed: false, next: root }; } export function resolveConfigSnapshotHash(snapshot: { @@ -1015,15 +1035,16 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { envRefMap && changedPaths ? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig) : cfgToWrite; - const outputConfig = options.unsetPaths?.length - ? cloneUnknown(outputConfigBase) - : outputConfigBase; + let outputConfig = outputConfigBase; if (options.unsetPaths?.length) { for (const unsetPath of options.unsetPaths) { if (!Array.isArray(unsetPath) || unsetPath.length === 0) { continue; } - unsetPathForWrite(outputConfig as Record, unsetPath); + const unsetResult = unsetPathForWrite(outputConfig as Record, unsetPath); + if (unsetResult.changed) { + outputConfig = unsetResult.next as OpenClawConfig; + } } } // Do NOT apply runtime defaults when writing — user config should only contain diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 77439077b88..6b4b7f229d3 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -152,6 +152,72 @@ describe("config io write", () => { }); }); + it("does not mutate caller config when unsetPaths is applied on existing files", async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const { configPath, io, snapshot } = await writeConfigAndCreateIo({ + home, + initialConfig: { + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + }, + }); + + const input = structuredClone(snapshot.config) as Record; + await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] }); + + expect((input.commands as Record).ownerDisplay).toBe("hash"); + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + commands?: Record; + }; + expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay"); + }); + }); + + it("keeps caller arrays immutable when unsetting array entries", async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const { configPath, io, snapshot } = await writeConfigAndCreateIo({ + home, + initialConfig: { + gateway: { mode: "local" }, + tools: { alsoAllow: ["exec", "fetch", "read"] }, + }, + }); + + const input = structuredClone(snapshot.config) as Record; + await io.writeConfigFile(input, { unsetPaths: [["tools", "alsoAllow", "1"]] }); + + expect((input.tools as { alsoAllow: string[] }).alsoAllow).toEqual(["exec", "fetch", "read"]); + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + tools?: { alsoAllow?: string[] }; + }; + expect(persisted.tools?.alsoAllow).toEqual(["exec", "read"]); + }); + }); + + it("treats missing unset paths as no-op without mutating caller config", 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", "missingKey"]] }); + + 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({