

























@@ -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 {
910maybeRecoverSuspiciousConfigRead,
1011maybeRecoverSuspiciousConfigReadSync,
@@ -135,6 +136,29 @@ describe("config observe recovery", () => {
135136return (await readObserveEvents(auditPath)).at(-1);
136137}
137138139+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+138162async function recoverClobberedUpdateChannel(params: {
139163deps: ObserveRecoveryDeps;
140164configPath: string;
@@ -368,6 +392,180 @@ describe("config observe recovery", () => {
368392});
369393});
370394395+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], /^size-drop-vs-last-good:/);
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+371569it("does not restore noncritical config edits", async () => {
372570await withSuiteHome(async (home) => {
373571const { deps, configPath, auditPath } = makeDeps(home);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。