From 809833ef9d1d692fd192903c2182175ee2c27ca4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 00:05:06 +0100 Subject: [PATCH] fix(config): recover clobbered config and isolate test paths --- src/config/io.observe-config.test.ts | 24 +- src/config/io.ts | 331 ++++++++++++++++-- .../server.roles-allowlist-update.test.ts | 14 +- 3 files changed, 339 insertions(+), 30 deletions(-) diff --git a/src/config/io.observe-config.test.ts b/src/config/io.observe-config.test.ts index 14ec6d718e1..865e531f60e 100644 --- a/src/config/io.observe-config.test.ts +++ b/src/config/io.observe-config.test.ts @@ -36,7 +36,7 @@ describe("config io observe", () => { return { io, configPath, auditPath, warn, error }; } - it("records forensic audit for suspicious out-of-band config clobbers", async () => { + it("auto-restores from backup for suspicious update-channel-only root clobbers", async () => { await withSuiteHome(async (home) => { const { io, configPath, auditPath, warn } = await makeIo(home); @@ -57,6 +57,7 @@ describe("config io observe", () => { const seeded = await io.readConfigFileSnapshot(); expect(seeded.valid).toBe(true); + await fs.copyFile(configPath, `${configPath}.bak`); const clobberedRaw = `${JSON.stringify({ update: { channel: "beta" } }, null, 2)}\n`; await fs.writeFile(configPath, clobberedRaw, "utf-8"); @@ -64,7 +65,8 @@ describe("config io observe", () => { const snapshot = await io.readConfigFileSnapshot(); expect(snapshot.valid).toBe(true); expect(snapshot.config.update?.channel).toBe("beta"); - expect(snapshot.config.gateway?.mode).toBeUndefined(); + expect(snapshot.config.gateway?.mode).toBe("local"); + await expect(fs.readFile(configPath, "utf-8")).resolves.not.toBe(clobberedRaw); const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean); const observe = lines @@ -84,13 +86,17 @@ describe("config io observe", () => { expect.arrayContaining(["gateway-mode-missing-vs-last-good", "update-channel-only-root"]), ); expect(observe?.clobberedPath).toBeTypeOf("string"); + expect(observe?.restoredFromBackup).toBe(true); await expect(fs.readFile(String(observe?.clobberedPath), "utf-8")).resolves.toBe( clobberedRaw, ); const anomalyLog = warn.mock.calls .map((call) => call[0]) - .find((entry) => typeof entry === "string" && entry.startsWith("Config observe anomaly:")); + .find( + (entry) => + typeof entry === "string" && entry.startsWith("Config auto-restored from backup:"), + ); expect(anomalyLog).toContain(configPath); }); }); @@ -111,6 +117,7 @@ describe("config io observe", () => { }, }); await io.readConfigFileSnapshot(); + await fs.copyFile(configPath, `${configPath}.bak`); await fs.writeFile( configPath, @@ -127,10 +134,11 @@ describe("config io observe", () => { .filter((line) => line.event === "config.observe"); expect(observeEvents).toHaveLength(1); + expect(observeEvents[0]?.restoredFromBackup).toBe(true); }); }); - it("records forensic audit from loadConfig when only the backup file provides the baseline", async () => { + it("loadConfig auto-restores from backup when only the backup file provides the baseline", async () => { await withSuiteHome(async (home) => { const { io, configPath, auditPath, warn } = await makeIo(home); @@ -151,7 +159,7 @@ describe("config io observe", () => { await fs.writeFile(configPath, clobberedRaw, "utf-8"); const loaded = io.loadConfig(); - expect(loaded.gateway?.mode).toBeUndefined(); + expect(loaded.gateway?.mode).toBe("local"); const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean); const observe = lines @@ -166,10 +174,14 @@ describe("config io observe", () => { expect(observe?.suspicious).toEqual( expect.arrayContaining(["gateway-mode-missing-vs-last-good", "update-channel-only-root"]), ); + expect(observe?.restoredFromBackup).toBe(true); const anomalyLog = warn.mock.calls .map((call) => call[0]) - .find((entry) => typeof entry === "string" && entry.startsWith("Config observe anomaly:")); + .find( + (entry) => + typeof entry === "string" && entry.startsWith("Config auto-restored from backup:"), + ); expect(anomalyLog).toContain(configPath); }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 606cbef5482..1139169e3a3 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -206,6 +206,8 @@ type ConfigObserveAuditRecord = { backupGid: number | null; backupGatewayMode: string | null; clobberedPath: string | null; + restoredFromBackup: boolean; + restoredBackupPath: string | null; }; type ConfigAuditRecord = ConfigWriteAuditRecord | ConfigObserveAuditRecord; @@ -933,6 +935,271 @@ function persistClobberedConfigSnapshotSync(params: { } } +type SuspiciousConfigRecoverySyncResult = { + raw: string; + parsed: unknown; +}; + +async function maybeRecoverSuspiciousConfigRead(params: { + deps: Required; + configPath: string; + raw: string; + parsed: unknown; +}): Promise<{ raw: string; parsed: unknown }> { + const stat = await params.deps.fs.promises.stat(params.configPath).catch(() => null); + const now = new Date().toISOString(); + const current: ConfigHealthFingerprint = { + hash: hashConfigRaw(params.raw), + bytes: Buffer.byteLength(params.raw, "utf-8"), + mtimeMs: stat?.mtimeMs ?? null, + ctimeMs: stat?.ctimeMs ?? null, + ...resolveConfigStatMetadata(stat), + hasMeta: hasConfigMeta(params.parsed), + gatewayMode: resolveGatewayMode(params.parsed), + observedAt: now, + }; + + let healthState = await readConfigHealthState(params.deps); + const entry = getConfigHealthEntry(healthState, params.configPath); + const backupPath = `${params.configPath}.bak`; + const backupBaseline = + entry.lastKnownGood ?? + (await readConfigFingerprintForPath(params.deps, backupPath)) ?? + undefined; + const suspicious = resolveConfigObserveSuspiciousReasons({ + bytes: current.bytes, + hasMeta: current.hasMeta, + gatewayMode: current.gatewayMode, + parsed: params.parsed, + lastKnownGood: backupBaseline, + }); + if (!suspicious.includes("update-channel-only-root")) { + return { raw: params.raw, parsed: params.parsed }; + } + + const suspiciousSignature = `${current.hash}:${suspicious.join(",")}`; + const backupRaw = await params.deps.fs.promises.readFile(backupPath, "utf-8").catch(() => null); + if (!backupRaw) { + return { raw: params.raw, parsed: params.parsed }; + } + const backupParsedRes = parseConfigJson5(backupRaw, params.deps.json5); + if (!backupParsedRes.ok) { + return { raw: params.raw, parsed: params.parsed }; + } + const backup = backupBaseline ?? (await readConfigFingerprintForPath(params.deps, backupPath)); + if (!backup?.gatewayMode) { + return { raw: params.raw, parsed: params.parsed }; + } + + const clobberedPath = await persistClobberedConfigSnapshot({ + deps: params.deps, + configPath: params.configPath, + raw: params.raw, + observedAt: now, + }); + + let restoredFromBackup = false; + try { + await params.deps.fs.promises.copyFile(backupPath, params.configPath); + restoredFromBackup = true; + } catch { + // Keep serving the backup payload for this read even if write-back fails. + } + + params.deps.logger.warn( + `Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`, + ); + await appendConfigAuditRecord(params.deps, { + ts: now, + source: "config-io", + event: "config.observe", + phase: "read", + configPath: params.configPath, + pid: process.pid, + ppid: process.ppid, + cwd: process.cwd(), + argv: process.argv.slice(0, 8), + execArgv: process.execArgv.slice(0, 8), + exists: true, + valid: true, + hash: current.hash, + bytes: current.bytes, + mtimeMs: current.mtimeMs, + ctimeMs: current.ctimeMs, + dev: current.dev, + ino: current.ino, + mode: current.mode, + nlink: current.nlink, + uid: current.uid, + gid: current.gid, + hasMeta: current.hasMeta, + gatewayMode: current.gatewayMode, + suspicious, + lastKnownGoodHash: entry.lastKnownGood?.hash ?? null, + lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null, + lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null, + lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null, + lastKnownGoodDev: entry.lastKnownGood?.dev ?? null, + lastKnownGoodIno: entry.lastKnownGood?.ino ?? null, + lastKnownGoodMode: entry.lastKnownGood?.mode ?? null, + lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null, + lastKnownGoodUid: entry.lastKnownGood?.uid ?? null, + lastKnownGoodGid: entry.lastKnownGood?.gid ?? null, + lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null, + backupHash: backup?.hash ?? null, + backupBytes: backup?.bytes ?? null, + backupMtimeMs: backup?.mtimeMs ?? null, + backupCtimeMs: backup?.ctimeMs ?? null, + backupDev: backup?.dev ?? null, + backupIno: backup?.ino ?? null, + backupMode: backup?.mode ?? null, + backupNlink: backup?.nlink ?? null, + backupUid: backup?.uid ?? null, + backupGid: backup?.gid ?? null, + backupGatewayMode: backup?.gatewayMode ?? null, + clobberedPath, + restoredFromBackup, + restoredBackupPath: backupPath, + }); + + healthState = setConfigHealthEntry(healthState, params.configPath, { + ...entry, + lastObservedSuspiciousSignature: suspiciousSignature, + }); + await writeConfigHealthState(params.deps, healthState); + return { raw: backupRaw, parsed: backupParsedRes.parsed }; +} + +function maybeRecoverSuspiciousConfigReadSync(params: { + deps: Required; + configPath: string; + raw: string; + parsed: unknown; +}): SuspiciousConfigRecoverySyncResult { + const stat = params.deps.fs.statSync(params.configPath, { throwIfNoEntry: false }) ?? null; + const now = new Date().toISOString(); + const current: ConfigHealthFingerprint = { + hash: hashConfigRaw(params.raw), + bytes: Buffer.byteLength(params.raw, "utf-8"), + mtimeMs: stat?.mtimeMs ?? null, + ctimeMs: stat?.ctimeMs ?? null, + ...resolveConfigStatMetadata(stat), + hasMeta: hasConfigMeta(params.parsed), + gatewayMode: resolveGatewayMode(params.parsed), + observedAt: now, + }; + + let healthState = readConfigHealthStateSync(params.deps); + const entry = getConfigHealthEntry(healthState, params.configPath); + const backupPath = `${params.configPath}.bak`; + const backupBaseline = + entry.lastKnownGood ?? readConfigFingerprintForPathSync(params.deps, backupPath) ?? undefined; + const suspicious = resolveConfigObserveSuspiciousReasons({ + bytes: current.bytes, + hasMeta: current.hasMeta, + gatewayMode: current.gatewayMode, + parsed: params.parsed, + lastKnownGood: backupBaseline, + }); + if (!suspicious.includes("update-channel-only-root")) { + return { raw: params.raw, parsed: params.parsed }; + } + + const suspiciousSignature = `${current.hash}:${suspicious.join(",")}`; + let backupRaw: string; + try { + backupRaw = params.deps.fs.readFileSync(backupPath, "utf-8"); + } catch { + return { raw: params.raw, parsed: params.parsed }; + } + const backupParsedRes = parseConfigJson5(backupRaw, params.deps.json5); + if (!backupParsedRes.ok) { + return { raw: params.raw, parsed: params.parsed }; + } + const backup = backupBaseline ?? readConfigFingerprintForPathSync(params.deps, backupPath); + if (!backup?.gatewayMode) { + return { raw: params.raw, parsed: params.parsed }; + } + + const clobberedPath = persistClobberedConfigSnapshotSync({ + deps: params.deps, + configPath: params.configPath, + raw: params.raw, + observedAt: now, + }); + + let restoredFromBackup = false; + try { + params.deps.fs.copyFileSync(backupPath, params.configPath); + restoredFromBackup = true; + } catch { + // Keep serving the backup payload for this read even if write-back fails. + } + + params.deps.logger.warn( + `Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`, + ); + appendConfigAuditRecordSync(params.deps, { + ts: now, + source: "config-io", + event: "config.observe", + phase: "read", + configPath: params.configPath, + pid: process.pid, + ppid: process.ppid, + cwd: process.cwd(), + argv: process.argv.slice(0, 8), + execArgv: process.execArgv.slice(0, 8), + exists: true, + valid: true, + hash: current.hash, + bytes: current.bytes, + mtimeMs: current.mtimeMs, + ctimeMs: current.ctimeMs, + dev: current.dev, + ino: current.ino, + mode: current.mode, + nlink: current.nlink, + uid: current.uid, + gid: current.gid, + hasMeta: current.hasMeta, + gatewayMode: current.gatewayMode, + suspicious, + lastKnownGoodHash: entry.lastKnownGood?.hash ?? null, + lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null, + lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null, + lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null, + lastKnownGoodDev: entry.lastKnownGood?.dev ?? null, + lastKnownGoodIno: entry.lastKnownGood?.ino ?? null, + lastKnownGoodMode: entry.lastKnownGood?.mode ?? null, + lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null, + lastKnownGoodUid: entry.lastKnownGood?.uid ?? null, + lastKnownGoodGid: entry.lastKnownGood?.gid ?? null, + lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null, + backupHash: backup?.hash ?? null, + backupBytes: backup?.bytes ?? null, + backupMtimeMs: backup?.mtimeMs ?? null, + backupCtimeMs: backup?.ctimeMs ?? null, + backupDev: backup?.dev ?? null, + backupIno: backup?.ino ?? null, + backupMode: backup?.mode ?? null, + backupNlink: backup?.nlink ?? null, + backupUid: backup?.uid ?? null, + backupGid: backup?.gid ?? null, + backupGatewayMode: backup?.gatewayMode ?? null, + clobberedPath, + restoredFromBackup, + restoredBackupPath: backupPath, + }); + + healthState = setConfigHealthEntry(healthState, params.configPath, { + ...entry, + lastObservedSuspiciousSignature: suspiciousSignature, + }); + writeConfigHealthStateSync(params.deps, healthState); + return { raw: backupRaw, parsed: backupParsedRes.parsed }; +} + function sameFingerprint( left: ConfigHealthFingerprint | undefined, right: ConfigHealthFingerprint, @@ -1073,6 +1340,8 @@ async function observeConfigSnapshot( backupGid: backup?.gid ?? null, backupGatewayMode: backup?.gatewayMode ?? null, clobberedPath, + restoredFromBackup: false, + restoredBackupPath: null, }); healthState = setConfigHealthEntry(healthState, snapshot.path, { @@ -1199,6 +1468,8 @@ function observeConfigSnapshotSync( backupGid: backup?.gid ?? null, backupGatewayMode: backup?.gatewayMode ?? null, clobberedPath, + restoredFromBackup: false, + restoredBackupPath: null, }); healthState = setConfigHealthEntry(healthState, snapshot.path, { @@ -1404,14 +1675,22 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { return {}; } const raw = deps.fs.readFileSync(configPath, "utf-8"); - const hash = hashConfigRaw(raw); const parsed = deps.json5.parse(raw); + const recovered = maybeRecoverSuspiciousConfigReadSync({ + deps, + configPath, + raw, + parsed, + }); + const effectiveRaw = recovered.raw; + const effectiveParsed = recovered.parsed; + const hash = hashConfigRaw(effectiveRaw); const readResolution = resolveConfigForRead( - resolveConfigIncludesForRead(parsed, configPath, deps), + resolveConfigIncludesForRead(effectiveParsed, configPath, deps), deps.env, ); const resolvedConfig = readResolution.resolvedConfigRaw; - const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, parsed); + const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, effectiveParsed); const effectiveConfigRaw = legacyResolution.effectiveConfigRaw; for (const w of readResolution.envWarnings) { deps.logger.warn( @@ -1423,8 +1702,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { observeLoadConfigSnapshot({ path: configPath, exists: true, - raw, - parsed, + raw: effectiveRaw, + parsed: effectiveParsed, resolved: {}, valid: true, config: {}, @@ -1447,8 +1726,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { observeLoadConfigSnapshot({ path: configPath, exists: true, - raw, - parsed, + raw: effectiveRaw, + parsed: effectiveParsed, resolved: coerceConfig(effectiveConfigRaw), valid: false, config: coerceConfig(effectiveConfigRaw), @@ -1498,8 +1777,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { observeLoadConfigSnapshot({ path: configPath, exists: true, - raw, - parsed, + raw: effectiveRaw, + parsed: effectiveParsed, resolved: coerceConfig(effectiveConfigRaw), valid: true, config: cfg, @@ -1617,7 +1896,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { try { const raw = deps.fs.readFileSync(configPath, "utf-8"); - const hash = hashConfigRaw(raw); + const rawHash = hashConfigRaw(raw); const parsedRes = parseConfigJson5(raw, deps.json5); if (!parsedRes.ok) { return await finalizeReadConfigSnapshotInternalResult(deps, { @@ -1629,7 +1908,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { resolved: {}, valid: false, config: {}, - hash, + hash: rawHash, issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], warnings: [], legacyIssues: [], @@ -1638,9 +1917,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } // Resolve $include directives + const recovered = await maybeRecoverSuspiciousConfigRead({ + deps, + configPath, + raw, + parsed: parsedRes.parsed, + }); + const effectiveRaw = recovered.raw; + const effectiveParsed = recovered.parsed; + const hash = hashConfigRaw(effectiveRaw); + let resolved: unknown; try { - resolved = resolveConfigIncludesForRead(parsedRes.parsed, configPath, deps); + resolved = resolveConfigIncludesForRead(effectiveParsed, configPath, deps); } catch (err) { const message = err instanceof ConfigIncludeError @@ -1650,11 +1939,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { snapshot: { path: configPath, exists: true, - raw, - parsed: parsedRes.parsed, - resolved: coerceConfig(parsedRes.parsed), + raw: effectiveRaw, + parsed: effectiveParsed, + resolved: coerceConfig(effectiveParsed), valid: false, - config: coerceConfig(parsedRes.parsed), + config: coerceConfig(effectiveParsed), hash, issues: [{ path: "", message }], warnings: [], @@ -1674,7 +1963,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { })); const resolvedConfigRaw = readResolution.resolvedConfigRaw; - const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, parsedRes.parsed); + const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, effectiveParsed); const effectiveConfigRaw = legacyResolution.effectiveConfigRaw; const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env }); @@ -1683,8 +1972,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { snapshot: { path: configPath, exists: true, - raw, - parsed: parsedRes.parsed, + raw: effectiveRaw, + parsed: effectiveParsed, resolved: coerceConfig(effectiveConfigRaw), valid: false, config: coerceConfig(effectiveConfigRaw), @@ -1713,8 +2002,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { snapshot: { path: configPath, exists: true, - raw, - parsed: parsedRes.parsed, + raw: effectiveRaw, + parsed: effectiveParsed, // Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults) // for config set/unset operations (issue #6070) resolved: coerceConfig(effectiveConfigRaw), diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index c5deeade7f6..e5d2632fa85 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; -import { CONFIG_PATH } from "../config/config.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; import { resolveRestartSentinelPath } from "../infra/restart-sentinel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -78,6 +77,14 @@ const approveAllPendingPairings = async () => { } }; +function getGatewayTestConfigPath(): string { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is required in the gateway test environment"); + } + return configPath; +} + const connectNodeClientWithPairing = async (params: Parameters[0]) => { try { return await connectNodeClient(params); @@ -171,8 +178,9 @@ describe("gateway update.run", () => { process.on("SIGUSR1", sigusr1); try { - await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); - await fs.writeFile(CONFIG_PATH, JSON.stringify({ update: { channel: "beta" } }, null, 2)); + const configPath = getGatewayTestConfigPath(); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ update: { channel: "beta" } }, null, 2)); const updateMock = vi.mocked(runGatewayUpdate); updateMock.mockClear();