mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
8711ba1560
commit
299c50a1e0
63
desktop/src/lib/__tests__/providerSettingsJson.test.ts
Normal file
63
desktop/src/lib/__tests__/providerSettingsJson.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
103
desktop/src/lib/providerSettingsJson.ts
Normal file
103
desktop/src/lib/providerSettingsJson.ts
Normal 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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user