Skip to content

Commit 6b5eba1

Browse files
fix(cli): preserve numeric config set record keys (#83769)
Merged via squash. Prepared head SHA: cb55b4a Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf
1 parent 1b77145 commit 6b5eba1

3 files changed

Lines changed: 308 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ Docs: https://docs.openclaw.ai
218218
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
219219
- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.
220220
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
221+
- CLI/config: preserve numeric-looking record keys such as Discord guild IDs when creating missing config containers with `config set`. (#83769) Thanks @TurboTheTurtle.
221222
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
222223
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
223224
- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.

src/cli/config-cli.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,63 @@ function createPluginMetadataSnapshot(
181181
};
182182
}
183183

184+
function configRecordWithRequireMentionSchema() {
185+
return {
186+
type: "object",
187+
additionalProperties: {
188+
type: "object",
189+
properties: {
190+
requireMention: { type: "boolean" },
191+
},
192+
},
193+
};
194+
}
195+
196+
function configChannelSchemaWithRecord(recordKey: string) {
197+
return {
198+
type: "object",
199+
properties: {
200+
[recordKey]: configRecordWithRequireMentionSchema(),
201+
},
202+
};
203+
}
204+
205+
function setConfigMutationShapeSchema() {
206+
mockReadBestEffortRuntimeConfigSchema.mockResolvedValue({
207+
schema: {
208+
$schema: "http://json-schema.org/draft-07/schema#",
209+
type: "object",
210+
properties: {
211+
agents: {
212+
type: "object",
213+
properties: {
214+
list: {
215+
type: "array",
216+
items: {
217+
type: "object",
218+
properties: {
219+
id: { type: "string" },
220+
name: { type: "string" },
221+
},
222+
},
223+
},
224+
},
225+
},
226+
channels: {
227+
type: "object",
228+
properties: {
229+
discord: configChannelSchemaWithRecord("guilds"),
230+
telegram: configChannelSchemaWithRecord("groups"),
231+
},
232+
},
233+
},
234+
},
235+
uiHints: {},
236+
version: "test",
237+
generatedAt: "2026-03-25T00:00:00.000Z",
238+
});
239+
}
240+
184241
function setExternalFeishuSchema() {
185242
mockLoadPluginMetadataSnapshot.mockReturnValue(
186243
createPluginMetadataSnapshot({
@@ -1262,6 +1319,83 @@ describe("config cli", () => {
12621319
});
12631320
});
12641321

1322+
it("keeps numeric config set path segments as object keys for schema-backed Discord guild records", async () => {
1323+
setConfigMutationShapeSchema();
1324+
const resolved: OpenClawConfig = {
1325+
channels: {
1326+
discord: {
1327+
enabled: true,
1328+
},
1329+
},
1330+
} as unknown as OpenClawConfig;
1331+
setSnapshot(resolved, resolved);
1332+
1333+
await runConfigCommand([
1334+
"config",
1335+
"set",
1336+
"channels.discord.guilds.1495587801394184362.requireMention",
1337+
"true",
1338+
"--strict-json",
1339+
]);
1340+
1341+
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
1342+
const written = firstWrittenConfig() as {
1343+
channels?: { discord?: { guilds?: unknown } };
1344+
};
1345+
expect(written.channels?.discord?.guilds).toEqual({
1346+
"1495587801394184362": {
1347+
requireMention: true,
1348+
},
1349+
});
1350+
expect(Array.isArray(written.channels?.discord?.guilds)).toBe(false);
1351+
});
1352+
1353+
it("keeps numeric config set path segments as object keys for other schema-backed records", async () => {
1354+
setConfigMutationShapeSchema();
1355+
const resolved: OpenClawConfig = {
1356+
channels: {
1357+
telegram: {
1358+
enabled: true,
1359+
},
1360+
},
1361+
} as unknown as OpenClawConfig;
1362+
setSnapshot(resolved, resolved);
1363+
1364+
await runConfigCommand([
1365+
"config",
1366+
"set",
1367+
"channels.telegram.groups.1495587801394184362.requireMention",
1368+
"true",
1369+
"--strict-json",
1370+
]);
1371+
1372+
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
1373+
const written = firstWrittenConfig() as {
1374+
channels?: { telegram?: { groups?: unknown } };
1375+
};
1376+
expect(written.channels?.telegram?.groups).toEqual({
1377+
"1495587801394184362": {
1378+
requireMention: true,
1379+
},
1380+
});
1381+
expect(Array.isArray(written.channels?.telegram?.groups)).toBe(false);
1382+
});
1383+
1384+
it("still creates arrays for schema-backed numeric list indexes", async () => {
1385+
setConfigMutationShapeSchema();
1386+
const resolved: OpenClawConfig = {};
1387+
setSnapshot(resolved, resolved);
1388+
1389+
await runConfigCommand(["config", "set", "agents.list.0.id", '"tech"', "--strict-json"]);
1390+
1391+
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
1392+
const written = firstWrittenConfig() as {
1393+
agents?: { list?: unknown };
1394+
};
1395+
expect(written.agents?.list).toEqual([{ id: "tech" }]);
1396+
expect(Array.isArray(written.agents?.list)).toBe(true);
1397+
});
1398+
12651399
it("fails early when unsupported mutable paths are assigned SecretRef objects (builder mode)", async () => {
12661400
const resolved: OpenClawConfig = {
12671401
gateway: { port: 18789 },

src/cli/config-cli.ts

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,157 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?
459459
return { found: true, value: current };
460460
}
461461

462-
type SetAtPathOptions = { numericObjectKeys?: boolean };
462+
type JsonSchemaRecord = {
463+
type?: unknown;
464+
properties?: unknown;
465+
additionalProperties?: unknown;
466+
items?: unknown;
467+
anyOf?: unknown;
468+
oneOf?: unknown;
469+
allOf?: unknown;
470+
};
471+
472+
type SetAtPathOptions = {
473+
numericObjectKeys?: boolean;
474+
schema?: JsonSchemaRecord;
475+
};
476+
477+
function isSchemaRecord(value: unknown): value is JsonSchemaRecord {
478+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
479+
}
480+
481+
function schemaTypes(schema: JsonSchemaRecord): Set<string> {
482+
if (typeof schema.type === "string") {
483+
return new Set([schema.type]);
484+
}
485+
if (Array.isArray(schema.type)) {
486+
return new Set(schema.type.filter((entry): entry is string => typeof entry === "string"));
487+
}
488+
return new Set();
489+
}
490+
491+
function schemaAlternatives(
492+
schema: JsonSchemaRecord,
493+
seen = new Set<JsonSchemaRecord>(),
494+
): JsonSchemaRecord[] {
495+
if (seen.has(schema)) {
496+
return [];
497+
}
498+
seen.add(schema);
499+
const alternatives: JsonSchemaRecord[] = [schema];
500+
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
501+
const entries = schema[key];
502+
if (!Array.isArray(entries)) {
503+
continue;
504+
}
505+
for (const entry of entries) {
506+
if (isSchemaRecord(entry)) {
507+
alternatives.push(...schemaAlternatives(entry, seen));
508+
}
509+
}
510+
}
511+
return alternatives;
512+
}
513+
514+
function schemaLooksArray(schema: JsonSchemaRecord): boolean {
515+
return (
516+
schemaTypes(schema).has("array") ||
517+
isSchemaRecord(schema.items) ||
518+
Array.isArray(schema.items)
519+
);
520+
}
521+
522+
function schemaLooksObject(schema: JsonSchemaRecord): boolean {
523+
const types = schemaTypes(schema);
524+
return (
525+
types.has("object") ||
526+
isSchemaRecord(schema.properties) ||
527+
schema.additionalProperties === true ||
528+
isSchemaRecord(schema.additionalProperties)
529+
);
530+
}
531+
532+
function propertySchema(schema: JsonSchemaRecord, segment: PathSegment): JsonSchemaRecord[] {
533+
const schemas: JsonSchemaRecord[] = [];
534+
for (const alternative of schemaAlternatives(schema)) {
535+
if (schemaLooksArray(alternative)) {
536+
if (isIndexSegment(segment)) {
537+
const index = Number.parseInt(segment, 10);
538+
const indexedItem = Array.isArray(alternative.items)
539+
? alternative.items[index]
540+
: alternative.items;
541+
if (isSchemaRecord(indexedItem)) {
542+
schemas.push(indexedItem);
543+
}
544+
}
545+
continue;
546+
}
547+
const properties = isSchemaRecord(alternative.properties)
548+
? (alternative.properties as Record<string, unknown>)
549+
: undefined;
550+
const explicit = properties?.[segment];
551+
if (isSchemaRecord(explicit)) {
552+
schemas.push(explicit);
553+
continue;
554+
}
555+
if (isSchemaRecord(alternative.additionalProperties)) {
556+
schemas.push(alternative.additionalProperties);
557+
}
558+
}
559+
return schemas;
560+
}
561+
562+
function schemasAtPath(schema: JsonSchemaRecord | undefined, path: readonly PathSegment[]) {
563+
if (!schema) {
564+
return [];
565+
}
566+
let schemas = [schema];
567+
for (const segment of path) {
568+
schemas = schemas.flatMap((candidate) => propertySchema(candidate, segment));
569+
if (schemas.length === 0) {
570+
return [];
571+
}
572+
}
573+
return schemas;
574+
}
575+
576+
function schemaPrefersArrayAtPath(
577+
schema: JsonSchemaRecord | undefined,
578+
path: readonly PathSegment[],
579+
): boolean | undefined {
580+
const candidates = schemasAtPath(schema, path).flatMap((candidate) =>
581+
schemaAlternatives(candidate),
582+
);
583+
if (candidates.length === 0) {
584+
return undefined;
585+
}
586+
const hasArray = candidates.some((candidate) => schemaLooksArray(candidate));
587+
const hasObject = candidates.some((candidate) => schemaLooksObject(candidate));
588+
if (hasArray && !hasObject) {
589+
return true;
590+
}
591+
if (hasObject && !hasArray) {
592+
return false;
593+
}
594+
return undefined;
595+
}
596+
597+
function shouldCreateArrayForMissingPathSegment(params: {
598+
path: readonly PathSegment[];
599+
segmentIndex: number;
600+
next?: PathSegment;
601+
options?: SetAtPathOptions;
602+
}): boolean {
603+
if (!params.next || params.options?.numericObjectKeys || !isIndexSegment(params.next)) {
604+
return false;
605+
}
606+
const parentPath = params.path.slice(0, params.segmentIndex + 1);
607+
const schemaPreference = schemaPrefersArrayAtPath(params.options?.schema, parentPath);
608+
if (schemaPreference !== undefined) {
609+
return schemaPreference;
610+
}
611+
return true;
612+
}
463613

464614
function setAtPath(
465615
root: Record<string, unknown>,
@@ -471,7 +621,12 @@ function setAtPath(
471621
for (let i = 0; i < path.length - 1; i += 1) {
472622
const segment = path[i];
473623
const next = path[i + 1];
474-
const nextIsIndex = !options?.numericObjectKeys && Boolean(next && isIndexSegment(next));
624+
const nextIsIndex = shouldCreateArrayForMissingPathSegment({
625+
path,
626+
segmentIndex: i,
627+
next,
628+
options,
629+
});
475630
if (Array.isArray(current)) {
476631
if (!isIndexSegment(segment)) {
477632
throw new Error(`Expected numeric index for array segment "${segment}"`);
@@ -1425,7 +1580,11 @@ function collectDryRunRefs(params: {
14251580
if (!ref) {
14261581
continue;
14271582
}
1428-
if (includeAllDiscoveredRefs || targetPaths.has(target.path) || providerAliases.has(ref.provider)) {
1583+
if (
1584+
includeAllDiscoveredRefs ||
1585+
targetPaths.has(target.path) ||
1586+
providerAliases.has(ref.provider)
1587+
) {
14291588
refsByKey.set(secretRefKey(ref), ref);
14301589
}
14311590
}
@@ -1629,6 +1788,14 @@ function formatAutoManagedMetaError(paths: readonly PathSegment[][]): string {
16291788
].join("\n");
16301789
}
16311790

1791+
async function loadConfigMutationSchema(): Promise<JsonSchemaRecord | undefined> {
1792+
try {
1793+
return structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as JsonSchemaRecord;
1794+
} catch {
1795+
return undefined;
1796+
}
1797+
}
1798+
16321799
function collectDryRunSchemaErrors(params: { config: OpenClawConfig }): ConfigSetDryRunError[] {
16331800
const validated = validateConfigObjectRawWithPlugins(params.config);
16341801
if (validated.ok) {
@@ -1719,6 +1886,7 @@ async function runConfigOperations(params: {
17191886
// instead of snapshot.config (runtime-merged with defaults).
17201887
// This prevents runtime defaults from leaking into the written config file (issue #6070)
17211888
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
1889+
const mutationSchema = await loadConfigMutationSchema();
17221890
const unsetPaths: PathSegment[][] = [];
17231891
const explicitSetPaths: PathSegment[][] = [];
17241892
for (const operation of operations) {
@@ -1731,6 +1899,7 @@ async function runConfigOperations(params: {
17311899
if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) {
17321900
mergeAtPath(next, operation.setPath, operation.value, {
17331901
numericObjectKeys: params.successMode === "patch",
1902+
schema: mutationSchema,
17341903
});
17351904
} else {
17361905
assertNonDestructiveReplacement({
@@ -1741,6 +1910,7 @@ async function runConfigOperations(params: {
17411910
});
17421911
setAtPath(next, operation.setPath, operation.value, {
17431912
numericObjectKeys: params.successMode === "patch",
1913+
schema: mutationSchema,
17441914
});
17451915
}
17461916
}

0 commit comments

Comments
 (0)