diff --git a/src/cli/handlers/auth.ts b/src/cli/handlers/auth.ts index 6e39d8abbf..8f74e75323 100644 --- a/src/cli/handlers/auth.ts +++ b/src/cli/handlers/auth.ts @@ -26,7 +26,6 @@ import { getAuthTokenSource, getOauthAccountInfo, getSubscriptionType, - isUsing3PServices, saveOAuthTokensIfNeeded, validateForceLoginOrg, } from '../../utils/auth.js' @@ -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 { @@ -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 diff --git a/src/commands.ts b/src/commands.ts index bcfb6399f4..8cab94d5fd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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' @@ -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 diff --git a/src/utils/auth.ts b/src/utils/auth.ts index b650b704e1..901ac7e37c 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -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, @@ -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 || @@ -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()) @@ -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 !!( diff --git a/src/utils/model/__tests__/providerGates.runner.ts b/src/utils/model/__tests__/providerGates.runner.ts new file mode 100644 index 0000000000..6059f09c5d --- /dev/null +++ b/src/utils/model/__tests__/providerGates.runner.ts @@ -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, + } +} + +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) + }) + } +}) diff --git a/src/utils/model/__tests__/providerGates.test.ts b/src/utils/model/__tests__/providerGates.test.ts new file mode 100644 index 0000000000..2a34f81e30 --- /dev/null +++ b/src/utils/model/__tests__/providerGates.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' +import { relative, resolve } from 'path' + +const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..') +const RUNNER_ABS = resolve(__dirname, 'providerGates.runner.ts') +const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/') + +describe('provider gates', () => { + test('runs provider gate regressions in an isolated process', async () => { + const proc = Bun.spawn([process.execPath, 'test', RUNNER_REL], { + cwd: PROJECT_ROOT, + stdout: 'pipe', + stderr: 'pipe', + }) + const code = await proc.exited + if (code !== 0) { + const stderr = await new Response(proc.stderr).text() + const stdout = await new Response(proc.stdout).text() + const output = (stderr + '\n' + stdout).slice(-5000) + throw new Error( + `provider gate test subprocess failed (exit ${code}):\n${output}`, + ) + } + + expect(code).toBe(0) + }, 60_000) +}) diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index 50e096585c..989b4d4fdb 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { + type APIProvider, + isThirdPartyAPIProvider, +} from '../providerClassification.js' /** * Inlined provider logic for hermetic testing. @@ -11,15 +15,6 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' * importing anything that can be polluted. */ -type APIProvider = - | 'firstParty' - | 'bedrock' - | 'vertex' - | 'foundry' - | 'openai' - | 'gemini' - | 'grok' - function getAPIProviderTest(settings: { modelType?: string }): APIProvider { const modelType = settings.modelType if (modelType === 'openai') return 'openai' @@ -61,6 +56,33 @@ function getAPIProviderTest(settings: { modelType?: string }): APIProvider { return 'firstParty' } +describe('isThirdPartyAPIProvider', () => { + test('returns false only for the first-party Anthropic provider', () => { + expect(isThirdPartyAPIProvider('firstParty')).toBe(false) + }) + + for (const provider of [ + 'bedrock', + 'vertex', + 'foundry', + 'openai', + 'gemini', + 'grok', + ] as const satisfies readonly APIProvider[]) { + test(`returns true for ${provider}`, () => { + expect(isThirdPartyAPIProvider(provider)).toBe(true) + }) + } + + for (const modelType of ['openai', 'gemini', 'grok'] as const) { + test(`classifies settings modelType=${modelType} as third-party`, () => { + expect(isThirdPartyAPIProvider(getAPIProviderTest({ modelType }))).toBe( + true, + ) + }) + } +}) + function isFirstPartyAnthropicBaseUrlTest(): boolean { const baseUrl = process.env.ANTHROPIC_BASE_URL if (!baseUrl) return true diff --git a/src/utils/model/providerClassification.ts b/src/utils/model/providerClassification.ts new file mode 100644 index 0000000000..074a699771 --- /dev/null +++ b/src/utils/model/providerClassification.ts @@ -0,0 +1,12 @@ +export type APIProvider = + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' + | 'openai' + | 'gemini' + | 'grok' + +export function isThirdPartyAPIProvider(provider: APIProvider): boolean { + return provider !== 'firstParty' +} diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index d4784da844..7b0c195e9e 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -2,15 +2,12 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from import { getInitialSettings } from '../settings/settings.js' import type { SettingsJson } from '../settings/types.js' import { isEnvTruthy } from '../envUtils.js' +import type { APIProvider } from './providerClassification.js' -export type APIProvider = - | 'firstParty' - | 'bedrock' - | 'vertex' - | 'foundry' - | 'openai' - | 'gemini' - | 'grok' +export { + type APIProvider, + isThirdPartyAPIProvider, +} from './providerClassification.js' export function getAPIProvider( settings: Pick = getInitialSettings(),