From 0dc8552cb3ff74af02aa70320c4b1a98658bc5cc Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Fri, 15 May 2026 00:20:08 -0700 Subject: [PATCH] fix(config): preserve numeric patch object keys (#81999) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/config-cli.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/cli/config-cli.ts | 30 +++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index f80f6d48b04..ded78b6c835 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1973,6 +1973,43 @@ describe("config cli", () => { }); }); + it("keeps numeric config patch object keys as object keys", async () => { + const resolved = { + channels: { + discord: { + enabled: true, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + const pathname = writeTempJson5File("openclaw-config-patch-numeric-object-key", { + channels: { + discord: { + guilds: { + "123456789012345678": { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }, + }, + }); + try { + await runConfigCommand(["config", "patch", "--file", pathname]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + const written = firstWrittenConfig() as { + channels?: { discord?: { guilds?: unknown } }; + }; + expect(written.channels?.discord?.guilds).toEqual({ + "123456789012345678": { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }); + }); + 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 52cc12072b0..df9983611ca 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -453,12 +453,19 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value? return { found: true, value: current }; } -function setAtPath(root: Record, path: PathSegment[], value: unknown): void { +type SetAtPathOptions = { numericObjectKeys?: boolean }; + +function setAtPath( + root: Record, + path: PathSegment[], + value: unknown, + options?: SetAtPathOptions, +): void { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; const next = path[i + 1]; - const nextIsIndex = Boolean(next && isIndexSegment(next)); + const nextIsIndex = !options?.numericObjectKeys && Boolean(next && isIndexSegment(next)); if (Array.isArray(current)) { if (!isIndexSegment(segment)) { throw new Error(`Expected numeric index for array segment "${segment}"`); @@ -554,13 +561,18 @@ function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[] throw new Error(`Cannot merge ${toDotPath(path)}; use --replace to replace intentionally.`); } -function mergeAtPath(root: Record, path: PathSegment[], value: unknown): void { +function mergeAtPath( + root: Record, + path: PathSegment[], + value: unknown, + options?: SetAtPathOptions, +): void { const existing = getAtPath(root, path); if (!existing.found) { - setAtPath(root, path, value); + setAtPath(root, path, value, options); return; } - setAtPath(root, path, mergeConfigValue(existing.value, value, path)); + setAtPath(root, path, mergeConfigValue(existing.value, value, path), options); } function isProviderModelListPath(path: PathSegment[]): boolean { @@ -1675,7 +1687,9 @@ async function runConfigOperations(params: { } explicitSetPaths.push(operation.setPath); if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) { - mergeAtPath(next, operation.setPath, operation.value); + mergeAtPath(next, operation.setPath, operation.value, { + numericObjectKeys: params.successMode === "patch", + }); } else { assertNonDestructiveReplacement({ root: next, @@ -1683,7 +1697,9 @@ async function runConfigOperations(params: { value: operation.value, allowReplace: options.replace || operation.mutation === "replace", }); - setAtPath(next, operation.setPath, operation.value); + setAtPath(next, operation.setPath, operation.value, { + numericObjectKeys: params.successMode === "patch", + }); } } const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({