From 0d6b61bfff9d38a364b2585ec1a7af85c11ea5ff Mon Sep 17 00:00:00 2001 From: core-be Date: Sun, 17 May 2026 07:25:23 -0700 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20make=20Settings=E2=86=92Secrets?= =?UTF-8?q?=20reveal=20honest=20(value=20is=20write-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eye/RevealToggle in SecretRow was a dead affordance: it flipped a local `revealed` boolean but the row always rendered `masked_value` and never consumed it, so nothing was ever revealed. RevealToggle renders an eye-WITH-SLASH when revealed=true, so a clicked row looked "active" while showing nothing — read by users as "this doesnt work" (reported on CLAUDE_CODE_OAUTH_TOKEN / Anthropic group). Root cause is not Anthropic/OAuth/category-specific and not a server 4xx/5xx: secret values are write-only from the browser by design — the server List handler "Never exposes values", there is no per-secret decrypt route, and the only decrypted path (GET /secrets/values) is bulk + token-gated for remote agents and never called by canvas. The client has no plaintext-fetch function. Reveal is architecturally impossible without a deliberate security regression (out of scope). Fix: remove the dead toggle (+ its local state / auto-hide effect) and show a static write-only indicator (lock + explanatory title). Edit (rotate/replace) and Delete are unaffected and independent of reveal. Refs: internal#490; sibling Secrets/Tokens fixes PR #1415 + #1420 (referenced in triage as internal#210 / internal#211). Does not touch the agent-error path (internal#212). Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/settings/SecretRow.tsx | 42 ++++++++---------- .../settings/__tests__/SecretRow.test.tsx | 44 ++++++++++++++++++- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/canvas/src/components/settings/SecretRow.tsx b/canvas/src/components/settings/SecretRow.tsx index 92bb633ea..10339e869 100644 --- a/canvas/src/components/settings/SecretRow.tsx +++ b/canvas/src/components/settings/SecretRow.tsx @@ -3,16 +3,24 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import type { Secret, SecretGroup } from '@/types/secrets'; import { useSecretsStore } from '@/stores/secrets-store'; import { StatusBadge } from '@/components/ui/StatusBadge'; -import { RevealToggle } from '@/components/ui/RevealToggle'; import { KeyValueField } from '@/components/ui/KeyValueField'; import { ValidationHint } from '@/components/ui/ValidationHint'; import { TestConnectionButton } from '@/components/ui/TestConnectionButton'; import { validateSecretValue } from '@/lib/validation/secret-formats'; import { SERVICES } from '@/lib/services'; -const AUTO_HIDE_MS = 30_000; const VALIDATION_DEBOUNCE_MS = 400; +// Secret values are write-only from the browser: the server List endpoint +// "Never exposes values", there is no per-secret decrypt route, and the +// only decrypted path (GET /secrets/values) is bulk + token-gated for +// remote agents. The old eye/RevealToggle was a dead affordance — it +// flipped its own icon but could never reveal anything, which read as +// "this doesn't work" (esp. once clicked → eye-with-slash). We show an +// honest static indicator instead; rotation is via Edit. +const WRITE_ONLY_TITLE = + 'Value is write-only and cannot be revealed — use Edit to replace/rotate it'; + interface SecretRowProps { secret: Secret; workspaceId: string; @@ -31,28 +39,12 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) { const setSecretStatus = useSecretsStore((s) => s.setSecretStatus); const isEditing = editingKey === secret.name; - const [revealed, setRevealed] = useState(false); const [editValue, setEditValue] = useState(''); const [validationError, setValidationError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const debounceRef = useRef>(undefined); const editBtnRef = useRef(null); - const revealTimerRef = useRef>(undefined); - - // Auto-hide revealed value after 30s - useEffect(() => { - if (revealed) { - clearTimeout(revealTimerRef.current); - revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS); - return () => clearTimeout(revealTimerRef.current); - } - }, [revealed]); - - // Reset revealed state when panel closes (session-only) - useEffect(() => { - return () => setRevealed(false); - }, []); // Debounced validation useEffect(() => { @@ -133,11 +125,15 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) { {secret.masked_value}
- setRevealed((r) => !r)} - label={`Toggle reveal ${secret.name}`} - /> + + 🔒 +