CLI/Config: keep explicitly unset keys removed
This commit is contained in:
@@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0.
|
||||
- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012.
|
||||
- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet.
|
||||
- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset <path>` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick.
|
||||
- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet.
|
||||
- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo.
|
||||
- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii.
|
||||
|
||||
@@ -9,11 +9,14 @@ import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
|
||||
*/
|
||||
|
||||
const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
|
||||
const mockWriteConfigFile = vi.fn<(cfg: OpenClawConfig) => Promise<void>>(async () => {});
|
||||
const mockWriteConfigFile = vi.fn<
|
||||
(cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
|
||||
writeConfigFile: (cfg: OpenClawConfig) => mockWriteConfigFile(cfg),
|
||||
writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
|
||||
mockWriteConfigFile(cfg, options),
|
||||
}));
|
||||
|
||||
const mockLog = vi.fn();
|
||||
@@ -216,6 +219,9 @@ describe("config cli", () => {
|
||||
expect(written.gateway).toEqual(resolved.gateway);
|
||||
expect(written.tools?.profile).toBe("coding");
|
||||
expect(written.logging).toEqual(resolved.logging);
|
||||
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({
|
||||
unsetPaths: [["tools", "alsoAllow"]],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(next);
|
||||
await writeConfigFile(next, { unsetPaths: [parsedPath] });
|
||||
runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`));
|
||||
} catch (err) {
|
||||
runtime.error(danger(String(err)));
|
||||
|
||||
@@ -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