mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 04:17:20 +00:00
fix(config): apply unsetPaths immutably
This commit is contained in:
@@ -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");
|
||||
|
||||
149
src/config/io.ts
149
src/config/io.ts
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user