feat: add Gemini ADC and access token auth (#312)

* feat: add Gemini ADC and access token auth

* feat: add Gemini token and ADC provider setup

* feat: add Gemini token and ADC provider setup

* fix: honor Gemini auth mode on restart
This commit is contained in:
Vasanth T
2026-04-04 15:07:17 +05:30
committed by GitHub
parent 280c9732f5
commit ea335aeddc
15 changed files with 1128 additions and 130 deletions

View File

@@ -0,0 +1,186 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
getGeminiProjectIdHint,
mayHaveGeminiAdcCredentials,
resolveGeminiCredential,
} from './geminiAuth.ts'
const existingFilePath = import.meta.path
const originalEnv = {
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
GCLOUD_PROJECT: process.env.GCLOUD_PROJECT,
GOOGLE_PROJECT_ID: process.env.GOOGLE_PROJECT_ID,
APPDATA: process.env.APPDATA,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv(
'GOOGLE_APPLICATION_CREDENTIALS',
originalEnv.GOOGLE_APPLICATION_CREDENTIALS,
)
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
restoreEnv('GCLOUD_PROJECT', originalEnv.GCLOUD_PROJECT)
restoreEnv('GOOGLE_PROJECT_ID', originalEnv.GOOGLE_PROJECT_ID)
restoreEnv('APPDATA', originalEnv.APPDATA)
})
describe('resolveGeminiCredential', () => {
test('prefers GEMINI_API_KEY over other Gemini auth inputs', async () => {
process.env.GEMINI_API_KEY = 'gem-key'
process.env.GOOGLE_API_KEY = 'google-key'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'api-key',
credential: 'gem-key',
})
})
test('uses GEMINI_ACCESS_TOKEN when no API key is configured', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_AUTH_MODE = 'access-token'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
process.env.GOOGLE_CLOUD_PROJECT = 'test-project'
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'access-token',
credential: 'token-123',
projectId: 'test-project',
})
})
test('falls back to ADC when available', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
process.env.GEMINI_AUTH_MODE = 'adc'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
async getProjectId() {
return 'adc-project'
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
})
})
test('returns none when no Gemini auth source is configured', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
delete process.env.GOOGLE_APPLICATION_CREDENTIALS
await expect(resolveGeminiCredential(process.env)).resolves.toEqual({
kind: 'none',
})
})
test('access-token mode does not silently fall back to ADC', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
process.env.GEMINI_AUTH_MODE = 'access-token'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'none',
})
})
test('adc mode ignores GEMINI_ACCESS_TOKEN and uses ADC credentials', async () => {
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_AUTH_MODE = 'adc'
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
const fakeAuth = {
async getClient() {
return {
async getAccessToken() {
return { token: 'adc-token' }
},
}
},
async getProjectId() {
return 'adc-project'
},
}
await expect(
resolveGeminiCredential(process.env, {
createGoogleAuth: async () => fakeAuth,
}),
).resolves.toEqual({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
})
})
})
describe('Gemini auth helpers', () => {
test('detects explicit project id hints', () => {
process.env.GOOGLE_PROJECT_ID = 'project-a'
expect(getGeminiProjectIdHint(process.env)).toBe('project-a')
})
test('only treats existing ADC paths as valid hints', () => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = existingFilePath
expect(mayHaveGeminiAdcCredentials(process.env)).toBe(true)
process.env.GOOGLE_APPLICATION_CREDENTIALS = `${existingFilePath}.missing`
process.env.APPDATA = undefined
expect(mayHaveGeminiAdcCredentials(process.env)).toBe(false)
})
})

216
src/utils/geminiAuth.ts Normal file
View File

@@ -0,0 +1,216 @@
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { memoizeWithTTLAsync } from './memoize.js'
const GEMINI_ADC_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
const GEMINI_ADC_CACHE_TTL_MS = 5 * 60 * 1000
export type GeminiAuthMode = 'api-key' | 'access-token' | 'adc'
type GoogleAccessTokenResult =
| string
| null
| undefined
| {
token?: string | null
}
type GoogleAuthClientLike = {
getAccessToken(): Promise<GoogleAccessTokenResult> | GoogleAccessTokenResult
}
type GoogleAuthLike = {
getClient(): Promise<GoogleAuthClientLike>
getProjectId?(): Promise<string>
}
export type GeminiResolvedCredential =
| {
kind: 'api-key'
credential: string
}
| {
kind: 'access-token' | 'adc'
credential: string
projectId?: string
}
| {
kind: 'none'
}
type ResolveGeminiCredentialDeps = {
createGoogleAuth?: () => Promise<GoogleAuthLike>
}
function sanitizeCredential(value: string | undefined | null): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
export function getGeminiProjectIdHint(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
return (
sanitizeCredential(env.GOOGLE_CLOUD_PROJECT) ??
sanitizeCredential(env.GCLOUD_PROJECT) ??
sanitizeCredential(env.GOOGLE_PROJECT_ID)
)
}
export function getGeminiAuthMode(
env: NodeJS.ProcessEnv = process.env,
): GeminiAuthMode | undefined {
const normalized = sanitizeCredential(env.GEMINI_AUTH_MODE)?.toLowerCase()
if (
normalized === 'api-key' ||
normalized === 'access-token' ||
normalized === 'adc'
) {
return normalized
}
return undefined
}
export function getGeminiAdcCredentialPaths(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const explicit = sanitizeCredential(env.GOOGLE_APPLICATION_CREDENTIALS)
const paths = new Set<string>()
if (explicit) {
paths.add(explicit)
}
paths.add(join(homedir(), '.config', 'gcloud', 'application_default_credentials.json'))
const appData = sanitizeCredential(env.APPDATA)
if (appData) {
paths.add(join(appData, 'gcloud', 'application_default_credentials.json'))
}
return [...paths]
}
export function mayHaveGeminiAdcCredentials(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return getGeminiAdcCredentialPaths(env).some(path => existsSync(path))
}
function normalizeAccessToken(
value: GoogleAccessTokenResult,
): string | undefined {
if (typeof value === 'string') {
return sanitizeCredential(value)
}
return sanitizeCredential(value?.token)
}
async function createDefaultGoogleAuth(): Promise<GoogleAuthLike> {
const { GoogleAuth } = await import('google-auth-library')
return new GoogleAuth({
scopes: [GEMINI_ADC_SCOPE],
}) as GoogleAuthLike
}
async function resolveGeminiAdcCredentialUncached(
env: NodeJS.ProcessEnv,
deps: ResolveGeminiCredentialDeps,
): Promise<Exclude<GeminiResolvedCredential, { kind: 'none' | 'api-key' | 'access-token' }> | { kind: 'none' }> {
if (!mayHaveGeminiAdcCredentials(env)) {
return { kind: 'none' }
}
try {
const auth = await (deps.createGoogleAuth ?? createDefaultGoogleAuth)()
const client = await auth.getClient()
const accessToken = normalizeAccessToken(await client.getAccessToken())
if (!accessToken) {
return { kind: 'none' }
}
const hintedProjectId = getGeminiProjectIdHint(env)
const resolvedProjectId =
hintedProjectId ??
(typeof auth.getProjectId === 'function'
? sanitizeCredential(await auth.getProjectId().catch(() => undefined))
: undefined)
return {
kind: 'adc',
credential: accessToken,
...(resolvedProjectId ? { projectId: resolvedProjectId } : {}),
}
} catch {
return { kind: 'none' }
}
}
const resolveDefaultGeminiAdcCredential = memoizeWithTTLAsync(
async (
googleApplicationCredentials: string | undefined,
appData: string | undefined,
home: string,
projectIdHint: string | undefined,
) =>
resolveGeminiAdcCredentialUncached(
{
GOOGLE_APPLICATION_CREDENTIALS: googleApplicationCredentials,
APPDATA: appData,
GOOGLE_CLOUD_PROJECT: projectIdHint,
GCLOUD_PROJECT: projectIdHint,
GOOGLE_PROJECT_ID: projectIdHint,
HOME: home,
} as NodeJS.ProcessEnv,
{},
),
GEMINI_ADC_CACHE_TTL_MS,
)
export async function resolveGeminiCredential(
env: NodeJS.ProcessEnv = process.env,
deps: ResolveGeminiCredentialDeps = {},
): Promise<GeminiResolvedCredential> {
const authMode = getGeminiAuthMode(env)
const apiKey =
authMode === 'access-token' || authMode === 'adc'
? undefined
: sanitizeCredential(env.GEMINI_API_KEY) ??
sanitizeCredential(env.GOOGLE_API_KEY)
if (apiKey && (authMode === undefined || authMode === 'api-key')) {
return {
kind: 'api-key',
credential: apiKey,
}
}
const accessToken =
authMode === 'api-key' || authMode === 'adc'
? undefined
: sanitizeCredential(env.GEMINI_ACCESS_TOKEN)
if (accessToken && (authMode === undefined || authMode === 'access-token')) {
const projectId = getGeminiProjectIdHint(env)
return {
kind: 'access-token',
credential: accessToken,
...(projectId ? { projectId } : {}),
}
}
if (authMode === 'api-key' || authMode === 'access-token') {
return { kind: 'none' }
}
if (deps.createGoogleAuth) {
return resolveGeminiAdcCredentialUncached(env, deps)
}
return resolveDefaultGeminiAdcCredential(
sanitizeCredential(env.GOOGLE_APPLICATION_CREDENTIALS),
sanitizeCredential(env.APPDATA),
homedir(),
getGeminiProjectIdHint(env),
)
}

View File

@@ -0,0 +1,31 @@
import { afterEach, expect, test } from 'bun:test'
import {
clearGeminiAccessToken,
readGeminiAccessToken,
saveGeminiAccessToken,
} from './geminiCredentials.ts'
const originalToken = process.env.GEMINI_ACCESS_TOKEN
afterEach(() => {
if (originalToken === undefined) {
delete process.env.GEMINI_ACCESS_TOKEN
} else {
process.env.GEMINI_ACCESS_TOKEN = originalToken
}
clearGeminiAccessToken()
})
test('saveGeminiAccessToken stores and reads back the token', () => {
const result = saveGeminiAccessToken('token-123')
expect(result.success).toBe(true)
expect(readGeminiAccessToken()).toBe('token-123')
})
test('clearGeminiAccessToken removes the stored token', () => {
expect(saveGeminiAccessToken('token-123').success).toBe(true)
expect(clearGeminiAccessToken().success).toBe(true)
expect(readGeminiAccessToken()).toBeUndefined()
})

View File

@@ -0,0 +1,76 @@
import { isBareMode, isEnvTruthy } from './envUtils.js'
import { getGeminiAuthMode } from './geminiAuth.js'
import { getSecureStorage } from './secureStorage/index.js'
export const GEMINI_TOKEN_STORAGE_KEY = 'gemini' as const
export type GeminiCredentialBlob = {
accessToken: string
}
export function readGeminiAccessToken(): string | undefined {
if (isBareMode()) return undefined
try {
const data = getSecureStorage().read() as
| ({ gemini?: GeminiCredentialBlob } & Record<string, unknown>)
| null
const token = data?.gemini?.accessToken?.trim()
return token || undefined
} catch {
return undefined
}
}
export function hydrateGeminiAccessTokenFromSecureStorage(): void {
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
return
}
const authMode = getGeminiAuthMode(process.env)
if (authMode && authMode !== 'access-token') {
return
}
if (process.env.GEMINI_ACCESS_TOKEN?.trim()) {
return
}
if (isBareMode()) {
return
}
const token = readGeminiAccessToken()
if (token) {
process.env.GEMINI_ACCESS_TOKEN = token
}
}
export function saveGeminiAccessToken(token: string): {
success: boolean
warning?: string
} {
if (isBareMode()) {
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
}
const trimmed = token.trim()
if (!trimmed) {
return { success: false, warning: 'Token is empty.' }
}
const secureStorage = getSecureStorage()
const previous = secureStorage.read() || {}
const next = {
...(previous as Record<string, unknown>),
[GEMINI_TOKEN_STORAGE_KEY]: { accessToken: trimmed },
}
return secureStorage.update(next as typeof previous)
}
export function clearGeminiAccessToken(): {
success: boolean
warning?: string
} {
if (isBareMode()) {
return { success: true }
}
const secureStorage = getSecureStorage()
const previous = secureStorage.read() || {}
const next = { ...(previous as Record<string, unknown>) }
delete next[GEMINI_TOKEN_STORAGE_KEY]
return secureStorage.update(next as typeof previous)
}

View File

@@ -355,11 +355,38 @@ test('gemini profiles accept google api key fallback', () => {
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'api-key',
GEMINI_MODEL: 'gemini-2.0-flash',
GEMINI_API_KEY: 'gem-live',
})
})
test('gemini profiles support access-token auth mode without persisting a key', () => {
const env = buildGeminiProfileEnv({
authMode: 'access-token',
model: 'gemini-2.5-flash',
processEnv: {},
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'access-token',
GEMINI_MODEL: 'gemini-2.5-flash',
})
})
test('gemini profiles support adc auth mode without persisting a key', () => {
const env = buildGeminiProfileEnv({
authMode: 'adc',
model: 'gemini-2.5-flash',
processEnv: {},
})
assert.deepEqual(env, {
GEMINI_AUTH_MODE: 'adc',
GEMINI_MODEL: 'gemini-2.5-flash',
})
})
test('gemini profiles require a key', () => {
const env = buildGeminiProfileEnv({
processEnv: {},
@@ -405,6 +432,39 @@ test('buildStartupEnvFromProfile applies persisted gemini settings when no provi
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
})
test('buildStartupEnvFromProfile rehydrates stored Gemini access token for access-token profile mode', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', {
GEMINI_AUTH_MODE: 'access-token',
GEMINI_MODEL: 'gemini-2.5-flash',
}),
processEnv: {},
readGeminiAccessToken: () => 'token-live',
})
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_AUTH_MODE, 'access-token')
assert.equal(env.GEMINI_ACCESS_TOKEN, 'token-live')
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
})
test('buildStartupEnvFromProfile does not inject stored access token for adc profile mode', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', {
GEMINI_AUTH_MODE: 'adc',
GEMINI_MODEL: 'gemini-2.5-flash',
}),
processEnv: {},
readGeminiAccessToken: () => 'token-live',
})
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_AUTH_MODE, 'adc')
assert.equal(env.GEMINI_ACCESS_TOKEN, undefined)
assert.equal(env.GEMINI_API_KEY, undefined)
})
test('buildStartupEnvFromProfile leaves explicit provider selections untouched', async () => {
const processEnv = {
CLAUDE_CODE_USE_GEMINI: '1',

View File

@@ -12,6 +12,7 @@ import {
normalizeRecommendationGoal,
type RecommendationGoal,
} from './providerRecommendation.ts'
import { readGeminiAccessToken } from './geminiCredentials.ts'
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
@@ -32,6 +33,8 @@ const PROFILE_ENV_KEYS = [
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY',
'GEMINI_AUTH_MODE',
'GEMINI_ACCESS_TOKEN',
'GEMINI_MODEL',
'GEMINI_BASE_URL',
'GOOGLE_API_KEY',
@@ -54,6 +57,7 @@ export type ProfileEnv = {
CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
GEMINI_MODEL?: string
GEMINI_BASE_URL?: string
}
@@ -220,19 +224,22 @@ export function buildGeminiProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
authMode?: 'api-key' | 'access-token' | 'adc'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
const authMode = options.authMode ?? 'api-key'
const key = sanitizeApiKey(
options.apiKey ??
processEnv.GEMINI_API_KEY ??
processEnv.GOOGLE_API_KEY,
)
if (!key) {
if (authMode === 'api-key' && !key) {
return null
}
const env: ProfileEnv = {
GEMINI_AUTH_MODE: authMode,
GEMINI_MODEL:
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
sanitizeProviderConfigValue(
@@ -241,7 +248,10 @@ export function buildGeminiProfileEnv(options: {
processEnv,
) ||
DEFAULT_GEMINI_MODEL,
GEMINI_API_KEY: key,
}
if (authMode === 'api-key' && key) {
env.GEMINI_API_KEY = key
}
const baseUrl =
@@ -422,6 +432,7 @@ export async function buildLaunchEnv(options: {
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
resolveAtomicChatDefaultModel?: () => Promise<string | null>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options.processEnv ?? process.env
const persistedEnv =
@@ -460,11 +471,16 @@ export async function buildLaunchEnv(options: {
processEnv.GEMINI_BASE_URL,
processEnv,
)
const shellGeminiAccessToken =
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
const storedGeminiAccessToken =
options.readGeminiAccessToken?.() ?? readGeminiAccessToken()
const shellGeminiKey = sanitizeApiKey(
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
)
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
const persistedGeminiAuthMode = persistedEnv.GEMINI_AUTH_MODE
if (options.profile === 'gemini') {
const env: NodeJS.ProcessEnv = {
@@ -484,12 +500,29 @@ export async function buildLaunchEnv(options: {
persistedGeminiBaseUrl ||
DEFAULT_GEMINI_BASE_URL
const geminiAuthMode =
persistedGeminiAuthMode === 'access-token' ||
persistedGeminiAuthMode === 'adc'
? persistedGeminiAuthMode
: 'api-key'
const geminiKey = shellGeminiKey || persistedGeminiKey
if (geminiKey) {
if (geminiAuthMode === 'api-key' && geminiKey) {
env.GEMINI_API_KEY = geminiKey
} else {
delete env.GEMINI_API_KEY
}
env.GEMINI_AUTH_MODE = geminiAuthMode
if (geminiAuthMode === 'access-token') {
const geminiAccessToken =
shellGeminiAccessToken || storedGeminiAccessToken
if (geminiAccessToken) {
env.GEMINI_ACCESS_TOKEN = geminiAccessToken
} else {
delete env.GEMINI_ACCESS_TOKEN
}
} else {
delete env.GEMINI_ACCESS_TOKEN
}
delete env.GOOGLE_API_KEY
delete env.OPENAI_BASE_URL
@@ -510,6 +543,8 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
delete env.GEMINI_MODEL
delete env.GEMINI_BASE_URL
delete env.GOOGLE_API_KEY
@@ -624,6 +659,7 @@ export async function buildStartupEnvFromProfile(options?: {
processEnv?: NodeJS.ProcessEnv
getOllamaChatBaseUrl?: (baseUrl?: string) => string
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
readGeminiAccessToken?: () => string | undefined
}): Promise<NodeJS.ProcessEnv> {
const processEnv = options?.processEnv ?? process.env
if (hasExplicitProviderSelection(processEnv)) {
@@ -645,6 +681,7 @@ export async function buildStartupEnvFromProfile(options?: {
getOllamaChatBaseUrl:
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
readGeminiAccessToken: options?.readGeminiAccessToken,
})
}

View File

@@ -0,0 +1,73 @@
import { afterEach, expect, test } from 'bun:test'
import { getProviderValidationError } from './providerValidation.ts'
const originalEnv = {
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS,
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
afterEach(() => {
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
restoreEnv(
'GOOGLE_APPLICATION_CREDENTIALS',
originalEnv.GOOGLE_APPLICATION_CREDENTIALS,
)
})
test('accepts GEMINI_ACCESS_TOKEN as valid Gemini auth', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'access-token'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
process.env.GEMINI_ACCESS_TOKEN = 'token-123'
await expect(getProviderValidationError(process.env)).resolves.toBeNull()
})
test('accepts ADC credentials for Gemini auth', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'adc'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
await expect(
getProviderValidationError(process.env, {
resolveGeminiCredential: async () => ({
kind: 'adc',
credential: 'adc-token',
projectId: 'adc-project',
}),
}),
).resolves.toBeNull()
})
test('still errors when no Gemini credential source is available', async () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_AUTH_MODE = 'access-token'
delete process.env.GEMINI_API_KEY
delete process.env.GOOGLE_API_KEY
delete process.env.GEMINI_ACCESS_TOKEN
delete process.env.GOOGLE_APPLICATION_CREDENTIALS
await expect(getProviderValidationError(process.env)).resolves.toBe(
'GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_ACCESS_TOKEN, or Google ADC credentials are required when CLAUDE_CODE_USE_GEMINI=1.',
)
})

View File

@@ -0,0 +1,96 @@
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.js'
import {
type GeminiResolvedCredential,
resolveGeminiCredential,
} from './geminiAuth.js'
import { redactSecretValueForDisplay } from './providerProfile.js'
function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
}
export async function getProviderValidationError(
env: NodeJS.ProcessEnv = process.env,
options?: {
resolveGeminiCredential?: (
env: NodeJS.ProcessEnv,
) => Promise<GeminiResolvedCredential>
},
): Promise<string | null> {
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
const geminiCredential = await (
options?.resolveGeminiCredential ?? resolveGeminiCredential
)(env)
if (geminiCredential.kind === 'none') {
return 'GEMINI_API_KEY, GOOGLE_API_KEY, GEMINI_ACCESS_TOKEN, or Google ADC credentials are required when CLAUDE_CODE_USE_GEMINI=1.'
}
return null
}
if (useGithub && !useOpenAI) {
const token = (env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim()) ?? ''
if (!token) {
return 'GITHUB_TOKEN or GH_TOKEN is required when CLAUDE_CODE_USE_GITHUB=1.'
}
return null
}
if (!useOpenAI) {
return null
}
const request = resolveProviderRequest({
model: env.OPENAI_MODEL,
baseUrl: env.OPENAI_BASE_URL,
})
if (env.OPENAI_API_KEY === 'SUA_CHAVE') {
return 'Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.'
}
if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) {
const authHint = credentials.authPath
? ` or put auth.json at ${credentials.authPath}`
: ''
const safeModel =
redactSecretValueForDisplay(request.requestedModel, env) ??
'the requested model'
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
}
if (!credentials.accountId) {
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
}
return null
}
if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
const hasGithubToken = !!(env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim())
if (useGithub && hasGithubToken) {
return null
}
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
}
return null
}
export async function validateProviderEnvOrExit(
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
const error = await getProviderValidationError(env)
if (error) {
console.error(error)
process.exit(1)
}
}

View File

@@ -25,24 +25,24 @@ describe("Secure Storage Platform Implementations", () => {
process.env = originalEnv;
});
const testData = {
mcpOAuth: {
"test-server": {
accessToken: "secret-token",
expiresAt: 123456789,
serverName: "test",
serverUrl: "http://test"
}
}
const testData = {
mcpOAuth: {
"test-server": {
accessToken: "secret-token",
expiresAt: 123456789,
serverName: "test",
serverUrl: "http://test"
}
}
};
describe("Config-Dir Isolation", () => {
test("service name changes with CLAUDE_CONFIG_DIR", () => {
const defaultName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
process.env.CLAUDE_CONFIG_DIR = "/tmp/other-config";
const otherName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
expect(otherName).not.toBe(defaultName);
expect(otherName).toContain("Claude Code");
expect(otherName).toContain(CREDENTIALS_SERVICE_SUFFIX);
@@ -51,9 +51,9 @@ describe("Secure Storage Platform Implementations", () => {
test("Linux storage uses scoped service name", () => {
process.env.CLAUDE_CONFIG_DIR = "/tmp/linux-scoped";
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
linuxSecretStorage.update(testData);
const args = mockExecaSync.mock.calls[0];
expect(args[1]).toContain(expectedName);
});
@@ -61,9 +61,9 @@ describe("Secure Storage Platform Implementations", () => {
test("Windows storage uses scoped resource name", () => {
process.env.CLAUDE_CONFIG_DIR = "/tmp/win-scoped";
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
windowsCredentialStorage.update(testData);
const script = mockExecaSync.mock.calls[0][1][1];
expect(script).toContain(expectedName);
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
@@ -72,19 +72,19 @@ describe("Secure Storage Platform Implementations", () => {
describe("Windows PowerShell Escaping", () => {
test("escapes single quotes and prevents $ expansion", () => {
const dataWithDollar = {
mcpOAuth: {
"server": {
const dataWithDollar = {
mcpOAuth: {
"server": {
accessToken: "token-with-$env:USERNAME",
expiresAt: 123,
serverName: "s",
serverUrl: "u"
}
}
}
}
};
windowsCredentialStorage.update(dataWithDollar);
const script = mockExecaSync.mock.calls[0][1][1];
// Should use single quotes for the payload
expect(script).toMatch(/'\{.*\}'/);
@@ -92,7 +92,7 @@ describe("Secure Storage Platform Implementations", () => {
expect(script).not.toContain("'token-with-$env:USERNAME'");
// But since it's JSON, the value will be "token-with-$env:USERNAME" inside the single-quoted string
// The JSON itself shouldn't have single quotes unless the data has them.
const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
windowsCredentialStorage.update(dataWithQuote);
const script2 = mockExecaSync.mock.calls[1][1][1];
@@ -117,7 +117,7 @@ describe("Secure Storage Platform Implementations", () => {
describe("Linux secret-tool Interaction", () => {
test("update passes payload via stdin", () => {
linuxSecretStorage.update(testData);
const options = mockExecaSync.mock.calls[0][2];
expect(options.input).toContain("secret-token");
});
@@ -125,7 +125,7 @@ describe("Secure Storage Platform Implementations", () => {
test("read parses stdout", () => {
mockExecaSync.mockReturnValue({ exitCode: 0, stdout: JSON.stringify(testData) });
const result = linuxSecretStorage.read();
expect(result).toEqual(testData);
});
});