From 6e5dd94dad28a6309ca686298eeee7dd0ae13b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 14:59:35 +0800 Subject: [PATCH] Stop bearer providers from inheriting local API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/conversation-service.test.ts | 10 ++- src/server/__tests__/providers-real.test.ts | 8 +- src/server/__tests__/providers.test.ts | 6 +- src/server/services/providerService.ts | 5 +- src/services/api/client.test.ts | 87 +++++++++++++++++++ src/services/api/client.ts | 20 ++++- 6 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 src/services/api/client.test.ts diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 6782ba54..38390642 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -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') }) diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts index 152fba6d..9efa13a6 100644 --- a/src/server/__tests__/providers-real.test.ts +++ b/src/server/__tests__/providers-real.test.ts @@ -67,7 +67,7 @@ describe('Real Provider Configs', () => { const settings = await readCcHahaSettings() expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic') expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only') - expect((settings.env as Record).ANTHROPIC_API_KEY).toBeUndefined() + expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('') expect((settings.env as Record).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed') expect(JSON.parse((settings.env as Record).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).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only') - expect((settings.env as Record).ANTHROPIC_API_KEY).toBeUndefined() + expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('') expect((settings.env as Record).ANTHROPIC_MODEL).toBe('claude-opus-4-7') expect((settings.env as Record).CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined() @@ -163,7 +163,7 @@ describe('Real Provider Configs', () => { // 验证新字段写入 expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic') expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk_test') - expect((settings.env as Record).ANTHROPIC_API_KEY).toBeUndefined() + expect((settings.env as Record).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).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic') expect((haha.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key') - expect((haha.env as Record).ANTHROPIC_API_KEY).toBeUndefined() + expect((haha.env as Record).ANTHROPIC_API_KEY).toBe('') console.log('✅ 原版 settings.json 完好无损,Haha 配置独立存储') }) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 09367a0f..5fda2cda 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -305,7 +305,7 @@ describe('ProviderService', () => { const env = settings.env as Record 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 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 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') }) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 2d1891bc..82e1c4bc 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -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: '', diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts new file mode 100644 index 00000000..4927c381 --- /dev/null +++ b/src/services/api/client.test.ts @@ -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 (fn: () => Promise) => 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 + } + }) +}) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index b26e8b3f..62fc6c9b 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -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,