Skip to content

Commit c8d21fe

Browse files
authored
fix: recover suspicious gateway startup configs (#89480)
1 parent 00d846d commit c8d21fe

4 files changed

Lines changed: 352 additions & 9 deletions

File tree

src/cli/gateway-cli/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ async function readGatewayStartupConfig(params: {
294294
const { readConfigFileSnapshotWithPluginMetadata } = await import("../../config/config.js");
295295
const snapshotRead: ReadConfigFileSnapshotWithPluginMetadataResult | null =
296296
await params.startupTrace.measure("cli.config-snapshot", () =>
297-
readConfigFileSnapshotWithPluginMetadata().catch(() => null),
297+
readConfigFileSnapshotWithPluginMetadata({ recoverSuspicious: true }).catch(() => null),
298298
);
299299
const snapshot: ConfigFileSnapshot | null = snapshotRead?.snapshot ?? null;
300300
const cfg = snapshot?.config ?? {};

src/config/io.observe-recovery.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "node:path";
55
import JSON5 from "json5";
66
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
77
import { CONFIG_CLOBBER_SNAPSHOT_LIMIT } from "./io.clobber-snapshot.js";
8+
import { createConfigIO } from "./io.js";
89
import {
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], /^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+
371569
it("does not restore noncritical config edits", async () => {
372570
await withSuiteHome(async (home) => {
373571
const { deps, configPath, auditPath } = makeDeps(home);

src/config/io.observe-recovery.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ type ConfigReadRecoveryParams = {
131131
configPath: string;
132132
raw: string;
133133
parsed: unknown;
134+
validateBackup?: (backup: { raw: string; parsed: unknown }) => Promise<boolean>;
135+
validateBackupSync?: (backup: { raw: string; parsed: unknown }) => boolean;
134136
};
135137

136138
type ConfigReadRecoveryResult = {
@@ -710,6 +712,12 @@ export async function maybeRecoverSuspiciousConfigRead(
710712
if (!backupParse) {
711713
return returnOriginalConfigRead(params);
712714
}
715+
if (
716+
params.validateBackup &&
717+
!(await params.validateBackup({ raw: backupRaw, parsed: backupParse.parsed }))
718+
) {
719+
return returnOriginalConfigRead(params);
720+
}
713721
const backup = backupBaseline ?? (await readConfigFingerprintForPath(params.deps, backupPath));
714722
if (!backup?.gatewayMode) {
715723
return returnOriginalConfigRead(params);
@@ -811,6 +819,12 @@ export function maybeRecoverSuspiciousConfigReadSync(
811819
if (!backupParse) {
812820
return returnOriginalConfigRead(params);
813821
}
822+
if (
823+
params.validateBackupSync &&
824+
!params.validateBackupSync({ raw: backupRaw, parsed: backupParse.parsed })
825+
) {
826+
return returnOriginalConfigRead(params);
827+
}
814828
const backup = backupBaseline ?? readConfigFingerprintForPathSync(params.deps, backupPath);
815829
if (!backup?.gatewayMode) {
816830
return returnOriginalConfigRead(params);

0 commit comments

Comments
 (0)