Skip to content

Commit 2ccc216

Browse files
Merge pull request #1326 from ai-yang/agent/settings-provider-gates
fix(auth): honor settings-based providers
2 parents 7bdc776 + 8292564 commit 2ccc216

8 files changed

Lines changed: 367 additions & 43 deletions

File tree

src/cli/handlers/auth.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
getAuthTokenSource,
2727
getOauthAccountInfo,
2828
getSubscriptionType,
29-
isUsing3PServices,
3029
saveOAuthTokensIfNeeded,
3130
validateForceLoginOrg,
3231
} from '../../utils/auth.js'
@@ -35,7 +34,10 @@ import { logForDebugging } from '../../utils/debug.js'
3534
import { isRunningOnHomespace } from '../../utils/envUtils.js'
3635
import { errorMessage } from '../../utils/errors.js'
3736
import { logError } from '../../utils/log.js'
38-
import { getAPIProvider } from '../../utils/model/providers.js'
37+
import {
38+
getAPIProvider,
39+
isThirdPartyAPIProvider,
40+
} from '../../utils/model/providers.js'
3941
import { getInitialSettings } from '../../utils/settings/settings.js'
4042
import { jsonStringify } from '../../utils/slowOperations.js'
4143
import {
@@ -243,7 +245,7 @@ export async function authStatus(opts: {
243245
!!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()
244246
const oauthAccount = getOauthAccountInfo()
245247
const subscriptionType = getSubscriptionType()
246-
const using3P = isUsing3PServices()
248+
const using3P = isThirdPartyAPIProvider(getAPIProvider())
247249
const loggedIn =
248250
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
249251

src/commands.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ import {
223223
} from './utils/plugins/loadPluginCommands.js'
224224
import memoize from 'lodash-es/memoize.js'
225225
import { isUsing3PServices, isClaudeAISubscriber } from './utils/auth.js'
226-
import { isFirstPartyAnthropicBaseUrl } from './utils/model/providers.js'
226+
import {
227+
getAPIProvider,
228+
isFirstPartyAnthropicBaseUrl,
229+
isThirdPartyAPIProvider,
230+
} from './utils/model/providers.js'
227231
import env from './commands/env/index.js'
228232
import exit from './commands/exit/index.js'
229233
import exportCommand from './commands/export/index.js'
@@ -510,11 +514,11 @@ export function meetsAvailabilityRequirement(cmd: Command): boolean {
510514
break
511515
case 'console':
512516
// Console API key user = direct 1P API customer (not 3P, not claude.ai).
513-
// Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
514-
// and gateway users who proxy through a custom base URL.
517+
// Excludes non-first-party providers selected through settings or env,
518+
// plus gateway users who proxy through a custom base URL.
515519
if (
516520
!isClaudeAISubscriber() &&
517-
!isUsing3PServices() &&
521+
!isThirdPartyAPIProvider(getAPIProvider()) &&
518522
isFirstPartyAnthropicBaseUrl()
519523
)
520524
return true

src/utils/auth.ts

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
logEvent,
1111
} from 'src/services/analytics/index.js'
1212
import { getModelStrings } from 'src/utils/model/modelStrings.js'
13-
import { getAPIProvider } from 'src/utils/model/providers.js'
13+
import {
14+
getAPIProvider,
15+
isThirdPartyAPIProvider,
16+
} from 'src/utils/model/providers.js'
1417
import {
1518
getIsNonInteractiveSession,
1619
preferThirdPartyAuthentication,
@@ -114,13 +117,11 @@ export function isAnthropicAuthEnabled(): boolean {
114117

115118
const settings = getSettings_DEPRECATED() || {}
116119
const is3P =
117-
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
118-
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
119-
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
120-
settings.modelType === 'openai' ||
121-
settings.modelType === 'gemini' ||
120+
isThirdPartyAPIProvider(getAPIProvider(settings)) ||
122121
!!process.env.OPENAI_BASE_URL ||
123122
!!process.env.GEMINI_BASE_URL
123+
if (is3P) return false
124+
124125
const apiKeyHelper = settings.apiKeyHelper
125126
const hasExternalAuthToken =
126127
process.env.ANTHROPIC_AUTH_TOKEN ||
@@ -134,15 +135,13 @@ export function isAnthropicAuthEnabled(): boolean {
134135
const hasExternalApiKey =
135136
apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper'
136137

137-
// Disable Anthropic auth if:
138-
// 1. Using 3rd party services (Bedrock/Vertex/Foundry)
139-
// 2. User has an external API key (regardless of proxy configuration)
140-
// 3. User has an external auth token (regardless of proxy configuration)
138+
// Third-party providers are handled above. Disable Anthropic auth if:
139+
// 1. User has an external API key (regardless of proxy configuration)
140+
// 2. User has an external auth token (regardless of proxy configuration)
141141
// this may cause issues if users have complex proxy / gateway "client-side creds" auth scenarios,
142142
// e.g. if they want to set X-Api-Key to a gateway key but use Anthropic OAuth for the Authorization
143143
// if we get reports of that, we should probably add an env var to force OAuth enablement
144144
const shouldDisableAuth =
145-
is3P ||
146145
(hasExternalAuthToken && !isManagedOAuthContext()) ||
147146
(hasExternalApiKey && !isManagedOAuthContext())
148147

@@ -1727,17 +1726,14 @@ export function getSubscriptionName(): string {
17271726
/**
17281727
* Check if using third-party services (non-Anthropic providers).
17291728
*
1730-
* This function gates several behaviours that should only apply when the user
1731-
* is NOT calling the first-party Anthropic API directly:
1732-
* - auth status display (authStatus handler)
1733-
* - command visibility (login/logout shown for non-3P)
1734-
* - command availability checks (meetsAvailabilityRequirement)
1729+
* This environment-only compatibility check intentionally does not inspect
1730+
* settings.modelType. It is used by behaviours whose existing visibility is
1731+
* tied specifically to CLAUDE_CODE_USE_* flags, such as login/logout commands.
17351732
*
17361733
* KEEP IN SYNC with providers.ts — when a new CLAUDE_CODE_USE_* env var is
17371734
* added to getAPIProvider(), the corresponding check MUST be added here.
1738-
* Providers whose selection is controlled purely via settings.modelType
1739-
* (rather than env vars) are NOT covered by this function and may need
1740-
* separate handling in the call sites above.
1735+
* For complete provider classification, use
1736+
* isThirdPartyAPIProvider(getAPIProvider()).
17411737
*/
17421738
export function isUsing3PServices(): boolean {
17431739
return !!(
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import {
2+
afterAll,
3+
beforeEach,
4+
describe,
5+
expect,
6+
mock,
7+
spyOn,
8+
test,
9+
} from 'bun:test'
10+
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
11+
import { tmpdir } from 'os'
12+
import { join } from 'path'
13+
import {
14+
resetSettingsCache,
15+
setSessionSettingsCache,
16+
} from '../../settings/settingsCache.js'
17+
import type { SettingsJson } from '../../settings/types.js'
18+
19+
const testRoot = mkdtempSync(join(tmpdir(), 'provider-gates-'))
20+
const testConfigDir = join(testRoot, 'config')
21+
process.env.CLAUDE_CONFIG_DIR = testConfigDir
22+
process.env.NODE_ENV = 'test'
23+
mkdirSync(testConfigDir, { recursive: true })
24+
25+
const isolatedEnvKeys = [
26+
'ANTHROPIC_API_KEY',
27+
'ANTHROPIC_AUTH_TOKEN',
28+
'ANTHROPIC_BASE_URL',
29+
'ANTHROPIC_UNIX_SOCKET',
30+
'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
31+
'CLAUDE_CODE_OAUTH_TOKEN',
32+
'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
33+
'CLAUDE_CODE_SIMPLE',
34+
'CLAUDE_CODE_USE_BEDROCK',
35+
'CLAUDE_CODE_USE_FOUNDRY',
36+
'CLAUDE_CODE_USE_GEMINI',
37+
'CLAUDE_CODE_USE_GROK',
38+
'CLAUDE_CODE_USE_OPENAI',
39+
'CLAUDE_CODE_USE_VERTEX',
40+
'DISABLE_INSTALL_GITHUB_APP_COMMAND',
41+
'DISABLE_LOGIN_COMMAND',
42+
'DISABLE_LOGOUT_COMMAND',
43+
'GEMINI_BASE_URL',
44+
'OPENAI_BASE_URL',
45+
] as const
46+
47+
function clearIsolatedEnv(): void {
48+
for (const key of isolatedEnvKeys) delete process.env[key]
49+
}
50+
51+
function setModelType(modelType?: SettingsJson['modelType']): void {
52+
setSessionSettingsCache({
53+
settings: modelType ? { modelType } : {},
54+
errors: [],
55+
})
56+
}
57+
58+
clearIsolatedEnv()
59+
setModelType()
60+
61+
// Keep the real auth implementation while preventing platform keychain reads.
62+
// The subprocess wrapper contains this module mock so it cannot leak globally.
63+
mock.module('src/utils/secureStorage/index.ts', () => ({
64+
getSecureStorage: () => ({
65+
name: 'provider-gates-test',
66+
read: () => null,
67+
readAsync: async () => null,
68+
update: () => ({ success: true }),
69+
delete: () => true,
70+
}),
71+
}))
72+
73+
const { authStatus } = await import('../../../cli/handlers/auth.js')
74+
const { clearCommandsCache, getCommands, meetsAvailabilityRequirement } =
75+
await import('../../../commands.js')
76+
const { isAnthropicAuthEnabled, isUsing3PServices } = await import(
77+
'../../auth.js'
78+
)
79+
const { default: fast } = await import('../../../commands/fast/index.js')
80+
const { default: installGitHubApp } = await import(
81+
'../../../commands/install-github-app/index.js'
82+
)
83+
84+
const envProviderCases = [
85+
['CLAUDE_CODE_USE_BEDROCK', 'bedrock'],
86+
['CLAUDE_CODE_USE_VERTEX', 'vertex'],
87+
['CLAUDE_CODE_USE_FOUNDRY', 'foundry'],
88+
['CLAUDE_CODE_USE_OPENAI', 'openai'],
89+
['CLAUDE_CODE_USE_GEMINI', 'gemini'],
90+
['CLAUDE_CODE_USE_GROK', 'grok'],
91+
] as const
92+
93+
beforeEach(() => {
94+
clearIsolatedEnv()
95+
resetSettingsCache()
96+
setModelType()
97+
})
98+
99+
afterAll(() => {
100+
clearCommandsCache()
101+
resetSettingsCache()
102+
mock.restore()
103+
rmSync(testRoot, { recursive: true, force: true })
104+
})
105+
106+
describe('Console command availability', () => {
107+
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
108+
test(`rejects settings modelType=${modelType}`, () => {
109+
setModelType(modelType)
110+
111+
expect(meetsAvailabilityRequirement(fast)).toBe(false)
112+
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(false)
113+
})
114+
}
115+
116+
for (const [envKey] of envProviderCases) {
117+
test(`rejects ${envKey}`, () => {
118+
process.env[envKey] = '1'
119+
120+
expect(meetsAvailabilityRequirement(fast)).toBe(false)
121+
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(false)
122+
})
123+
}
124+
125+
test('continues accepting direct first-party Anthropic', () => {
126+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
127+
expect(meetsAvailabilityRequirement(fast)).toBe(true)
128+
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(true)
129+
})
130+
})
131+
132+
describe('Anthropic auth provider gate', () => {
133+
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
134+
test(`disables Anthropic auth for settings modelType=${modelType}`, () => {
135+
setModelType(modelType)
136+
expect(isAnthropicAuthEnabled()).toBe(false)
137+
})
138+
}
139+
140+
for (const [envKey] of envProviderCases) {
141+
test(`disables Anthropic auth for ${envKey}`, () => {
142+
process.env[envKey] = '1'
143+
expect(isAnthropicAuthEnabled()).toBe(false)
144+
})
145+
}
146+
147+
for (const envKey of ['OPENAI_BASE_URL', 'GEMINI_BASE_URL'] as const) {
148+
test(`disables Anthropic auth for ${envKey}`, () => {
149+
process.env[envKey] = 'https://provider-gates.invalid'
150+
expect(isAnthropicAuthEnabled()).toBe(false)
151+
})
152+
}
153+
154+
test('continues enabling Anthropic auth for first-party OAuth', () => {
155+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
156+
expect(isAnthropicAuthEnabled()).toBe(true)
157+
})
158+
})
159+
160+
describe('environment-only third-party compatibility gate', () => {
161+
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
162+
test(`ignores settings modelType=${modelType}`, () => {
163+
setModelType(modelType)
164+
165+
expect(isUsing3PServices()).toBe(false)
166+
})
167+
}
168+
})
169+
170+
class ExitSignal extends Error {
171+
constructor(readonly code: string | number | null | undefined) {
172+
super(`process.exit(${String(code)})`)
173+
}
174+
}
175+
176+
async function captureAuthStatus() {
177+
let stdout = ''
178+
const writeSpy = spyOn(process.stdout, 'write').mockImplementation(((
179+
chunk: string | Uint8Array,
180+
) => {
181+
stdout += chunk.toString()
182+
return true
183+
}) as typeof process.stdout.write)
184+
const exitSpy = spyOn(process, 'exit').mockImplementation(((
185+
code?: string | number | null,
186+
): never => {
187+
throw new ExitSignal(code)
188+
}) as typeof process.exit)
189+
190+
let exitCode: ExitSignal['code']
191+
try {
192+
await authStatus({ json: true })
193+
throw new Error('authStatus did not call process.exit')
194+
} catch (error) {
195+
if (!(error instanceof ExitSignal)) throw error
196+
exitCode = error.code
197+
} finally {
198+
exitSpy.mockRestore()
199+
writeSpy.mockRestore()
200+
}
201+
202+
return {
203+
exitCode,
204+
output: JSON.parse(stdout) as Record<string, unknown>,
205+
}
206+
}
207+
208+
describe('auth status provider reporting', () => {
209+
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
210+
test(`reports settings modelType=${modelType} as third_party`, async () => {
211+
setModelType(modelType)
212+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
213+
214+
const { exitCode, output } = await captureAuthStatus()
215+
216+
expect(exitCode).toBe(0)
217+
expect(output.apiProvider).toBe(modelType)
218+
expect(output.authMethod).toBe('third_party')
219+
expect(output.loggedIn).toBe(true)
220+
})
221+
}
222+
223+
for (const [envKey, apiProvider] of envProviderCases) {
224+
test(`reports ${envKey} as third_party`, async () => {
225+
process.env[envKey] = '1'
226+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
227+
228+
const { exitCode, output } = await captureAuthStatus()
229+
230+
expect(exitCode).toBe(0)
231+
expect(output.apiProvider).toBe(apiProvider)
232+
expect(output.authMethod).toBe('third_party')
233+
expect(output.loggedIn).toBe(true)
234+
})
235+
}
236+
237+
test('continues reporting direct first-party OAuth', async () => {
238+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
239+
240+
const { exitCode, output } = await captureAuthStatus()
241+
242+
expect(exitCode).toBe(0)
243+
expect(output.apiProvider).toBe('firstParty')
244+
expect(output.authMethod).toBe('oauth_token')
245+
expect(output.loggedIn).toBe(true)
246+
})
247+
})
248+
249+
describe('login and logout visibility', () => {
250+
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
251+
test(`keeps both commands visible for settings modelType=${modelType}`, async () => {
252+
setModelType(modelType)
253+
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
254+
clearCommandsCache()
255+
256+
const commandNames = new Set(
257+
(await getCommands(testRoot)).map(command => command.name),
258+
)
259+
260+
expect(commandNames.has('login')).toBe(true)
261+
expect(commandNames.has('logout')).toBe(true)
262+
})
263+
}
264+
})

0 commit comments

Comments
 (0)