diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts index b2e90a4cd2f..5564da7a5d4 100644 --- a/src/config/io.eacces.test.ts +++ b/src/config/io.eacces.test.ts @@ -2,8 +2,9 @@ import fsNode from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createConfigIO } from "./io.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; +import { createConfigIO, resetConfigRuntimeState, writeConfigFile } from "./io.js"; import type { ConfigWriteOptions } from "./io.js"; import type { OpenClawConfig } from "./types.openclaw.js"; @@ -154,4 +155,49 @@ describe("config write guard after unreadable config", () => { expect(rejectedArtifacts).toHaveLength(1); }, ); + + it.skipIf(process.platform === "win32")( + "rejects exported writes before re-reading an unreadable base snapshot", + async () => { + const home = fsNode.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreadable-")); + tempRoots.push(home); + const stateDir = path.join(home, ".openclaw"); + fsNode.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const configPath = path.join(stateDir, "openclaw.json"); + const liveConfig = { + gateway: { mode: "local", port: 18789, auth: { mode: "token" } }, + meta: { lastTouchedVersion: "2026.5.3-1" }, + } satisfies OpenClawConfig; + const liveBytes = `${JSON.stringify(liveConfig, null, 2)}\n`; + fsNode.writeFileSync(configPath, liveBytes, { mode: 0o600 }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + fsNode.chmodSync(configPath, 0o000); + await withEnvAsync( + { OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_TEST_FAST: "1" }, + async () => { + await expect( + writeConfigFile({ channels: { telegram: { enabled: true } } }), + ).rejects.toMatchObject({ + code: "CONFIG_WRITE_REJECTED", + reasons: expect.arrayContaining(["unreadable-config-before-write"]), + }); + }, + ); + } finally { + resetConfigRuntimeState(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + fsNode.chmodSync(configPath, 0o600); + } + + expect(fsNode.readFileSync(configPath, "utf-8")).toBe(liveBytes); + const rejectedArtifacts = fsNode + .readdirSync(stateDir) + .filter((name) => name.startsWith("openclaw.json.rejected.")); + expect(rejectedArtifacts).toHaveLength(1); + }, + ); }); diff --git a/src/config/io.ts b/src/config/io.ts index 6c1135762df..c14f71da2cf 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -309,6 +309,11 @@ function assertBaseSnapshotStillCurrent( retryable: false, }); } + // Unreadable snapshots cannot be re-read for freshness; the write guard rejects + // them before commit unless the caller explicitly requests a destructive write. + if (snapshot.readError) { + return; + } const expectedHash = resolveConfigSnapshotHash(snapshot); let currentRaw: string | null = null; let currentExists = true; @@ -2128,7 +2133,7 @@ export function createConfigIO( valid: false, runtimeConfig: fallbackSourceConfig, hash: fallbackHash, - readError: { code: nodeErr?.code ?? null }, + ...(fallbackRaw === null ? { readError: { code: nodeErr?.code ?? null } } : {}), issues: [{ path: "", message }], warnings: [], legacyIssues: [],