Stop bearer providers from inheriting local API keys

Desktop Anthropic-compatible relay providers use ANTHROPIC_AUTH_TOKEN for
Authorization, but the CLI process can still load a stale ANTHROPIC_API_KEY
from inherited env, original settings, keychain-backed config, or the SDK
constructor fallback. That made Provider Test pass while real sessions sent
both Authorization and x-api-key to relays such as Packy and Shengsuanyun,
which then rejected the request as an invalid token/key.

The client now avoids falling back to a local API key when bearer-token
provider auth is active. Provider-managed auth_token env also writes an empty
ANTHROPIC_API_KEY so cc-haha settings explicitly override stale original
settings during desktop/sidecar startup. Regression tests pin both the SDK
client behavior and the desktop child-env behavior.

Constraint: Anthropic-compatible relay providers can reject mixed Authorization plus x-api-key headers even when direct provider tests pass
Rejected: Treat this as a provider/model-name issue | local proxy captures showed the failing request shape before provider handling
Rejected: Rely only on client.ts fallback suppression | settings/env layering can leave ANTHROPIC_API_KEY populated before the client is constructed
Confidence: high
Scope-risk: moderate
Directive: Bearer-token provider sessions must keep Authorization without inheriting or reintroducing x-api-key from user/global settings
Tested: bun test src/services/api/client.test.ts
Tested: bun test src/server/__tests__/providers.test.ts src/server/__tests__/providers-real.test.ts src/server/__tests__/conversation-service.test.ts
Tested: bun run check:server
Tested: Source and packaged sidecar proxy captures with stale original ANTHROPIC_API_KEY showed Authorization=true and x-api-key=false after the fix
Tested: desktop/scripts/build-macos-arm64.sh
Tested: PackyCodex2ccCustom and 胜算云 packaged-sidecar live calls returned ok
Not-tested: Full manual UI click-through in the rebuilt macOS app window
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 14:59:35 +08:00
parent a31b3045fd
commit 6e5dd94dad
6 changed files with 125 additions and 11 deletions

View File

@ -8,6 +8,7 @@ import { ProviderService } from '../services/providerService.js'
describe('ConversationService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalApiKey: string | undefined
let originalAuthToken: string | undefined
let originalBaseUrl: string | undefined
let originalModel: string | undefined
@ -19,6 +20,7 @@ describe('ConversationService', () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalApiKey = process.env.ANTHROPIC_API_KEY
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
originalModel = process.env.ANTHROPIC_MODEL
@ -28,6 +30,7 @@ describe('ConversationService', () => {
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.ANTHROPIC_API_KEY = 'stale-parent-api-key'
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
process.env.ANTHROPIC_MODEL = 'test-model'
@ -43,6 +46,9 @@ describe('ConversationService', () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = originalApiKey
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
@ -199,7 +205,7 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet')
})
test('buildChildEnv preserves provider capability overrides from presets', async () => {
test('buildChildEnv clears stale api key for bearer-token providers', async () => {
const providerService = new ProviderService()
const provider = await providerService.addProvider({
presetId: 'jiekouai',
@ -223,7 +229,7 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('provider-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none')
})

View File

@ -67,7 +67,7 @@ describe('Real Provider Configs', () => {
const settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed')
expect(JSON.parse((settings.env as Record<string, string>).CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({
'MiniMax-M2.7': 204800,
@ -116,7 +116,7 @@ describe('Real Provider Configs', () => {
settings = await readCcHahaSettings()
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
expect((settings.env as Record<string, string>).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
expect((settings.env as Record<string, string>).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
@ -163,7 +163,7 @@ describe('Real Provider Configs', () => {
// 验证新字段写入
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((settings.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
// 验证已有字段保留
expect(settings.customField).toBe('should_be_preserved')
@ -252,7 +252,7 @@ describe('Real Provider Configs', () => {
const haha = await readCcHahaSettings()
expect((haha.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
expect((haha.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBeUndefined()
expect((haha.env as Record<string, string>).ANTHROPIC_API_KEY).toBe('')
console.log('✅ 原版 settings.json 完好无损Haha 配置独立存储')
})

View File

@ -305,7 +305,7 @@ describe('ProviderService', () => {
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
@ -445,7 +445,7 @@ describe('ProviderService', () => {
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet')
@ -963,7 +963,7 @@ describe('Providers API', () => {
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second')
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})

View File

@ -143,7 +143,10 @@ function buildProviderAuthEnv(
case 'api_key':
return key ? { ANTHROPIC_API_KEY: key } : {}
case 'auth_token':
return key ? { ANTHROPIC_AUTH_TOKEN: key } : {}
return {
ANTHROPIC_API_KEY: '',
...(key ? { ANTHROPIC_AUTH_TOKEN: key } : {}),
}
case 'auth_token_empty_api_key':
return {
ANTHROPIC_API_KEY: '',

View File

@ -0,0 +1,87 @@
import { describe, expect, mock, test } from 'bun:test'
mock.module('src/utils/http.js', () => ({
getAuthHeaders: mock(() => ({})),
getMCPUserAgent: mock(() => 'client-test-agent'),
getUserAgent: mock(() => 'client-test-agent'),
getWebFetchUserAgent: mock(() => 'client-test-agent'),
withOAuth401Retry: mock(async <T>(fn: () => Promise<T>) => fn()),
}))
describe('resolveAnthropicClientApiKey', () => {
test('does not inherit a local api key when a provider auth token is explicit', async () => {
const { resolveAnthropicClientApiKey } = await import('./client.js')
const getFallbackApiKey = mock(() => 'sk-keychain-fallback')
const apiKey = resolveAnthropicClientApiKey({
envAuthToken: 'provider-bearer-token',
envApiKey: undefined,
getFallbackApiKey,
})
expect(apiKey).toBeNull()
expect(getFallbackApiKey).not.toHaveBeenCalled()
})
test('preserves an explicit api key when the caller opts into dual auth', async () => {
const { resolveAnthropicClientApiKey } = await import('./client.js')
const getFallbackApiKey = mock(() => 'sk-keychain-fallback')
const apiKey = resolveAnthropicClientApiKey({
explicitApiKey: 'sk-explicit-api-key',
envAuthToken: 'provider-bearer-token',
getFallbackApiKey,
})
expect(apiKey).toBe('sk-explicit-api-key')
expect(getFallbackApiKey).not.toHaveBeenCalled()
})
test('falls back to the local api key when no provider auth token is present', async () => {
const { resolveAnthropicClientApiKey } = await import('./client.js')
const getFallbackApiKey = mock(() => 'sk-keychain-fallback')
const apiKey = resolveAnthropicClientApiKey({
envAuthToken: undefined,
envApiKey: undefined,
getFallbackApiKey,
})
expect(apiKey).toBe('sk-keychain-fallback')
expect(getFallbackApiKey).toHaveBeenCalled()
})
})
describe('getAnthropicClient', () => {
test('passes bearer-token provider auth without an SDK api key', async () => {
const { getAnthropicClient } = await import('./client.js')
const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
const originalApiKey = process.env.ANTHROPIC_API_KEY
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
process.env.ANTHROPIC_AUTH_TOKEN = 'provider-bearer-token'
process.env.CLAUDE_CODE_SIMPLE = '1'
delete process.env.ANTHROPIC_API_KEY
try {
const client = await getAnthropicClient({
maxRetries: 0,
model: 'claude-sonnet-4-6',
})
expect(client.apiKey).toBeNull()
expect(client._options.defaultHeaders).toMatchObject({
Authorization: 'Bearer provider-bearer-token',
})
} finally {
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY
else process.env.ANTHROPIC_API_KEY = originalApiKey
if (originalSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE
else process.env.CLAUDE_CODE_SIMPLE = originalSimple
}
})
})

View File

@ -91,6 +91,24 @@ function createStderrLogger(): ClientOptions['logger'] {
}
}
export function resolveAnthropicClientApiKey({
explicitApiKey,
envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN,
envApiKey = process.env.ANTHROPIC_API_KEY,
getFallbackApiKey = getAnthropicApiKey,
}: {
explicitApiKey?: string
envAuthToken?: string
envApiKey?: string
getFallbackApiKey?: () => string | null
}): string | null {
if (envAuthToken && !explicitApiKey && !envApiKey) {
return null
}
return explicitApiKey || getFallbackApiKey()
}
export async function getAnthropicClient({
apiKey,
maxRetries,
@ -319,7 +337,7 @@ export async function getAnthropicClient({
? null
: usingOpenAICodex
? OPENAI_OAUTH_DUMMY_KEY
: apiKey || getAnthropicApiKey(),
: resolveAnthropicClientApiKey({ explicitApiKey: apiKey }),
authToken: isClaudeAISubscriber()
? getClaudeAIOAuthTokens()?.accessToken
: undefined,