fix(config): apply unsetPaths immutably

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 02:09:55 -05:00
parent bcd964d268
commit 191c533ced
3 changed files with 151 additions and 66 deletions

View File

@@ -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");

View File

@@ -143,76 +143,96 @@ function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function unsetPathForWrite(root: Record<string, unknown>, 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<string, unknown> = { ...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<string, unknown> = { ...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<string, unknown>,
pathSegments: string[],
): { changed: boolean; next: Record<string, unknown> } {
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<string, unknown>, unsetPath);
const unsetResult = unsetPathForWrite(outputConfig as Record<string, unknown>, unsetPath);
if (unsetResult.changed) {
outputConfig = unsetResult.next as OpenClawConfig;
}
}
}
// Do NOT apply runtime defaults when writing — user config should only contain

View File

@@ -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<string, unknown>;
await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
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<string, unknown>;
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<string, unknown> = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
await io.writeConfigFile(input, { unsetPaths: [["commands", "missingKey"]] });
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
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({