fix(ui): revealing a stored secret displayed the redaction sentinel as an editable value (#123493)

config.get never sends plaintext secrets — stored values arrive as the
__OPENCLAW_REDACTED__ sentinel. The reveal eye had no sentinel awareness:
clicking it showed the literal sentinel in a now-editable input, and any
edit broke the exact-match restore so the mangled text was written over
the real credential with a green Saved. Sentinel values are now never
revealable (input stays readonly) and the disabled eye explains why.
This commit is contained in:
Peter Steinberger
2026-08-14 02:27:06 -07:00
committed by GitHub
parent fc87337c14
commit 06e23130cb
4 changed files with 47 additions and 4 deletions
@@ -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<HTMLInputElement>("input"),
"sentinel secret input",
);
expect(input.value).not.toContain("__OPENCLAW_REDACTED__");
expect(input.readOnly).toBe(true);
const eye = container.querySelector<HTMLButtonElement>(".settings-secret__toggle");
expect(eye?.disabled ?? true).toBe(true);
});
});
+13 -3
View File
@@ -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<string | number>) => 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`
<openclaw-tooltip .content=${label}>
<button
+2
View File
@@ -1244,6 +1244,8 @@ export const en: TranslationMap = {
hideValue: "Hide value",
revealValue: "Reveal value",
disableStreamToReveal: "Disable stream mode to reveal value",
storedSecretNotRevealable:
"Stored secrets are never sent to the browser; enter a new value to replace it",
unsupportedType: "Unsupported type: {type}. Use Raw mode.",
structuredSecretRaw: "Structured value (SecretRef) - use Raw mode to edit",
structuredSecretFile: "Structured value (SecretRef) - edit the config file directly",
+1 -1
View File
@@ -9,7 +9,7 @@ export function serializeConfigForm(form: Record<string, unknown>): string {
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
}
const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
type SanitizeResult = { omitted: true } | { omitted: false; value: unknown };
const OMIT_VALUE: SanitizeResult = { omitted: true };