fix(config): prevent synthetic raw content in redaction fallback

When text-level config redaction cannot round-trip cleanly (e.g. a
sensitive value like "local" also appears in non-sensitive fields),
the fallback previously generated a synthetic raw string from the
runtime-defaulted config object. This synthetic content diverges from
the on-disk file (runtime defaults injected, $include flattened,
comments stripped) and fails config.set validation in the Control UI.

Set raw to null when the fallback triggers, forcing the UI into
form-only editing mode. The UI already handles raw: null gracefully
via applyConfigSnapshot() in the config controller.

Closes #48415

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cypherm 2026-03-21 10:36:00 +08:00
parent a4a5ed8948
commit 202c7de3d1
2 changed files with 28 additions and 19 deletions

View File

@ -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<string, unknown>;
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", () => {

View File

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