diff --git a/src/config/io.ts b/src/config/io.ts index fba17f253aa..9f7183e072e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1,10 +1,11 @@ +import JSON5 from "json5"; +import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; +import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; -import JSON5 from "json5"; -import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { @@ -47,8 +48,8 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin. import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; +import { REDACTED_SENTINEL } from "./redact-snapshot.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; -import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectRawWithPlugins, validateConfigObjectWithPlugins, @@ -1188,6 +1189,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // explicitly set values. Runtime defaults are applied when loading (issue #6070). const stampedOutputConfig = stampConfigVersion(outputConfig); const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n"); + + if (json.includes(REDACTED_SENTINEL)) { + deps.logger.error( + `Config write blocked: redaction sentinel "${REDACTED_SENTINEL}" found in output — ` + + `writing would destroy credentials. Config file was NOT modified.`, + ); + throw new Error( + `Refusing to write config: found redaction sentinel "${REDACTED_SENTINEL}". ` + + `This is a bug — credentials would be permanently lost. ` + + `The config file on disk was not changed.`, + ); + } + const nextHash = hashConfigRaw(json); const previousHash = resolveConfigSnapshotHash(snapshot); const changedPathCount = changedPaths?.size; diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 68709725d83..5d00d54c40e 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; import type { OpenClawConfig } from "./types.js"; +import { REDACTED_SENTINEL } from "./redact-snapshot.js"; describe("config io write", () => { let fixtureRoot = ""; @@ -548,4 +549,35 @@ describe("config io write", () => { expect(last.watchCommand).toBe("gateway --force"); }); }); + + it("refuses to write config containing redaction sentinel (issue #18102)", async () => { + await withSuiteHome(async (home) => { + const initialConfig = { + gateway: { port: 18789 }, + channels: { telegram: { botToken: "real-bot-token-123" } }, + }; + const errorFn = vi.fn(); + const { configPath, io, snapshot } = await writeConfigAndCreateIo({ + home, + initialConfig, + logger: { warn: vi.fn(), error: errorFn }, + }); + + const poisoned = structuredClone(snapshot.config); + (poisoned as Record).channels = { + ...poisoned.channels, + telegram: { + ...poisoned.channels?.telegram, + botToken: REDACTED_SENTINEL, + }, + }; + + await expect(io.writeConfigFile(poisoned)).rejects.toThrow(/redaction sentinel/i); + + const ondisk = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + expect(JSON.stringify(ondisk)).not.toContain(REDACTED_SENTINEL); + expect(ondisk).toEqual(initialConfig); + expect(errorFn).toHaveBeenCalled(); + }); + }); });