fix(config): preserve unreadable write rejection for exported writes

This commit is contained in:
Ayaan Zaidi
2026-06-30 10:39:11 -07:00
parent c1605064d0
commit 68c533cfb3
2 changed files with 54 additions and 3 deletions

View File

@@ -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);
},
);
});

View File

@@ -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: [],