fix(config): preserve numeric patch object keys (#81999)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gio Della-Libera
2026-05-15 00:20:08 -07:00
committed by GitHub
parent bea597b2d6
commit 0dc8552cb3
2 changed files with 60 additions and 7 deletions

View File

@@ -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: {

View File

@@ -453,12 +453,19 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?
return { found: true, value: current };
}
function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
type SetAtPathOptions = { numericObjectKeys?: boolean };
function setAtPath(
root: Record<string, unknown>,
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<string, unknown>, path: PathSegment[], value: unknown): void {
function mergeAtPath(
root: Record<string, unknown>,
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({