mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
a31b3045fd
commit
6e5dd94dad
@ -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')
|
||||
})
|
||||
|
||||
@ -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 配置独立存储')
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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: '',
|
||||
|
||||
87
src/services/api/client.test.ts
Normal file
87
src/services/api/client.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user