From 587e6809fcd3a06ac4a41db49f745624c04148dc 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 Custom Anthropic-compatible providers that authenticate with ANTHROPIC_AUTH_TOKEN must not also inherit the user's local login or keychain API key as x-api-key. The Provider Test path uses direct fetch and already sent only Authorization, while real desktop sessions use the Anthropic SDK and could attach both headers in trusted project cwd sessions. Constraint: Provider Test uses direct fetch while real sessions use Anthropic SDK headers Rejected: Disable prompt, git, or context loading | 401 reproduced with a minimal prompt and no tools while the extra x-api-key persisted Confidence: high Scope-risk: narrow Directive: Do not fallback to local Anthropic API keys when ANTHROPIC_AUTH_TOKEN is explicit unless ANTHROPIC_API_KEY or an explicit apiKey is also provided Tested: bun test src/services/api/client.test.ts Tested: bun run check:server Tested: source CLI proxy capture verified Authorization=true and x-api-key=false in a real repository cwd Tested: live parallel source CLI calls returned ok for PackyCodex2ccCustom and 胜算云 Not-tested: Rebuilt macOS app artifact after this patch --- src/services/api/client.test.ts | 87 +++++++++++++++++++++++++++++++++ src/services/api/client.ts | 20 +++++++- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/services/api/client.test.ts 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,