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