Stop bearer providers from inheriting local API keys

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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 14:59:35 +08:00
parent a31b3045fd
commit 587e6809fc
2 changed files with 106 additions and 1 deletions

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,