@@ -5,6 +5,7 @@ import path from "node:path";
55import JSON5 from "json5" ;
66import { afterAll , beforeAll , describe , expect , it , vi } from "vitest" ;
77import { CONFIG_CLOBBER_SNAPSHOT_LIMIT } from "./io.clobber-snapshot.js" ;
8+ import { createConfigIO } from "./io.js" ;
89import {
910 maybeRecoverSuspiciousConfigRead ,
1011 maybeRecoverSuspiciousConfigReadSync ,
@@ -135,6 +136,29 @@ describe("config observe recovery", () => {
135136 return ( await readObserveEvents ( auditPath ) ) . at ( - 1 ) ;
136137 }
137138
139+ function createTestConfigIO (
140+ home : string ,
141+ warn = vi . fn ( ) ,
142+ options : { env ?: NodeJS . ProcessEnv ; observe ?: boolean } = { } ,
143+ ) {
144+ const configPath = path . join ( home , ".openclaw" , "openclaw.json" ) ;
145+ const error = vi . fn ( ) ;
146+ return {
147+ configPath,
148+ warn,
149+ error,
150+ io : createConfigIO ( {
151+ fs,
152+ json5 : JSON5 ,
153+ env : options . env ?? ( { } as NodeJS . ProcessEnv ) ,
154+ homedir : ( ) => home ,
155+ configPath,
156+ logger : { warn, error } ,
157+ ...( options . observe === false ? { observe : false } : { } ) ,
158+ } ) ,
159+ } ;
160+ }
161+
138162 async function recoverClobberedUpdateChannel ( params : {
139163 deps : ObserveRecoveryDeps ;
140164 configPath : string ;
@@ -368,6 +392,180 @@ describe("config observe recovery", () => {
368392 } ) ;
369393 } ) ;
370394
395+ it ( "read snapshots auto-restore tiny valid clobbers before recording them observed" , async ( ) => {
396+ await withSuiteHome ( async ( home ) => {
397+ const { io, configPath, warn } = createTestConfigIO ( home ) ;
398+ const auditPath = path . join ( home , ".openclaw" , "logs" , "config-audit.jsonl" ) ;
399+ await seedConfigBackup ( configPath , {
400+ ...recoverableTelegramConfig ,
401+ channels : {
402+ telegram : {
403+ enabled : true ,
404+ dmPolicy : "pairing" ,
405+ groupPolicy : "allowlist" ,
406+ allowFrom : Array . from ( { length : 60 } , ( _ , index ) => `telegram-user-${ index } ` ) ,
407+ } ,
408+ } ,
409+ } ) ;
410+ const clobbered = await writeConfigRaw ( configPath , {
411+ meta : { lastTouchedVersion : "2026.5.28" } ,
412+ } ) ;
413+
414+ const snapshot = await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
415+
416+ expect ( snapshot . valid ) . toBe ( true ) ;
417+ expect ( snapshot . config . gateway ?. mode ) . toBe ( "local" ) ;
418+ await expect ( fsp . readFile ( configPath , "utf-8" ) ) . resolves . not . toBe ( clobbered . raw ) ;
419+ expectWarnContaining ( warn , "Config auto-restored from backup:" ) ;
420+ const observeEvents = await readObserveEvents ( auditPath ) ;
421+ expect ( observeEvents ) . toHaveLength ( 1 ) ;
422+ expect ( observeEvents [ 0 ] ?. restoredFromBackup ) . toBe ( true ) ;
423+ expectSuspiciousMatching ( observeEvents [ 0 ] , / ^ s i z e - d r o p - v s - l a s t - g o o d : / ) ;
424+ expectSuspiciousIncludes ( observeEvents [ 0 ] , "gateway-mode-missing-vs-last-good" ) ;
425+ await expect ( listClobberFiles ( configPath ) ) . resolves . toHaveLength ( 1 ) ;
426+ } ) ;
427+ } ) ;
428+
429+ it ( "loadConfig auto-restores tiny valid clobbers before using defaults" , async ( ) => {
430+ await withSuiteHome ( async ( home ) => {
431+ const { io, configPath, warn } = createTestConfigIO ( home ) ;
432+ await seedConfigBackup ( configPath , recoverableTelegramConfig ) ;
433+ await writeConfigRaw ( configPath , {
434+ meta : { lastTouchedVersion : "2026.5.28" } ,
435+ } ) ;
436+
437+ const config = io . loadConfig ( ) ;
438+
439+ expect ( config . gateway ?. mode ) . toBe ( "local" ) ;
440+ expectWarnContaining ( warn , "Config auto-restored from backup:" ) ;
441+ } ) ;
442+ } ) ;
443+
444+ it ( "loadConfig clears env vars from the discarded clobbered config before rereading backup" , async ( ) => {
445+ await withSuiteHome ( async ( home ) => {
446+ const env = { } as NodeJS . ProcessEnv ;
447+ const { io, configPath } = createTestConfigIO ( home , vi . fn ( ) , { env } ) ;
448+ await seedConfigBackup ( configPath , recoverableTelegramConfig ) ;
449+ await writeConfigRaw ( configPath , {
450+ meta : { lastTouchedVersion : "2026.5.28" } ,
451+ env : { vars : { OPENCLAW_CLOBBER_ONLY : "bad" } } ,
452+ } ) ;
453+
454+ const config = io . loadConfig ( ) ;
455+
456+ expect ( config . gateway ?. mode ) . toBe ( "local" ) ;
457+ expect ( env . OPENCLAW_CLOBBER_ONLY ) . toBeUndefined ( ) ;
458+ } ) ;
459+ } ) ;
460+
461+ it ( "read snapshot recovery clears env vars from the discarded clobbered config" , async ( ) => {
462+ await withSuiteHome ( async ( home ) => {
463+ const env = { } as NodeJS . ProcessEnv ;
464+ const { io, configPath } = createTestConfigIO ( home , vi . fn ( ) , { env } ) ;
465+ await seedConfigBackup ( configPath , recoverableTelegramConfig ) ;
466+ await writeConfigRaw ( configPath , {
467+ meta : { lastTouchedVersion : "2026.5.28" } ,
468+ env : { vars : { OPENCLAW_CLOBBER_ONLY : "bad" } } ,
469+ } ) ;
470+
471+ const snapshot = await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
472+
473+ expect ( snapshot . config . gateway ?. mode ) . toBe ( "local" ) ;
474+ expect ( env . OPENCLAW_CLOBBER_ONLY ) . toBeUndefined ( ) ;
475+ } ) ;
476+ } ) ;
477+
478+ it ( "does not auto-restore read snapshots when observation is disabled" , async ( ) => {
479+ await withSuiteHome ( async ( home ) => {
480+ const { io, configPath } = createTestConfigIO ( home , vi . fn ( ) , { observe : false } ) ;
481+ const auditPath = path . join ( home , ".openclaw" , "logs" , "config-audit.jsonl" ) ;
482+ await seedConfigBackup ( configPath , recoverableTelegramConfig ) ;
483+ const clobbered = await writeConfigRaw ( configPath , {
484+ meta : { lastTouchedVersion : "2026.5.28" } ,
485+ } ) ;
486+
487+ const snapshot = await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
488+
489+ expect ( snapshot . valid ) . toBe ( true ) ;
490+ expect ( snapshot . config . gateway ?. mode ) . toBeUndefined ( ) ;
491+ await expect ( fsp . readFile ( configPath , "utf-8" ) ) . resolves . toBe ( clobbered . raw ) ;
492+ await expectPathMissing ( auditPath ) ;
493+ } ) ;
494+ } ) ;
495+
496+ it ( "does not auto-restore include-authored roots from stale full-file backups" , async ( ) => {
497+ await withSuiteHome ( async ( home ) => {
498+ const { io, configPath } = createTestConfigIO ( home ) ;
499+ const auditPath = path . join ( home , ".openclaw" , "logs" , "config-audit.jsonl" ) ;
500+ const includedConfig = {
501+ ...recoverableTelegramConfig ,
502+ channels : {
503+ telegram : {
504+ enabled : true ,
505+ dmPolicy : "pairing" ,
506+ groupPolicy : "allowlist" ,
507+ allowFrom : Array . from ( { length : 60 } , ( _ , index ) => `telegram-user-${ index } ` ) ,
508+ } ,
509+ } ,
510+ } ;
511+ await seedConfigBackup ( configPath , includedConfig ) ;
512+ await fsp . writeFile (
513+ path . join ( path . dirname ( configPath ) , "base.json5" ) ,
514+ `${ JSON . stringify ( includedConfig , null , 2 ) } \n` ,
515+ "utf-8" ,
516+ ) ;
517+ const includeRootRaw = `{\n "$include": "./base.json5"\n}\n` ;
518+ await fsp . writeFile ( configPath , includeRootRaw , "utf-8" ) ;
519+
520+ const snapshot = await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
521+
522+ expect ( snapshot . valid ) . toBe ( true ) ;
523+ expect ( snapshot . config . gateway ?. mode ) . toBe ( "local" ) ;
524+ await expect ( fsp . readFile ( configPath , "utf-8" ) ) . resolves . toBe ( includeRootRaw ) ;
525+ const observe = await readLastObserveEvent ( auditPath ) ;
526+ expect ( observe ?. restoredFromBackup ) . toBe ( false ) ;
527+ } ) ;
528+ } ) ;
529+
530+ it ( "does not auto-restore invalid backup candidates during opted-in reads" , async ( ) => {
531+ await withSuiteHome ( async ( home ) => {
532+ const { io, configPath } = createTestConfigIO ( home ) ;
533+ await seedConfigBackup ( configPath , {
534+ gateway : { mode : "local" } ,
535+ agents : { defaults : { model : 123 } } ,
536+ } ) ;
537+ const clobbered = await writeConfigRaw ( configPath , {
538+ meta : { lastTouchedVersion : "2026.5.28" } ,
539+ } ) ;
540+
541+ const snapshot = await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
542+
543+ expect ( snapshot . valid ) . toBe ( true ) ;
544+ expect ( snapshot . config . gateway ?. mode ) . toBeUndefined ( ) ;
545+ await expect ( fsp . readFile ( configPath , "utf-8" ) ) . resolves . toBe ( clobbered . raw ) ;
546+ await expect ( listClobberFiles ( configPath ) ) . resolves . toHaveLength ( 0 ) ;
547+ } ) ;
548+ } ) ;
549+
550+ it ( "validates backup candidates without leaking their env into live state" , async ( ) => {
551+ await withSuiteHome ( async ( home ) => {
552+ const env = { } as NodeJS . ProcessEnv ;
553+ const { io, configPath } = createTestConfigIO ( home , vi . fn ( ) , { env } ) ;
554+ await seedConfigBackup ( configPath , {
555+ gateway : { mode : "local" } ,
556+ env : { vars : { OPENCLAW_BACKUP_ONLY : "stale" } } ,
557+ agents : { defaults : { model : 123 } } ,
558+ } ) ;
559+ await writeConfigRaw ( configPath , {
560+ meta : { lastTouchedVersion : "2026.5.28" } ,
561+ } ) ;
562+
563+ await io . readConfigFileSnapshot ( { recoverSuspicious : true } ) ;
564+
565+ expect ( env . OPENCLAW_BACKUP_ONLY ) . toBeUndefined ( ) ;
566+ } ) ;
567+ } ) ;
568+
371569 it ( "does not restore noncritical config edits" , async ( ) => {
372570 await withSuiteHome ( async ( home ) => {
373571 const { deps, configPath, auditPath } = makeDeps ( home ) ;
0 commit comments