mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
CLI/Config: keep explicitly unset keys removed
This commit is contained in:
@@ -114,6 +114,11 @@ export type ConfigWriteOptions = {
|
||||
* same config file path that produced the snapshot.
|
||||
*/
|
||||
expectedConfigPath?: string;
|
||||
/**
|
||||
* Paths that must be explicitly removed from the persisted file payload,
|
||||
* even if schema/default normalization reintroduces them.
|
||||
*/
|
||||
unsetPaths?: string[][];
|
||||
};
|
||||
|
||||
export type ReadConfigFileSnapshotForWriteResult = {
|
||||
@@ -128,6 +133,86 @@ function hashConfigRaw(raw: string | null): string {
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function isNumericPathSegment(raw: string): boolean {
|
||||
return /^[0-9]+$/.test(raw);
|
||||
}
|
||||
|
||||
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 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 (!isWritePlainObject(cursor) || !(segment in cursor)) {
|
||||
return false;
|
||||
}
|
||||
traversal.push({ container: cursor, key: segment });
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
|
||||
const leaf = pathSegments[pathSegments.length - 1];
|
||||
if (Array.isArray(cursor)) {
|
||||
if (!isNumericPathSegment(leaf)) {
|
||||
return false;
|
||||
}
|
||||
const index = Number.parseInt(leaf, 10);
|
||||
if (!Number.isFinite(index) || index < 0 || index >= cursor.length) {
|
||||
return false;
|
||||
}
|
||||
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)];
|
||||
} 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)];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveConfigSnapshotHash(snapshot: {
|
||||
hash?: string;
|
||||
raw?: string | null;
|
||||
@@ -892,6 +977,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
envRefMap && changedPaths
|
||||
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
|
||||
: cfgToWrite;
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Do NOT apply runtime defaults when writing — user config should only contain
|
||||
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
||||
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
||||
@@ -1129,5 +1222,6 @@ export async function writeConfigFile(
|
||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||
await io.writeConfigFile(cfg, {
|
||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
||||
unsetPaths: options.unsetPaths,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +96,34 @@ describe("config io write", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("honors explicit unset paths when schema defaults would otherwise reappear", async () => {
|
||||
await withTempHome("openclaw-config-io-", async (home) => {
|
||||
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
|
||||
home,
|
||||
initialConfig: {
|
||||
gateway: { auth: { mode: "none" } },
|
||||
commands: { ownerDisplay: "hash" },
|
||||
},
|
||||
});
|
||||
|
||||
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
|
||||
if (
|
||||
next.commands &&
|
||||
typeof next.commands === "object" &&
|
||||
"ownerDisplay" in (next.commands as Record<string, unknown>)
|
||||
) {
|
||||
delete (next.commands as Record<string, unknown>).ownerDisplay;
|
||||
}
|
||||
|
||||
await io.writeConfigFile(next, { unsetPaths: [["commands", "ownerDisplay"]] });
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
|
||||
commands?: Record<string, unknown>;
|
||||
};
|
||||
expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay");
|
||||
});
|
||||
});
|
||||
|
||||
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