From 299c50a1e0f749eb2c61bec313df0afbe0d294f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 17:02:56 +0800 Subject: [PATCH] fix: hide provider auth env secrets Provider settings JSON previously masked only values that matched the current form API key. Switching providers or editing stale settings could leave ANTHROPIC_API_KEY visible while ANTHROPIC_AUTH_TOKEN was masked, even though both are sensitive auth env vars. The settings preview now masks Anthropic API key and auth token fields by key name, restores placeholders from the previous JSON value per field, and strips provider-managed env vars before merging the active provider preview. This keeps historical provider auth values from leaking into the editable JSON surface. Constraint: Claude Code uses ANTHROPIC_API_KEY for x-api-key auth and ANTHROPIC_AUTH_TOKEN for Bearer auth, so the fix keeps runtime semantics separate from UI redaction. Rejected: Collapse all providers onto one env var | direct Anthropic and gateway auth paths have different documented headers. Confidence: high Scope-risk: narrow Directive: Treat provider auth env vars as secrets by field name in UI surfaces, not by comparing against the current form API key. Tested: cd desktop && bun run test -- src/lib/__tests__/providerSettingsJson.test.ts Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: agent-browser opened DeepSeek provider edit modal and verified Settings JSON shows ANTHROPIC_AUTH_TOKEN as placeholder with no ANTHROPIC_API_KEY entry. --- .../__tests__/providerSettingsJson.test.ts | 63 +++++++++++ desktop/src/lib/providerSettingsJson.ts | 103 ++++++++++++++++++ desktop/src/pages/Settings.tsx | 55 ++-------- 3 files changed, 177 insertions(+), 44 deletions(-) create mode 100644 desktop/src/lib/__tests__/providerSettingsJson.test.ts create mode 100644 desktop/src/lib/providerSettingsJson.ts diff --git a/desktop/src/lib/__tests__/providerSettingsJson.test.ts b/desktop/src/lib/__tests__/providerSettingsJson.test.ts new file mode 100644 index 00000000..94c47807 --- /dev/null +++ b/desktop/src/lib/__tests__/providerSettingsJson.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { + API_KEY_JSON_PLACEHOLDER, + maskSettingsJsonSecrets, + restoreSettingsJsonSecrets, + stripProviderSettingsJsonEnv, +} from '../providerSettingsJson' + +describe('provider settings JSON helpers', () => { + it('masks both Anthropic API key env vars even when the values differ', () => { + const raw = JSON.stringify({ + env: { + ANTHROPIC_API_KEY: 'stale-api-key', + ANTHROPIC_AUTH_TOKEN: 'current-auth-token', + OTHER_VALUE: 'visible', + }, + }) + + const masked = JSON.parse(maskSettingsJsonSecrets(raw)) as { env: Record } + + expect(masked.env.ANTHROPIC_API_KEY).toBe(API_KEY_JSON_PLACEHOLDER) + expect(masked.env.ANTHROPIC_AUTH_TOKEN).toBe(API_KEY_JSON_PLACEHOLDER) + expect(masked.env.OTHER_VALUE).toBe('visible') + }) + + it('restores masked Anthropic API key env vars from their previous field values', () => { + const previousRaw = JSON.stringify({ + env: { + ANTHROPIC_API_KEY: 'previous-api-key', + ANTHROPIC_AUTH_TOKEN: 'previous-auth-token', + }, + }) + const edited = { + env: { + ANTHROPIC_API_KEY: API_KEY_JSON_PLACEHOLDER, + ANTHROPIC_AUTH_TOKEN: API_KEY_JSON_PLACEHOLDER, + }, + } + + const restored = restoreSettingsJsonSecrets(edited, previousRaw, 'fallback-key') + + expect(restored.env.ANTHROPIC_API_KEY).toBe('previous-api-key') + expect(restored.env.ANTHROPIC_AUTH_TOKEN).toBe('previous-auth-token') + }) + + it('strips provider-managed env vars from existing settings before preview merge', () => { + const cleaned = stripProviderSettingsJsonEnv( + { + ANTHROPIC_API_KEY: 'old-api-key', + ANTHROPIC_AUTH_TOKEN: 'old-auth-token', + ANTHROPIC_BASE_URL: 'https://old.example.com', + ANTHROPIC_MODEL: 'old-model', + CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}', + CC_HAHA_SEND_DISABLED_THINKING: '1', + USER_DEFINED: 'keep-me', + }, + ['CC_HAHA_SEND_DISABLED_THINKING'], + ) + + expect(cleaned).toEqual({ USER_DEFINED: 'keep-me' }) + }) +}) diff --git a/desktop/src/lib/providerSettingsJson.ts b/desktop/src/lib/providerSettingsJson.ts new file mode 100644 index 00000000..9ad5fc09 --- /dev/null +++ b/desktop/src/lib/providerSettingsJson.ts @@ -0,0 +1,103 @@ +export const API_KEY_JSON_PLACEHOLDER = '••••••••' + +const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const + +const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', + 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', + 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'ANTHROPIC_SMALL_FAST_MODEL', + 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', + 'CLAUDE_CODE_SUBAGENT_MODEL', +]) + +function getEnvRecord(raw: string): Record | null { + try { + const parsed = JSON.parse(raw) as { env?: unknown } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) { + return null + } + return parsed.env as Record + } catch { + return null + } +} + +function isSecretDisplayValue(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim() !== '' && + value !== API_KEY_JSON_PLACEHOLDER && + value !== '(your API key)' && + value !== 'proxy-managed' + ) +} + +export function maskSettingsJsonSecrets(raw: string): string { + try { + const parsed = JSON.parse(raw) as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) return raw + let changed = false + for (const key of API_KEY_JSON_KEYS) { + if (isSecretDisplayValue(parsed.env[key])) { + parsed.env[key] = API_KEY_JSON_PLACEHOLDER + changed = true + } + } + return changed ? JSON.stringify(parsed, null, 2) : raw + } catch { + return raw + } +} + +export function restoreSettingsJsonSecrets( + settings: T, + previousRaw: string, + fallbackApiKey = '', +): T { + if (!settings || typeof settings !== 'object') return settings + const parsed = settings as { env?: Record } + if (!parsed.env || typeof parsed.env !== 'object' || Array.isArray(parsed.env)) return settings + + const previousEnv = getEnvRecord(previousRaw) + const fallback = fallbackApiKey.trim() + + for (const key of API_KEY_JSON_KEYS) { + if (parsed.env[key] !== API_KEY_JSON_PLACEHOLDER) continue + const previousValue = previousEnv?.[key] + if (isSecretDisplayValue(previousValue)) { + parsed.env[key] = previousValue + } else if (fallback) { + parsed.env[key] = fallback + } + } + + return settings +} + +export function stripProviderSettingsJsonEnv( + env: Record, + extraManagedKeys: Iterable = [], +): Record { + const extraKeys = new Set(Array.from(extraManagedKeys, key => key.toUpperCase())) + return Object.fromEntries( + Object.entries(env).filter(([key]) => { + const upperKey = key.toUpperCase() + return !PROVIDER_SETTINGS_JSON_ENV_KEYS.has(upperKey) && !extraKeys.has(upperKey) + }), + ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ebf0c1d6..69ba2308 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -31,6 +31,12 @@ import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' import { isTauriRuntime } from '../lib/desktopRuntime' +import { + API_KEY_JSON_PLACEHOLDER, + maskSettingsJsonSecrets, + restoreSettingsJsonSecrets, + stripProviderSettingsJsonEnv, +} from '../lib/providerSettingsJson' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -506,39 +512,6 @@ function openExternalUrl(url: string) { .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) } -const API_KEY_JSON_PLACEHOLDER = '••••••••' -const API_KEY_JSON_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'] as const - -function maskSettingsJsonSecrets(raw: string, apiKey: string): string { - if (!apiKey.trim()) return raw - try { - const parsed = JSON.parse(raw) as { env?: Record } - if (!parsed.env || typeof parsed.env !== 'object') return raw - let changed = false - for (const key of API_KEY_JSON_KEYS) { - if (parsed.env[key] === apiKey) { - parsed.env[key] = API_KEY_JSON_PLACEHOLDER - changed = true - } - } - return changed ? JSON.stringify(parsed, null, 2) : raw - } catch { - return raw - } -} - -function restoreSettingsJsonSecrets(settings: T, apiKey: string): T { - if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings - const parsed = settings as { env?: Record } - if (!parsed.env || typeof parsed.env !== 'object') return settings - for (const key of API_KEY_JSON_KEYS) { - if (parsed.env[key] === API_KEY_JSON_PLACEHOLDER) { - parsed.env[key] = apiKey - } - } - return settings -} - function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) @@ -548,11 +521,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const regularPresets = availablePresets.filter((p) => !p.featured) const featuredPresets = availablePresets.filter((p) => p.featured) const presetDefaultEnvKeys = useMemo( - () => new Set([ - AUTO_COMPACT_WINDOW_ENV_KEY, - MODEL_CONTEXT_WINDOWS_ENV_KEY, - ...presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})), - ]), + () => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})), [presets], ) const fallbackPreset = provider @@ -601,9 +570,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const autoCompactWindowEnv = autoCompactWindow.trim() const modelContextWindows = buildModelContextWindows(models, modelContextInputs) const existingEnv = (settings.env as Record) || {} - const cleanedEnv = Object.fromEntries( - Object.entries(existingEnv).filter(([key]) => !presetDefaultEnvKeys.has(key)), - ) + const cleanedEnv = stripProviderSettingsJsonEnv(existingEnv, presetDefaultEnvKeys) const merged = { ...settings, skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, @@ -653,7 +620,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const promoText = selectedPreset.promoText?.trim() const displayedSettingsJson = showApiKey ? settingsJson - : maskSettingsJsonSecrets(settingsJson, apiKey) + : maskSettingsJsonSecrets(settingsJson) const apiFormatItems = [ { value: 'anthropic' as const, @@ -735,7 +702,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF // settings never conflict with the user's global ~/.claude/settings.json. if (settingsJson.trim()) { try { - const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), apiKey) + const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey) const { providersApi } = await import('../api/providers') await providersApi.updateSettings(parsed) } catch { @@ -1069,7 +1036,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF onChange={(e) => { const raw = e.target.value try { - const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), apiKey) + const parsed = restoreSettingsJsonSecrets(JSON.parse(raw), settingsJson, apiKey) setSettingsJson(JSON.stringify(parsed, null, 2)) setSettingsJsonError(null) // Auto-fill form fields from parsed JSON env