@@ -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
464614function 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+
16321799function 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