diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index dd754a44fac..4d52c26fbba 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -285,7 +285,7 @@ describe("redactConfigSnapshot", () => { expect(result.raw).toContain(REDACTED_SENTINEL); }); - it("keeps non-sensitive raw fields intact when secret values overlap", () => { + it("returns null raw when secret values overlap non-sensitive text (forces form-only mode)", () => { const config = { gateway: { mode: "local", @@ -294,14 +294,16 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot(config, JSON.stringify(config)); const result = redactConfigSnapshot(snapshot, mainSchemaHints); - const parsed: { + // When text-level redaction can't round-trip cleanly (overlap corrupts + // non-sensitive "mode" field), raw is set to null to force form-only mode + // in the UI, preventing the config.set validation failures from #48415. + expect(result.raw).toBeNull(); + // The redacted config and parsed objects are still available for form mode. + const redactedCfg = result.config as { 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"); + }; + expect(redactedCfg.gateway?.mode).toBe("local"); + expect(redactedCfg.gateway?.auth?.password).toBe(REDACTED_SENTINEL); }); it("preserves SecretRef structural fields while redacting SecretRef id", () => { @@ -327,7 +329,7 @@ describe("redactConfigSnapshot", () => { expect(restored).toEqual(snapshot.config); }); - it("handles overlap fallback and SecretRef in the same snapshot", () => { + it("returns null raw when overlap fallback triggers with SecretRef", () => { const config = { gateway: { mode: "default", auth: { password: "default" } }, // pragma: allowlist secret models: { @@ -341,14 +343,16 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot(config, JSON.stringify(config, null, 2)); const result = redactConfigSnapshot(snapshot, mainSchemaHints); - const parsed = JSON5.parse(result.raw ?? "{}"); - expect(parsed.gateway?.mode).toBe("default"); - expect(parsed.gateway?.auth?.password).toBe(REDACTED_SENTINEL); - expect(parsed.models?.providers?.default?.apiKey?.source).toBe("env"); - expect(parsed.models?.providers?.default?.apiKey?.provider).toBe("default"); - expect(result.raw).not.toContain("OPENAI_API_KEY"); - const restored = restoreRedactedValues(parsed, snapshot.config, mainSchemaHints); - expect(restored).toEqual(snapshot.config); + // Overlap fallback triggers because "default" appears in both password + // (sensitive) and mode/provider (non-sensitive). Raw is null to prevent + // synthetic content from failing config.set validation (#48415). + expect(result.raw).toBeNull(); + // Redacted config is still correct for form-mode editing. + const redactedCfg = result.config as Record; + expect((redactedCfg as { gateway?: { mode?: string } }).gateway?.mode).toBe("default"); + // SecretRef id is redacted in the config object. + expect(result.raw).toBeNull(); + expect(result.config).toBeDefined(); }); it("redacts parsed and resolved objects", () => { diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 7c4eb5e50c5..840c70230a0 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,4 +1,3 @@ -import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import { @@ -416,7 +415,13 @@ export function redactConfigSnapshot( ), }) ) { - redactedRaw = JSON5.stringify(redactedParsed ?? redactedConfig, null, 2); + // When the raw text cannot round-trip cleanly through redaction and restore, + // do not fall back to JSON5.stringify(redactedConfig) — that injects runtime + // defaults (model, session, logging) that were never in the on-disk file, + // producing a synthetic document that fails config.set validation (#48415). + // Setting raw to null forces the Control UI into form-only editing mode, + // which is already handled by applyConfigSnapshot() in the UI controller. + redactedRaw = null; } // Also redact the resolved config (contains values after ${ENV} substitution) const redactedResolved = redactConfigObject(snapshot.resolved, uiHints);