diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 2911f309144..cec50573a22 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -1,3 +1,4 @@ +import JSON5 from "json5"; import { describe, expect, it } from "vitest"; import { REDACTED_SENTINEL, @@ -254,6 +255,27 @@ describe("redactConfigSnapshot", () => { expect(result.raw).toContain(REDACTED_SENTINEL); }); + it("keeps non-sensitive raw fields intact when secret values overlap", () => { + const config = { + gateway: { + mode: "local", + auth: { password: "local" }, + }, + }; + const snapshot = makeSnapshot(config, JSON.stringify(config)); + + const result = redactConfigSnapshot(snapshot); + const parsed: { + gateway?: { mode?: string; auth?: { password?: string } }; + } = JSON5.parse(result.raw ?? "{}"); + expect(parsed.gateway?.mode).toBe("local"); + expect(parsed.gateway?.auth?.password).toBe(REDACTED_SENTINEL); + + const restored = restoreRedactedValues(parsed, snapshot.config, mainSchemaHints); + expect(restored.gateway.mode).toBe("local"); + expect(restored.gateway.auth.password).toBe("local"); + }); + it("redacts parsed and resolved objects", () => { const snapshot = makeSnapshot({ channels: { discord: { token: "MTIzNDU2Nzg5MDEyMzQ1Njc4.GaBcDe.FgH" } }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index b9ebeac84bf..eea34c20b8f 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,3 +1,4 @@ +import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isSensitiveConfigPath, type ConfigUiHints } from "./schema.hints.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; @@ -294,6 +295,23 @@ function redactRawText(raw: string, config: unknown, hints?: ConfigUiHints): str return result; } +function shouldFallbackToStructuredRawRedaction(params: { + redactedRaw: string; + originalConfig: unknown; + hints?: ConfigUiHints; +}): boolean { + try { + const parsed = JSON5.parse(params.redactedRaw); + const restored = restoreRedactedValues(parsed, params.originalConfig, params.hints); + if (!restored.ok) { + return true; + } + return JSON.stringify(restored.result) !== JSON.stringify(params.originalConfig); + } catch { + return true; + } +} + /** * Returns a copy of the config snapshot with all sensitive fields * replaced by {@link REDACTED_SENTINEL}. The `hash` is preserved @@ -338,8 +356,18 @@ export function redactConfigSnapshot( // readConfigFileSnapshot() does when it creates the snapshot. const redactedConfig = redactObject(snapshot.config, uiHints) as ConfigFileSnapshot["config"]; - const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null; const redactedParsed = snapshot.parsed ? redactObject(snapshot.parsed, uiHints) : snapshot.parsed; + let redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null; + if ( + redactedRaw && + shouldFallbackToStructuredRawRedaction({ + redactedRaw, + originalConfig: snapshot.config, + hints: uiHints, + }) + ) { + redactedRaw = JSON5.stringify(redactedParsed ?? redactedConfig, null, 2); + } // Also redact the resolved config (contains values after ${ENV} substitution) const redactedResolved = redactConfigObject(snapshot.resolved, uiHints);