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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 17:02:56 +08:00
parent 8711ba1560
commit 299c50a1e0
3 changed files with 177 additions and 44 deletions

View File

@ -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<string, string> }
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' })
})
})

View File

@ -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<string, unknown> | 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<string, unknown>
} 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<string, unknown> }
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<T>(
settings: T,
previousRaw: string,
fallbackApiKey = '',
): T {
if (!settings || typeof settings !== 'object') return settings
const parsed = settings as { env?: Record<string, unknown> }
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<string, string>,
extraManagedKeys: Iterable<string> = [],
): Record<string, string> {
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)
}),
)
}

View File

@ -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<SettingsTab>('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<string, unknown> }
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<T>(settings: T, apiKey: string): T {
if (!apiKey.trim() || !settings || typeof settings !== 'object') return settings
const parsed = settings as { env?: Record<string, unknown> }
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<string, string>) || {}
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