diff --git a/ui/src/components/config-form-scalar-integrity.browser.test.ts b/ui/src/components/config-form-scalar-integrity.browser.test.ts index b6cb2d0d3d0d..c777014decfc 100644 --- a/ui/src/components/config-form-scalar-integrity.browser.test.ts +++ b/ui/src/components/config-form-scalar-integrity.browser.test.ts @@ -355,4 +355,35 @@ describe("config form scalar integrity", () => { expect(reset.disabled).toBe(true); expect(container.textContent).not.toContain("inherited"); }); + + it("never reveals a server-redacted sentinel and keeps the input readonly", () => { + const container = document.createElement("div"); + + render( + renderTextInput({ + schema: { type: "string" }, + value: "__OPENCLAW_REDACTED__", + path: ["secret"], + hints: { secret: { sensitive: true } }, + unsupported: new Set(), + disabled: false, + inputType: "text", + // Even with reveal forced on, the sentinel is not the stored value; + // showing it editable would let a stray edit overwrite the credential. + revealSensitive: true, + onPatch: vi.fn(), + onRemove: vi.fn(), + }), + container, + ); + + const input = expectElement( + container.querySelector("input"), + "sentinel secret input", + ); + expect(input.value).not.toContain("__OPENCLAW_REDACTED__"); + expect(input.readOnly).toBe(true); + const eye = container.querySelector(".settings-secret__toggle"); + expect(eye?.disabled ?? true).toBe(true); + }); }); diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 7331a944a185..9b425f4336e8 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -4,8 +4,9 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import type { ConfigUiHints } from "../api/types.ts"; import { icons } from "../components/icons.ts"; -import "../components/tooltip.ts"; import { t } from "../i18n/index.ts"; +import "../components/tooltip.ts"; +import { REDACTED_SENTINEL } from "../lib/config-form-utils.ts"; import { formatUnknownText } from "../lib/format.ts"; import { isSupportedConfigValueValid } from "./config-form.constraints.ts"; import type { ConfigSearchCriteria } from "./config-form.search.ts"; @@ -64,6 +65,7 @@ type SensitiveRenderState = { isRedacted: boolean; isRevealed: boolean; canReveal: boolean; + sentinelRedacted: boolean; }; export function isAnySchema(schema: JsonSchema): boolean { @@ -130,14 +132,20 @@ export function getSensitiveRenderState(params: { isSensitivePathRevealed?: (path: Array) => boolean; }): SensitiveRenderState { const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints); + // The server never sends plaintext secrets: a stored secret arrives as the + // redaction sentinel. Revealing it would display the sentinel as an editable + // value; any edit then overwrites the real credential with mangled text. + const sentinel = params.value === REDACTED_SENTINEL; const isRevealed = isSensitive && + !sentinel && (params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false)); return { isSensitive, isRedacted: isSensitive && !isRevealed, isRevealed, - canReveal: isSensitive, + canReveal: isSensitive && !sentinel, + sentinelRedacted: sentinel, }; } @@ -155,7 +163,9 @@ export function renderSensitiveToggleButton(params: { ? state.isRevealed ? t("configForm.hideValue") : t("configForm.revealValue") - : t("configForm.disableStreamToReveal"); + : state.sentinelRedacted + ? t("configForm.storedSecretNotRevealable") + : t("configForm.disableStreamToReveal"); return html`