Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/cli/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
getAuthTokenSource,
getOauthAccountInfo,
getSubscriptionType,
isUsing3PServices,
saveOAuthTokensIfNeeded,
validateForceLoginOrg,
} from '../../utils/auth.js'
Expand All @@ -35,7 +34,10 @@ import { logForDebugging } from '../../utils/debug.js'
import { isRunningOnHomespace } from '../../utils/envUtils.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getAPIProvider } from '../../utils/model/providers.js'
import {
getAPIProvider,
isThirdPartyAPIProvider,
} from '../../utils/model/providers.js'
import { getInitialSettings } from '../../utils/settings/settings.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
Expand Down Expand Up @@ -243,7 +245,7 @@ export async function authStatus(opts: {
!!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()
const oauthAccount = getOauthAccountInfo()
const subscriptionType = getSubscriptionType()
const using3P = isUsing3PServices()
const using3P = isThirdPartyAPIProvider(getAPIProvider())
const loggedIn =
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P

Expand Down
12 changes: 8 additions & 4 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,11 @@ import {
} from './utils/plugins/loadPluginCommands.js'
import memoize from 'lodash-es/memoize.js'
import { isUsing3PServices, isClaudeAISubscriber } from './utils/auth.js'
import { isFirstPartyAnthropicBaseUrl } from './utils/model/providers.js'
import {
getAPIProvider,
isFirstPartyAnthropicBaseUrl,
isThirdPartyAPIProvider,
} from './utils/model/providers.js'
import env from './commands/env/index.js'
import exit from './commands/exit/index.js'
import exportCommand from './commands/export/index.js'
Expand Down Expand Up @@ -510,11 +514,11 @@ export function meetsAvailabilityRequirement(cmd: Command): boolean {
break
case 'console':
// Console API key user = direct 1P API customer (not 3P, not claude.ai).
// Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
// and gateway users who proxy through a custom base URL.
// Excludes non-first-party providers selected through settings or env,
// plus gateway users who proxy through a custom base URL.
if (
!isClaudeAISubscriber() &&
!isUsing3PServices() &&
!isThirdPartyAPIProvider(getAPIProvider()) &&
isFirstPartyAnthropicBaseUrl()
)
return true
Expand Down
27 changes: 12 additions & 15 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
logEvent,
} from 'src/services/analytics/index.js'
import { getModelStrings } from 'src/utils/model/modelStrings.js'
import { getAPIProvider } from 'src/utils/model/providers.js'
import {
getAPIProvider,
isThirdPartyAPIProvider,
} from 'src/utils/model/providers.js'
import {
getIsNonInteractiveSession,
preferThirdPartyAuthentication,
Expand Down Expand Up @@ -114,13 +117,11 @@ export function isAnthropicAuthEnabled(): boolean {

const settings = getSettings_DEPRECATED() || {}
const is3P =
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
settings.modelType === 'openai' ||
settings.modelType === 'gemini' ||
isThirdPartyAPIProvider(getAPIProvider(settings)) ||
!!process.env.OPENAI_BASE_URL ||
!!process.env.GEMINI_BASE_URL
if (is3P) return false

const apiKeyHelper = settings.apiKeyHelper
const hasExternalAuthToken =
process.env.ANTHROPIC_AUTH_TOKEN ||
Expand All @@ -142,7 +143,6 @@ export function isAnthropicAuthEnabled(): boolean {
// e.g. if they want to set X-Api-Key to a gateway key but use Anthropic OAuth for the Authorization
// if we get reports of that, we should probably add an env var to force OAuth enablement
const shouldDisableAuth =
is3P ||
(hasExternalAuthToken && !isManagedOAuthContext()) ||
(hasExternalApiKey && !isManagedOAuthContext())

Expand Down Expand Up @@ -1727,17 +1727,14 @@ export function getSubscriptionName(): string {
/**
* Check if using third-party services (non-Anthropic providers).
*
* This function gates several behaviours that should only apply when the user
* is NOT calling the first-party Anthropic API directly:
* - auth status display (authStatus handler)
* - command visibility (login/logout shown for non-3P)
* - command availability checks (meetsAvailabilityRequirement)
* This environment-only compatibility check intentionally does not inspect
* settings.modelType. It is used by behaviours whose existing visibility is
* tied specifically to CLAUDE_CODE_USE_* flags, such as login/logout commands.
*
* KEEP IN SYNC with providers.ts — when a new CLAUDE_CODE_USE_* env var is
* added to getAPIProvider(), the corresponding check MUST be added here.
* Providers whose selection is controlled purely via settings.modelType
* (rather than env vars) are NOT covered by this function and may need
* separate handling in the call sites above.
* For complete provider classification, use
* isThirdPartyAPIProvider(getAPIProvider()).
*/
export function isUsing3PServices(): boolean {
return !!(
Expand Down
264 changes: 264 additions & 0 deletions src/utils/model/__tests__/providerGates.runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import {
afterAll,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
resetSettingsCache,
setSessionSettingsCache,
} from '../../settings/settingsCache.js'
import type { SettingsJson } from '../../settings/types.js'

const testRoot = mkdtempSync(join(tmpdir(), 'provider-gates-'))
const testConfigDir = join(testRoot, 'config')
process.env.CLAUDE_CONFIG_DIR = testConfigDir
process.env.NODE_ENV = 'test'
mkdirSync(testConfigDir, { recursive: true })

const isolatedEnvKeys = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_UNIX_SOCKET',
'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
'CLAUDE_CODE_SIMPLE',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_GROK',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_VERTEX',
'DISABLE_INSTALL_GITHUB_APP_COMMAND',
'DISABLE_LOGIN_COMMAND',
'DISABLE_LOGOUT_COMMAND',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
] as const

function clearIsolatedEnv(): void {
for (const key of isolatedEnvKeys) delete process.env[key]
}

function setModelType(modelType?: SettingsJson['modelType']): void {
setSessionSettingsCache({
settings: modelType ? { modelType } : {},
errors: [],
})
}

clearIsolatedEnv()
setModelType()

// Keep the real auth implementation while preventing platform keychain reads.
// The subprocess wrapper contains this module mock so it cannot leak globally.
mock.module('src/utils/secureStorage/index.ts', () => ({
getSecureStorage: () => ({
name: 'provider-gates-test',
read: () => null,
readAsync: async () => null,
update: () => ({ success: true }),
delete: () => true,
}),
}))

const { authStatus } = await import('../../../cli/handlers/auth.js')
const { clearCommandsCache, getCommands, meetsAvailabilityRequirement } =
await import('../../../commands.js')
const { isAnthropicAuthEnabled, isUsing3PServices } = await import(
'../../auth.js'
)
const { default: fast } = await import('../../../commands/fast/index.js')
const { default: installGitHubApp } = await import(
'../../../commands/install-github-app/index.js'
)

const envProviderCases = [
['CLAUDE_CODE_USE_BEDROCK', 'bedrock'],
['CLAUDE_CODE_USE_VERTEX', 'vertex'],
['CLAUDE_CODE_USE_FOUNDRY', 'foundry'],
['CLAUDE_CODE_USE_OPENAI', 'openai'],
['CLAUDE_CODE_USE_GEMINI', 'gemini'],
['CLAUDE_CODE_USE_GROK', 'grok'],
] as const

beforeEach(() => {
clearIsolatedEnv()
resetSettingsCache()
setModelType()
})

afterAll(() => {
clearCommandsCache()
resetSettingsCache()
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})

describe('Console command availability', () => {
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
test(`rejects settings modelType=${modelType}`, () => {
setModelType(modelType)

expect(meetsAvailabilityRequirement(fast)).toBe(false)
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(false)
})
}

for (const [envKey] of envProviderCases) {
test(`rejects ${envKey}`, () => {
process.env[envKey] = '1'

expect(meetsAvailabilityRequirement(fast)).toBe(false)
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(false)
})
}

test('continues accepting direct first-party Anthropic', () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
expect(meetsAvailabilityRequirement(fast)).toBe(true)
expect(meetsAvailabilityRequirement(installGitHubApp)).toBe(true)
})
})

describe('Anthropic auth provider gate', () => {
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
test(`disables Anthropic auth for settings modelType=${modelType}`, () => {
setModelType(modelType)
expect(isAnthropicAuthEnabled()).toBe(false)
})
}

for (const [envKey] of envProviderCases) {
test(`disables Anthropic auth for ${envKey}`, () => {
process.env[envKey] = '1'
expect(isAnthropicAuthEnabled()).toBe(false)
})
}

for (const envKey of ['OPENAI_BASE_URL', 'GEMINI_BASE_URL'] as const) {
test(`disables Anthropic auth for ${envKey}`, () => {
process.env[envKey] = 'https://provider-gates.invalid'
expect(isAnthropicAuthEnabled()).toBe(false)
})
}

test('continues enabling Anthropic auth for first-party OAuth', () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
expect(isAnthropicAuthEnabled()).toBe(true)
})
})

describe('environment-only third-party compatibility gate', () => {
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
test(`ignores settings modelType=${modelType}`, () => {
setModelType(modelType)

expect(isUsing3PServices()).toBe(false)
})
}
})

class ExitSignal extends Error {
constructor(readonly code: string | number | null | undefined) {
super(`process.exit(${String(code)})`)
}
}

async function captureAuthStatus() {
let stdout = ''
const writeSpy = spyOn(process.stdout, 'write').mockImplementation(((
chunk: string | Uint8Array,
) => {
stdout += chunk.toString()
return true
}) as typeof process.stdout.write)
const exitSpy = spyOn(process, 'exit').mockImplementation(((
code?: string | number | null,
): never => {
throw new ExitSignal(code)
}) as typeof process.exit)

let exitCode: ExitSignal['code']
try {
await authStatus({ json: true })
throw new Error('authStatus did not call process.exit')
} catch (error) {
if (!(error instanceof ExitSignal)) throw error
exitCode = error.code
} finally {
exitSpy.mockRestore()
writeSpy.mockRestore()
}

return {
exitCode,
output: JSON.parse(stdout) as Record<string, unknown>,
}
}

describe('auth status provider reporting', () => {
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
test(`reports settings modelType=${modelType} as third_party`, async () => {
setModelType(modelType)
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'

const { exitCode, output } = await captureAuthStatus()

expect(exitCode).toBe(0)
expect(output.apiProvider).toBe(modelType)
expect(output.authMethod).toBe('third_party')
expect(output.loggedIn).toBe(true)
})
}

for (const [envKey, apiProvider] of envProviderCases) {
test(`reports ${envKey} as third_party`, async () => {
process.env[envKey] = '1'
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'

const { exitCode, output } = await captureAuthStatus()

expect(exitCode).toBe(0)
expect(output.apiProvider).toBe(apiProvider)
expect(output.authMethod).toBe('third_party')
expect(output.loggedIn).toBe(true)
})
}

test('continues reporting direct first-party OAuth', async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'

const { exitCode, output } = await captureAuthStatus()

expect(exitCode).toBe(0)
expect(output.apiProvider).toBe('firstParty')
expect(output.authMethod).toBe('oauth_token')
expect(output.loggedIn).toBe(true)
})
})

describe('login and logout visibility', () => {
for (const modelType of ['openai', 'gemini', 'grok'] as const) {
test(`keeps both commands visible for settings modelType=${modelType}`, async () => {
setModelType(modelType)
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'provider-gates-test-token'
clearCommandsCache()

const commandNames = new Set(
(await getCommands(testRoot)).map(command => command.name),
)

expect(commandNames.has('login')).toBe(true)
expect(commandNames.has('logout')).toBe(true)
})
}
})
Loading