diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 9a133f4a265..1316871c0cf 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -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; + expect( + ((written.agents as Record).defaults as Record).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; + expect((written.channels as Record).slack).toEqual({ + enabled: true, + mode: "socket", + }); + }); + it("dry-runs config patch and resolves changed SecretRefs", async () => { const resolved = { secrets: { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index b3a6e9745e4..5bb3eff8b58 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -915,7 +915,7 @@ function configPatchModeError(message: string): Error { } async function readStdinText(): Promise { - let raw = ""; + const chunks: string[] = []; let bytes = 0; if (process.stdin.isTTY) { throw configPatchModeError( @@ -931,9 +931,9 @@ async function readStdinText(): Promise { `--stdin input exceeds ${CONFIG_PATCH_STDIN_MAX_BYTES} bytes; use --file for larger patches.`, ); } - raw += text; + chunks.push(text); } - return raw; + return chunks.join(""); } async function readConfigPatchInput(opts: ConfigPatchOptions): Promise { @@ -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",