diff --git a/README.md b/README.md
index 36eed3eb..bee390ba 100644
--- a/README.md
+++ b/README.md
@@ -116,13 +116,15 @@ Advanced and source-build guides:
| Provider | Setup Path | Notes |
| --- | --- | --- |
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and compatible local `/v1` servers |
-| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer |
+| Gemini | `/provider` or env vars | Google Gemini support through the runtime provider layer (API key, access token, or local ADC) |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available |
| Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
+For Gemini, `/provider` can now save either the API-key path, a securely stored access-token path, or a local ADC profile.
+
---
## What Works
diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx
index 7f5560dc..c04c2d18 100644
--- a/src/commands/provider/provider.test.tsx
+++ b/src/commands/provider/provider.test.tsx
@@ -197,6 +197,23 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
expect(message).not.toContain('sk-secret-12345678')
})
+test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', () => {
+ const message = buildProfileSaveMessage(
+ 'gemini',
+ {
+ GEMINI_AUTH_MODE: 'access-token',
+ GEMINI_MODEL: 'gemini-2.5-flash',
+ GEMINI_BASE_URL: 'https://generativelanguage.googleapis.com/v1beta/openai',
+ },
+ 'D:/codings/Opensource/openclaude/.openclaude-profile.json',
+ )
+
+ expect(message).toContain('Saved Google Gemini profile.')
+ expect(message).toContain('Model: gemini-2.5-flash')
+ expect(message).toContain('Credentials: access token (stored securely)')
+ expect(message).not.toContain('AIza')
+})
+
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({
processEnv: {
diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx
index b43b6c3d..6bf8a06c 100644
--- a/src/commands/provider/provider.tsx
+++ b/src/commands/provider/provider.tsx
@@ -36,6 +36,14 @@ import {
type ProfileFile,
type ProviderProfile,
} from '../../utils/providerProfile.js'
+import {
+ getGeminiProjectIdHint,
+ mayHaveGeminiAdcCredentials,
+} from '../../utils/geminiAuth.js'
+import {
+ readGeminiAccessToken,
+ saveGeminiAccessToken,
+} from '../../utils/geminiCredentials.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -60,8 +68,14 @@ type Step =
baseUrl: string | null
defaultModel: string
}
+ | { name: 'gemini-auth-method' }
| { name: 'gemini-key' }
- | { name: 'gemini-model'; apiKey: string }
+ | { name: 'gemini-access-token' }
+ | {
+ name: 'gemini-model'
+ apiKey?: string
+ authMode: 'api-key' | 'access-token' | 'adc'
+ }
| { name: 'codex-check' }
type CurrentProviderSummary = {
@@ -216,9 +230,13 @@ function buildSavedProfileSummary(
env,
),
credentialLabel:
- maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
- ? 'configured'
- : undefined,
+ env.GEMINI_AUTH_MODE === 'access-token'
+ ? 'access token (stored securely)'
+ : env.GEMINI_AUTH_MODE === 'adc'
+ ? 'local ADC'
+ : maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
+ ? 'configured'
+ : undefined,
}
case 'codex':
return {
@@ -427,7 +445,7 @@ function ProviderChooser({
{
label: 'Gemini',
value: 'gemini',
- description: 'Use a Google Gemini API key',
+ description: 'Use Google Gemini with API key, access token, or local ADC',
},
{
label: 'Codex',
@@ -926,7 +944,7 @@ export function ProviderWizard({
defaultModel: defaults.openAIModel,
})
} else if (value === 'gemini') {
- setStep({ name: 'gemini-key' })
+ setStep({ name: 'gemini-auth-method' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1066,12 +1084,76 @@ export function ProviderWizard({
/>
)
+ case 'gemini-auth-method': {
+ const hasShellGeminiKey = Boolean(
+ process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
+ )
+ const hasShellGeminiAccessToken = Boolean(process.env.GEMINI_ACCESS_TOKEN)
+ const hasStoredGeminiAccessToken = Boolean(readGeminiAccessToken())
+ const hasAdc = mayHaveGeminiAdcCredentials(process.env)
+ const projectHint = getGeminiProjectIdHint(process.env)
+
+ const options: OptionWithDescription[] = [
+ {
+ label: 'API key',
+ value: 'api-key',
+ description: hasShellGeminiKey
+ ? 'Use the current Gemini API key from this shell, or enter a new one'
+ : 'Use a Google Gemini API key',
+ },
+ {
+ label: 'Access token',
+ value: 'access-token',
+ description: hasShellGeminiAccessToken || hasStoredGeminiAccessToken
+ ? `Use ${
+ hasShellGeminiAccessToken
+ ? 'the current GEMINI_ACCESS_TOKEN'
+ : 'the securely stored Gemini access token'
+ }`
+ : 'Enter a Gemini access token and store it securely',
+ },
+ {
+ label: 'Local ADC',
+ value: 'adc',
+ description: hasAdc
+ ? `Use local Google ADC credentials${projectHint ? ` (project: ${projectHint})` : ''}`
+ : 'Use local Google ADC credentials after running gcloud auth application-default login',
+ },
+ ]
+
+ return (
+
+ )
+ }
+
case 'gemini-key':
return (
setStep({ name: 'choose' })}
+ onCancel={() => setStep({ name: 'gemini-auth-method' })}
/>
)
+ case 'gemini-access-token': {
+ const currentToken =
+ process.env.GEMINI_ACCESS_TOKEN || readGeminiAccessToken() || ''
+ return (
+ {
+ const token = value.trim() || currentToken
+ return token ? null : 'Enter a Gemini access token or go back and choose Local ADC.'
+ }}
+ onSubmit={value => {
+ const token = value.trim() || currentToken
+ const saved = saveGeminiAccessToken(token)
+ if (!saved.success) {
+ onDone(
+ `Failed to save Gemini access token: ${saved.warning ?? 'unknown error'}`,
+ {
+ display: 'system',
+ },
+ )
+ return
+ }
+
+ setStep({
+ name: 'gemini-model',
+ authMode: 'access-token',
+ })
+ }}
+ onCancel={() => setStep({ name: 'gemini-auth-method' })}
+ />
+ )
+ }
+
case 'gemini-model':
return (
{
+ if (
+ step.authMode === 'adc' &&
+ !mayHaveGeminiAdcCredentials(process.env)
+ ) {
+ onDone(
+ 'Local ADC credentials were not detected. Run `gcloud auth application-default login` first, then save the Gemini ADC profile again.',
+ {
+ display: 'system',
+ },
+ )
+ return
+ }
+
const env = buildGeminiProfileEnv({
apiKey: step.apiKey,
+ authMode: step.authMode,
model: value.trim() || DEFAULT_GEMINI_MODEL,
processEnv: {},
})
@@ -1115,7 +1267,13 @@ export function ProviderWizard({
finishProfileSave(onDone, 'gemini', env)
}
}}
- onCancel={() => setStep({ name: 'gemini-key' })}
+ onCancel={() =>
+ step.authMode === 'api-key'
+ ? setStep({ name: 'gemini-key' })
+ : step.authMode === 'access-token'
+ ? setStep({ name: 'gemini-access-token' })
+ : setStep({ name: 'gemini-auth-method' })
+ }
/>
)
diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx
index 4f75d5c3..ee91304a 100644
--- a/src/entrypoints/cli.tsx
+++ b/src/entrypoints/cli.tsx
@@ -1,14 +1,12 @@
import { feature } from 'bun:bundle';
-import {
- isLocalProviderUrl,
- resolveCodexApiCredentials,
- resolveProviderRequest,
-} from '../services/api/providerConfig.js'
import {
applyProfileEnvToProcessEnv,
buildStartupEnvFromProfile,
- redactSecretValueForDisplay,
} from '../utils/providerProfile.js'
+import {
+ getProviderValidationError,
+ validateProviderEnvOrExit,
+} from '../utils/providerValidation.js'
// OpenClaude: disable experimental API betas by default.
// Tool search (defer_loading), global cache scope, and context management
@@ -42,82 +40,6 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
}
}
-function isEnvTruthy(value: string | undefined): boolean {
- if (!value) return false
- const normalized = value.trim().toLowerCase()
- return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
-}
-
-function getProviderValidationError(
- env: NodeJS.ProcessEnv = process.env,
-): 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)) {
- if (!(env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY)) {
- return 'GEMINI_API_KEY is 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
-}
-
-function validateProviderEnvOrExit(): void {
- const error = getProviderValidationError()
- if (error) {
- console.error(error)
- process.exit(1)
- }
-}
-
/**
* Bootstrap entrypoint - checks for special flags before loading the full CLI.
* All imports are dynamic to minimize module evaluation for fast paths.
@@ -151,6 +73,8 @@ async function main(): Promise {
enableConfigs()
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
applySafeConfigEnvironmentVariables()
+ const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../utils/geminiCredentials.js')
+ hydrateGeminiAccessTokenFromSecureStorage()
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
hydrateGithubModelsTokenFromSecureStorage()
}
@@ -159,7 +83,7 @@ async function main(): Promise {
processEnv: process.env,
})
if (startupEnv !== process.env) {
- const startupProfileError = getProviderValidationError(startupEnv)
+ const startupProfileError = await getProviderValidationError(startupEnv)
if (startupProfileError) {
console.error(
`Warning: ignoring saved provider profile. ${startupProfileError}`,
@@ -169,7 +93,7 @@ async function main(): Promise {
}
}
- validateProviderEnvOrExit()
+ await validateProviderEnvOrExit()
// Print the gradient startup screen before the Ink UI loads
const { printStartupScreen } = await import('../components/StartupScreen.js')
diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts
index 681755e3..be975650 100644
--- a/src/services/api/openaiShim.test.ts
+++ b/src/services/api/openaiShim.test.ts
@@ -6,10 +6,27 @@ type FetchType = typeof globalThis.fetch
const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ OPENAI_MODEL: process.env.OPENAI_MODEL,
+ 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,
+ GEMINI_BASE_URL: process.env.GEMINI_BASE_URL,
+ GEMINI_MODEL: process.env.GEMINI_MODEL,
+ GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
}
const originalFetch = globalThis.fetch
+function restoreEnv(key: string, value: string | undefined): void {
+ if (value === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = value
+ }
+}
+
type OpenAIShimClient = {
beta: {
messages: {
@@ -52,11 +69,29 @@ function makeStreamChunks(chunks: unknown[]): string[] {
beforeEach(() => {
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
process.env.OPENAI_API_KEY = 'test-key'
+ delete process.env.OPENAI_MODEL
+ delete process.env.CLAUDE_CODE_USE_GEMINI
+ delete process.env.GEMINI_API_KEY
+ delete process.env.GOOGLE_API_KEY
+ delete process.env.GEMINI_ACCESS_TOKEN
+ delete process.env.GEMINI_AUTH_MODE
+ delete process.env.GEMINI_BASE_URL
+ delete process.env.GEMINI_MODEL
+ delete process.env.GOOGLE_CLOUD_PROJECT
})
afterEach(() => {
- process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
- process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
+ restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
+ restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
+ restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
+ 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('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
+ restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
+ restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
globalThis.fetch = originalFetch
})
@@ -308,6 +343,76 @@ test('preserves image tool results as placeholders in follow-up requests', async
expect(toolMessage?.content).toContain('[image:image/png]')
})
+test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => {
+ let capturedAuthorization: string | null = null
+ let capturedProject: string | null = null
+ let requestUrl: string | undefined
+
+ process.env.CLAUDE_CODE_USE_GEMINI = '1'
+ process.env.GEMINI_AUTH_MODE = 'access-token'
+ process.env.GEMINI_ACCESS_TOKEN = 'gemini-access-token'
+ process.env.GOOGLE_CLOUD_PROJECT = 'gemini-project'
+ process.env.GEMINI_BASE_URL =
+ 'https://generativelanguage.googleapis.com/v1beta/openai'
+ process.env.GEMINI_MODEL = 'gemini-2.0-flash'
+ delete process.env.OPENAI_BASE_URL
+ delete process.env.OPENAI_API_KEY
+ delete process.env.GEMINI_API_KEY
+ delete process.env.GOOGLE_API_KEY
+
+ globalThis.fetch = (async (input, init) => {
+ requestUrl = typeof input === 'string' ? input : input.url
+ const headers = init?.headers as Record | undefined
+ capturedAuthorization =
+ headers?.Authorization ?? headers?.authorization ?? null
+ capturedProject =
+ headers?.['x-goog-user-project'] ??
+ headers?.['X-Goog-User-Project'] ??
+ null
+
+ return new Response(
+ JSON.stringify({
+ id: 'chatcmpl-gemini',
+ model: 'gemini-2.0-flash',
+ choices: [
+ {
+ message: {
+ role: 'assistant',
+ content: 'ok',
+ },
+ finish_reason: 'stop',
+ },
+ ],
+ usage: {
+ prompt_tokens: 3,
+ completion_tokens: 1,
+ total_tokens: 4,
+ },
+ }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
+ }) as FetchType
+
+ const client = createOpenAIShimClient({}) as OpenAIShimClient
+
+ await client.beta.messages.create({
+ model: 'gemini-2.0-flash',
+ messages: [{ role: 'user', content: 'hello' }],
+ max_tokens: 32,
+ stream: false,
+ })
+
+ expect(requestUrl).toBe(
+ 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
+ )
+ expect(capturedAuthorization).toBe('Bearer gemini-access-token')
+ expect(capturedProject).toBe('gemini-project')
+})
+
test('preserves Gemini tool call extra_content from streaming chunks', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts
index 128b075d..29f0ce87 100644
--- a/src/services/api/openaiShim.ts
+++ b/src/services/api/openaiShim.ts
@@ -23,6 +23,8 @@
import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js'
+import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
+import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import {
codexStreamToAnthropic,
@@ -46,6 +48,7 @@ type SecretValueSource = Partial<{
CODEX_API_KEY: string
GEMINI_API_KEY: string
GOOGLE_API_KEY: string
+ GEMINI_ACCESS_TOKEN: string
}>
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
@@ -893,7 +896,9 @@ class OpenAIShimMessages {
...(options?.headers ?? {}),
}
- const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
+ const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
+ const apiKey =
+ this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
// path segments like https://evil.com/cognitiveservices.azure.com/
let isAzure = false
@@ -910,6 +915,14 @@ class OpenAIShimMessages {
} else {
headers.Authorization = `Bearer ${apiKey}`
}
+ } else if (isGemini) {
+ const geminiCredential = await resolveGeminiCredential(process.env)
+ if (geminiCredential.kind !== 'none') {
+ headers.Authorization = `Bearer ${geminiCredential.credential}`
+ if (geminiCredential.projectId) {
+ headers['x-goog-user-project'] = geminiCredential.projectId
+ }
+ }
}
if (isGithub) {
@@ -1105,6 +1118,7 @@ export function createOpenAIShimClient(options: {
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
providerOverride?: { model: string; baseURL: string; apiKey: string }
}): unknown {
+ hydrateGeminiAccessTokenFromSecureStorage()
hydrateGithubModelsTokenFromSecureStorage()
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
@@ -1113,8 +1127,11 @@ export function createOpenAIShimClient(options: {
process.env.OPENAI_BASE_URL ??=
process.env.GEMINI_BASE_URL ??
'https://generativelanguage.googleapis.com/v1beta/openai'
- process.env.OPENAI_API_KEY ??=
- process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? ''
+ const geminiApiKey =
+ process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
+ if (geminiApiKey && !process.env.OPENAI_API_KEY) {
+ process.env.OPENAI_API_KEY = geminiApiKey
+ }
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
}
diff --git a/src/utils/geminiAuth.test.ts b/src/utils/geminiAuth.test.ts
new file mode 100644
index 00000000..8bb9a5c2
--- /dev/null
+++ b/src/utils/geminiAuth.test.ts
@@ -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)
+ })
+})
diff --git a/src/utils/geminiAuth.ts b/src/utils/geminiAuth.ts
new file mode 100644
index 00000000..d22d0d2f
--- /dev/null
+++ b/src/utils/geminiAuth.ts
@@ -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
+}
+
+type GoogleAuthLike = {
+ getClient(): Promise
+ getProjectId?(): Promise
+}
+
+export type GeminiResolvedCredential =
+ | {
+ kind: 'api-key'
+ credential: string
+ }
+ | {
+ kind: 'access-token' | 'adc'
+ credential: string
+ projectId?: string
+ }
+ | {
+ kind: 'none'
+ }
+
+type ResolveGeminiCredentialDeps = {
+ createGoogleAuth?: () => Promise
+}
+
+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()
+
+ 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 {
+ 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 | { 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 {
+ 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),
+ )
+}
diff --git a/src/utils/geminiCredentials.test.ts b/src/utils/geminiCredentials.test.ts
new file mode 100644
index 00000000..87c5e4ca
--- /dev/null
+++ b/src/utils/geminiCredentials.test.ts
@@ -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()
+})
+
diff --git a/src/utils/geminiCredentials.ts b/src/utils/geminiCredentials.ts
new file mode 100644
index 00000000..5f986a4d
--- /dev/null
+++ b/src/utils/geminiCredentials.ts
@@ -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)
+ | 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),
+ [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) }
+ delete next[GEMINI_TOKEN_STORAGE_KEY]
+ return secureStorage.update(next as typeof previous)
+}
diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts
index 44f8cf94..e1da62c7 100644
--- a/src/utils/providerProfile.test.ts
+++ b/src/utils/providerProfile.test.ts
@@ -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',
diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts
index 42da7412..6a61a758 100644
--- a/src/utils/providerProfile.ts
+++ b/src/utils/providerProfile.ts
@@ -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
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
resolveAtomicChatDefaultModel?: () => Promise
+ readGeminiAccessToken?: () => string | undefined
}): Promise {
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
+ readGeminiAccessToken?: () => string | undefined
}): Promise {
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,
})
}
diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts
new file mode 100644
index 00000000..3917638f
--- /dev/null
+++ b/src/utils/providerValidation.test.ts
@@ -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.',
+ )
+})
diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts
new file mode 100644
index 00000000..a24ccf76
--- /dev/null
+++ b/src/utils/providerValidation.ts
@@ -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
+ },
+): Promise {
+ 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 {
+ const error = await getProviderValidationError(env)
+ if (error) {
+ console.error(error)
+ process.exit(1)
+ }
+}
diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts
index eb68edd1..059e2035 100644
--- a/src/utils/secureStorage/platformStorage.test.ts
+++ b/src/utils/secureStorage/platformStorage.test.ts
@@ -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);
});
});