fix(config): preserve empty patch objects

This commit is contained in:
Peter Steinberger
2026-04-29 21:53:20 +01:00
parent ec7536078f
commit df51878b0b
2 changed files with 88 additions and 7 deletions

View File

@@ -81,6 +81,15 @@ function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
}
function writeTempJson5File(prefix: string, value: unknown): string {
const pathname = path.join(
os.tmpdir(),
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
);
fs.writeFileSync(pathname, JSON.stringify(value), "utf8");
return pathname;
}
function withRuntimeDefaults(resolved: OpenClawConfig): OpenClawConfig {
return {
...resolved,
@@ -733,7 +742,10 @@ describe("config cli", () => {
const configCommand = program.commands.find((command) => command.name() === "config");
const setCommand = configCommand?.commands.find((command) => command.name() === "set");
const helpText = setCommand?.helpInformation() ?? "";
const configHelpText = configCommand?.helpInformation() ?? "";
expect(configHelpText).toContain("get/set/patch/unset/file/schema/validate");
expect(configHelpText).not.toContain("get/set/apply/unset/file/schema/validate");
expect(helpText).toContain("--strict-json");
expect(helpText).toContain("--json");
expect(helpText).toContain("Legacy alias for --strict-json");
@@ -1462,6 +1474,71 @@ describe("config cli", () => {
).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
});
it("preserves empty object values in config patch", async () => {
const resolved = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT 5.4" },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = writeTempJson5File("openclaw-config-patch-empty-object", {
agents: {
defaults: {
models: {
"openai/gpt-5.5": {},
},
},
},
});
try {
await runConfigCommand(["config", "patch", "--file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
).toEqual({
"openai/gpt-5.4": { alias: "GPT 5.4" },
"openai/gpt-5.5": {},
});
});
it("treats empty object config patches as recursive merges", async () => {
const resolved = {
channels: {
slack: {
enabled: true,
mode: "socket",
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = writeTempJson5File("openclaw-config-patch-empty-merge", {
channels: {
slack: {},
},
});
try {
await runConfigCommand(["config", "patch", "--file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
expect((written.channels as Record<string, unknown>).slack).toEqual({
enabled: true,
mode: "socket",
});
});
it("dry-runs config patch and resolves changed SecretRefs", async () => {
const resolved = {
secrets: {

View File

@@ -915,7 +915,7 @@ function configPatchModeError(message: string): Error {
}
async function readStdinText(): Promise<string> {
let raw = "";
const chunks: string[] = [];
let bytes = 0;
if (process.stdin.isTTY) {
throw configPatchModeError(
@@ -931,9 +931,9 @@ async function readStdinText(): Promise<string> {
`--stdin input exceeds ${CONFIG_PATCH_STDIN_MAX_BYTES} bytes; use --file <path> for larger patches.`,
);
}
raw += text;
chunks.push(text);
}
return raw;
return chunks.join("");
}
async function readConfigPatchInput(opts: ConfigPatchOptions): Promise<unknown> {
@@ -972,7 +972,7 @@ function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation {
function buildApplyValueOperation(params: {
path: PathSegment[];
value: unknown;
mutation?: "set" | "replace";
mutation?: ConfigSetOperation["mutation"];
}): ConfigSetOperation {
const ref = isPlainRecord(params.value) ? coerceSecretRef(params.value) : null;
if (ref) {
@@ -1026,6 +1026,10 @@ function buildConfigPatchOperations(params: {
return;
}
if (isPlainRecord(value)) {
if (path.length > 0 && Object.keys(value).length === 0) {
operations.push(buildApplyValueOperation({ path, value, mutation: "merge" }));
return;
}
for (const [key, child] of Object.entries(value)) {
visit(child, [...path, key]);
}
@@ -1373,7 +1377,7 @@ async function runConfigOperations(params: {
runtime: RuntimeEnv;
operations: ConfigSetOperation[];
options: ConfigMutationOptions;
successMode: "set" | "apply";
successMode: "set" | "patch";
}) {
const { runtime, operations, options } = params;
if (
@@ -1640,7 +1644,7 @@ export async function runConfigPatch(opts: {
allowExec: opts.cliOptions.allowExec,
json: opts.cliOptions.json,
},
successMode: "apply",
successMode: "patch",
});
} catch (err) {
handleConfigMutationError({ err, runtime, options: opts.cliOptions });
@@ -1795,7 +1799,7 @@ export function registerConfigCli(program: Command) {
const cmd = program
.command("config")
.description(
"Non-interactive config helpers (get/set/apply/unset/file/schema/validate). Run without subcommand for guided setup.",
"Non-interactive config helpers (get/set/patch/unset/file/schema/validate). Run without subcommand for guided setup.",
)
.addHelpText(
"after",