diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4fe623545..c110e2f612f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k. - Discord: avoid misrouting numeric guild allowlist entries to `/channels/` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim. - Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow. +- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers. - Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow. - Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42. - Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293. diff --git a/src/config/io.ts b/src/config/io.ts index 184f73942aa..26d812d1469 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -725,6 +725,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // Do NOT apply runtime defaults when writing — user config should only contain // explicitly set values. Runtime defaults are applied when loading (issue #6070). const json = JSON.stringify(stampConfigVersion(outputConfig), null, 2).trimEnd().concat("\n"); + const nextHash = hashConfigRaw(json); + const previousHash = resolveConfigSnapshotHash(snapshot); + const changedPathCount = changedPaths?.size; + const logConfigOverwrite = () => { + if (!snapshot.exists) { + return; + } + const changeSummary = + typeof changedPathCount === "number" ? `, changedPaths=${changedPathCount}` : ""; + deps.logger.warn( + `Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`, + ); + }; const tmp = path.join( dir, @@ -756,6 +769,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { await deps.fs.promises.unlink(tmp).catch(() => { // best-effort }); + logConfigOverwrite(); return; } await deps.fs.promises.unlink(tmp).catch(() => { @@ -763,6 +777,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }); throw err; } + logConfigOverwrite(); } return { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 2aa85b20d46..917a3f3f009 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; import { withTempHome } from "./test-helpers.js"; @@ -174,4 +174,66 @@ describe("config io write", () => { ]); }); }); + + it("logs an overwrite audit entry when replacing an existing config file", async () => { + await withTempHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { port: 18789 } }, null, 2), + "utf-8", + ); + const warn = vi.fn(); + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: { + warn, + error: vi.fn(), + }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(true); + const next = structuredClone(snapshot.config); + next.gateway = { + ...next.gateway, + auth: { mode: "token" }, + }; + + await io.writeConfigFile(next); + + const overwriteLog = warn.mock.calls + .map((call) => call[0]) + .find((entry) => typeof entry === "string" && entry.startsWith("Config overwrite:")); + expect(typeof overwriteLog).toBe("string"); + expect(overwriteLog).toContain(configPath); + expect(overwriteLog).toContain(`${configPath}.bak`); + expect(overwriteLog).toContain("sha256"); + }); + }); + + it("does not log an overwrite audit entry when creating config for the first time", async () => { + await withTempHome(async (home) => { + const warn = vi.fn(); + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: { + warn, + error: vi.fn(), + }, + }); + + await io.writeConfigFile({ + gateway: { mode: "local" }, + }); + + const overwriteLogs = warn.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].startsWith("Config overwrite:"), + ); + expect(overwriteLogs).toHaveLength(0); + }); + }); });