mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:18:38 +00:00
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.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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 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", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
|
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
|
||||||
writeConfigFile: (cfg: OpenClawConfig) => mockWriteConfigFile(cfg),
|
writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
|
||||||
|
mockWriteConfigFile(cfg, options),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLog = vi.fn();
|
const mockLog = vi.fn();
|
||||||
@@ -216,6 +219,9 @@ describe("config cli", () => {
|
|||||||
expect(written.gateway).toEqual(resolved.gateway);
|
expect(written.gateway).toEqual(resolved.gateway);
|
||||||
expect(written.tools?.profile).toBe("coding");
|
expect(written.tools?.profile).toBe("coding");
|
||||||
expect(written.logging).toEqual(resolved.logging);
|
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);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next, { unsetPaths: [parsedPath] });
|
||||||
runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`));
|
runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(danger(String(err)));
|
runtime.error(danger(String(err)));
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ export type ConfigWriteOptions = {
|
|||||||
* same config file path that produced the snapshot.
|
* same config file path that produced the snapshot.
|
||||||
*/
|
*/
|
||||||
expectedConfigPath?: string;
|
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 = {
|
export type ReadConfigFileSnapshotForWriteResult = {
|
||||||
@@ -128,6 +133,86 @@ function hashConfigRaw(raw: string | null): string {
|
|||||||
.digest("hex");
|
.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: {
|
export function resolveConfigSnapshotHash(snapshot: {
|
||||||
hash?: string;
|
hash?: string;
|
||||||
raw?: string | null;
|
raw?: string | null;
|
||||||
@@ -892,6 +977,14 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
envRefMap && changedPaths
|
envRefMap && changedPaths
|
||||||
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
|
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
|
||||||
: cfgToWrite;
|
: 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
|
// Do NOT apply runtime defaults when writing — user config should only contain
|
||||||
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
||||||
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
||||||
@@ -1129,5 +1222,6 @@ export async function writeConfigFile(
|
|||||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||||
await io.writeConfigFile(cfg, {
|
await io.writeConfigFile(cfg, {
|
||||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
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 () => {
|
it("preserves env var references when writing", async () => {
|
||||||
await withTempHome("openclaw-config-io-", async (home) => {
|
await withTempHome("openclaw-config-io-", async (home) => {
|
||||||
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
|
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
|
||||||
|
|||||||
Reference in New Issue
Block a user