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:
@@ -116,13 +116,15 @@ Advanced and source-build guides:
|
|||||||
| Provider | Setup Path | Notes |
|
| Provider | Setup Path | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and compatible local `/v1` servers |
|
| 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 |
|
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
||||||
| Codex | `/provider` | Uses existing Codex credentials when available |
|
| Codex | `/provider` | Uses existing Codex credentials when available |
|
||||||
| Ollama | `/provider` or env vars | Local inference with no API key |
|
| Ollama | `/provider` or env vars | Local inference with no API key |
|
||||||
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
||||||
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
| 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
|
## What Works
|
||||||
|
|||||||
@@ -197,6 +197,23 @@ test('buildProfileSaveMessage maps provider fields without echoing secrets', ()
|
|||||||
expect(message).not.toContain('sk-secret-12345678')
|
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', () => {
|
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||||
const summary = buildCurrentProviderSummary({
|
const summary = buildCurrentProviderSummary({
|
||||||
processEnv: {
|
processEnv: {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ import {
|
|||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
} from '../../utils/providerProfile.js'
|
} from '../../utils/providerProfile.js'
|
||||||
|
import {
|
||||||
|
getGeminiProjectIdHint,
|
||||||
|
mayHaveGeminiAdcCredentials,
|
||||||
|
} from '../../utils/geminiAuth.js'
|
||||||
|
import {
|
||||||
|
readGeminiAccessToken,
|
||||||
|
saveGeminiAccessToken,
|
||||||
|
} from '../../utils/geminiCredentials.js'
|
||||||
import {
|
import {
|
||||||
getGoalDefaultOpenAIModel,
|
getGoalDefaultOpenAIModel,
|
||||||
normalizeRecommendationGoal,
|
normalizeRecommendationGoal,
|
||||||
@@ -60,8 +68,14 @@ type Step =
|
|||||||
baseUrl: string | null
|
baseUrl: string | null
|
||||||
defaultModel: string
|
defaultModel: string
|
||||||
}
|
}
|
||||||
|
| { name: 'gemini-auth-method' }
|
||||||
| { name: 'gemini-key' }
|
| { 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' }
|
| { name: 'codex-check' }
|
||||||
|
|
||||||
type CurrentProviderSummary = {
|
type CurrentProviderSummary = {
|
||||||
@@ -216,9 +230,13 @@ function buildSavedProfileSummary(
|
|||||||
env,
|
env,
|
||||||
),
|
),
|
||||||
credentialLabel:
|
credentialLabel:
|
||||||
maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
|
env.GEMINI_AUTH_MODE === 'access-token'
|
||||||
? 'configured'
|
? 'access token (stored securely)'
|
||||||
: undefined,
|
: env.GEMINI_AUTH_MODE === 'adc'
|
||||||
|
? 'local ADC'
|
||||||
|
: maskSecretForDisplay(env.GEMINI_API_KEY) !== undefined
|
||||||
|
? 'configured'
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return {
|
return {
|
||||||
@@ -427,7 +445,7 @@ function ProviderChooser({
|
|||||||
{
|
{
|
||||||
label: 'Gemini',
|
label: 'Gemini',
|
||||||
value: 'gemini',
|
value: 'gemini',
|
||||||
description: 'Use a Google Gemini API key',
|
description: 'Use Google Gemini with API key, access token, or local ADC',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
@@ -926,7 +944,7 @@ export function ProviderWizard({
|
|||||||
defaultModel: defaults.openAIModel,
|
defaultModel: defaults.openAIModel,
|
||||||
})
|
})
|
||||||
} else if (value === 'gemini') {
|
} else if (value === 'gemini') {
|
||||||
setStep({ name: 'gemini-key' })
|
setStep({ name: 'gemini-auth-method' })
|
||||||
} else if (value === 'clear') {
|
} else if (value === 'clear') {
|
||||||
const filePath = deleteProfileFile()
|
const filePath = deleteProfileFile()
|
||||||
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
|
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 (
|
||||||
|
<Dialog title="Gemini setup" onCancel={() => onDone()}>
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text>Choose how this Gemini profile should authenticate.</Text>
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
inlineDescriptions
|
||||||
|
visibleOptionCount={options.length}
|
||||||
|
onChange={value => {
|
||||||
|
if (value === 'api-key') {
|
||||||
|
setStep({ name: 'gemini-key' })
|
||||||
|
} else if (value === 'access-token') {
|
||||||
|
setStep({ name: 'gemini-access-token' })
|
||||||
|
} else {
|
||||||
|
setStep({
|
||||||
|
name: 'gemini-model',
|
||||||
|
authMode: 'adc',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => setStep({ name: 'choose' })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'gemini-key':
|
case 'gemini-key':
|
||||||
return (
|
return (
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
resetStateKey={step.name}
|
resetStateKey={step.name}
|
||||||
title="Gemini setup"
|
title="Gemini setup"
|
||||||
subtitle="Step 1 of 2"
|
subtitle="Step 1 of 3"
|
||||||
description={
|
description={
|
||||||
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
|
||||||
? 'Enter a Gemini API key, or leave this blank to reuse the current GEMINI_API_KEY/GOOGLE_API_KEY from this session.'
|
? 'Enter a Gemini API key, or leave this blank to reuse the current GEMINI_API_KEY/GOOGLE_API_KEY from this session.'
|
||||||
@@ -1089,25 +1171,95 @@ export function ProviderWizard({
|
|||||||
process.env.GEMINI_API_KEY ||
|
process.env.GEMINI_API_KEY ||
|
||||||
process.env.GOOGLE_API_KEY ||
|
process.env.GOOGLE_API_KEY ||
|
||||||
''
|
''
|
||||||
setStep({ name: 'gemini-model', apiKey })
|
setStep({ name: 'gemini-model', apiKey, authMode: 'api-key' })
|
||||||
}}
|
}}
|
||||||
onCancel={() => setStep({ name: 'choose' })}
|
onCancel={() => setStep({ name: 'gemini-auth-method' })}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case 'gemini-access-token': {
|
||||||
|
const currentToken =
|
||||||
|
process.env.GEMINI_ACCESS_TOKEN || readGeminiAccessToken() || ''
|
||||||
|
return (
|
||||||
|
<TextEntryDialog
|
||||||
|
resetStateKey={step.name}
|
||||||
|
title="Gemini setup"
|
||||||
|
subtitle="Step 2 of 3"
|
||||||
|
description={
|
||||||
|
currentToken
|
||||||
|
? 'Enter a Gemini access token, or leave this blank to reuse the current token from this session or secure storage.'
|
||||||
|
: 'Enter a Gemini access token. It will be stored securely for this profile.'
|
||||||
|
}
|
||||||
|
initialValue=""
|
||||||
|
placeholder="ya29...."
|
||||||
|
mask="*"
|
||||||
|
allowEmpty={Boolean(currentToken)}
|
||||||
|
validate={value => {
|
||||||
|
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':
|
case 'gemini-model':
|
||||||
return (
|
return (
|
||||||
<TextEntryDialog
|
<TextEntryDialog
|
||||||
resetStateKey={step.name}
|
resetStateKey={step.name}
|
||||||
title="Gemini setup"
|
title="Gemini setup"
|
||||||
subtitle="Step 2 of 2"
|
subtitle={
|
||||||
description={`Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`}
|
step.authMode === 'api-key'
|
||||||
|
? 'Step 3 of 3'
|
||||||
|
: step.authMode === 'access-token'
|
||||||
|
? 'Step 3 of 3'
|
||||||
|
: 'Step 2 of 2'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
step.authMode === 'api-key'
|
||||||
|
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}.`
|
||||||
|
: step.authMode === 'access-token'
|
||||||
|
? `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use the stored Gemini access token at runtime.`
|
||||||
|
: `Enter a Gemini model name. Leave blank for ${DEFAULT_GEMINI_MODEL}. This profile will use local Google ADC credentials at runtime.`
|
||||||
|
}
|
||||||
initialValue={defaults.geminiModel}
|
initialValue={defaults.geminiModel}
|
||||||
placeholder={DEFAULT_GEMINI_MODEL}
|
placeholder={DEFAULT_GEMINI_MODEL}
|
||||||
allowEmpty
|
allowEmpty
|
||||||
onSubmit={value => {
|
onSubmit={value => {
|
||||||
|
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({
|
const env = buildGeminiProfileEnv({
|
||||||
apiKey: step.apiKey,
|
apiKey: step.apiKey,
|
||||||
|
authMode: step.authMode,
|
||||||
model: value.trim() || DEFAULT_GEMINI_MODEL,
|
model: value.trim() || DEFAULT_GEMINI_MODEL,
|
||||||
processEnv: {},
|
processEnv: {},
|
||||||
})
|
})
|
||||||
@@ -1115,7 +1267,13 @@ export function ProviderWizard({
|
|||||||
finishProfileSave(onDone, 'gemini', env)
|
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' })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { feature } from 'bun:bundle';
|
import { feature } from 'bun:bundle';
|
||||||
import {
|
|
||||||
isLocalProviderUrl,
|
|
||||||
resolveCodexApiCredentials,
|
|
||||||
resolveProviderRequest,
|
|
||||||
} from '../services/api/providerConfig.js'
|
|
||||||
import {
|
import {
|
||||||
applyProfileEnvToProcessEnv,
|
applyProfileEnvToProcessEnv,
|
||||||
buildStartupEnvFromProfile,
|
buildStartupEnvFromProfile,
|
||||||
redactSecretValueForDisplay,
|
|
||||||
} from '../utils/providerProfile.js'
|
} from '../utils/providerProfile.js'
|
||||||
|
import {
|
||||||
|
getProviderValidationError,
|
||||||
|
validateProviderEnvOrExit,
|
||||||
|
} from '../utils/providerValidation.js'
|
||||||
|
|
||||||
// OpenClaude: disable experimental API betas by default.
|
// OpenClaude: disable experimental API betas by default.
|
||||||
// Tool search (defer_loading), global cache scope, and context management
|
// 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.
|
* Bootstrap entrypoint - checks for special flags before loading the full CLI.
|
||||||
* All imports are dynamic to minimize module evaluation for fast paths.
|
* All imports are dynamic to minimize module evaluation for fast paths.
|
||||||
@@ -151,6 +73,8 @@ async function main(): Promise<void> {
|
|||||||
enableConfigs()
|
enableConfigs()
|
||||||
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
|
const { applySafeConfigEnvironmentVariables } = await import('../utils/managedEnv.js')
|
||||||
applySafeConfigEnvironmentVariables()
|
applySafeConfigEnvironmentVariables()
|
||||||
|
const { hydrateGeminiAccessTokenFromSecureStorage } = await import('../utils/geminiCredentials.js')
|
||||||
|
hydrateGeminiAccessTokenFromSecureStorage()
|
||||||
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
|
const { hydrateGithubModelsTokenFromSecureStorage } = await import('../utils/githubModelsCredentials.js')
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
}
|
}
|
||||||
@@ -159,7 +83,7 @@ async function main(): Promise<void> {
|
|||||||
processEnv: process.env,
|
processEnv: process.env,
|
||||||
})
|
})
|
||||||
if (startupEnv !== process.env) {
|
if (startupEnv !== process.env) {
|
||||||
const startupProfileError = getProviderValidationError(startupEnv)
|
const startupProfileError = await getProviderValidationError(startupEnv)
|
||||||
if (startupProfileError) {
|
if (startupProfileError) {
|
||||||
console.error(
|
console.error(
|
||||||
`Warning: ignoring saved provider profile. ${startupProfileError}`,
|
`Warning: ignoring saved provider profile. ${startupProfileError}`,
|
||||||
@@ -169,7 +93,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateProviderEnvOrExit()
|
await validateProviderEnvOrExit()
|
||||||
|
|
||||||
// Print the gradient startup screen before the Ink UI loads
|
// Print the gradient startup screen before the Ink UI loads
|
||||||
const { printStartupScreen } = await import('../components/StartupScreen.js')
|
const { printStartupScreen } = await import('../components/StartupScreen.js')
|
||||||
|
|||||||
@@ -6,10 +6,27 @@ type FetchType = typeof globalThis.fetch
|
|||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
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
|
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 = {
|
type OpenAIShimClient = {
|
||||||
beta: {
|
beta: {
|
||||||
messages: {
|
messages: {
|
||||||
@@ -52,11 +69,29 @@ function makeStreamChunks(chunks: unknown[]): string[] {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
||||||
process.env.OPENAI_API_KEY = 'test-key'
|
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(() => {
|
afterEach(() => {
|
||||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||||
process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
|
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
|
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]')
|
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<string, string> | 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 () => {
|
test('preserves Gemini tool call extra_content from streaming chunks', async () => {
|
||||||
globalThis.fetch = (async (_input, _init) => {
|
globalThis.fetch = (async (_input, _init) => {
|
||||||
const chunks = makeStreamChunks([
|
const chunks = makeStreamChunks([
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
|
|
||||||
import { APIError } from '@anthropic-ai/sdk'
|
import { APIError } from '@anthropic-ai/sdk'
|
||||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
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 { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||||
import {
|
import {
|
||||||
codexStreamToAnthropic,
|
codexStreamToAnthropic,
|
||||||
@@ -46,6 +48,7 @@ type SecretValueSource = Partial<{
|
|||||||
CODEX_API_KEY: string
|
CODEX_API_KEY: string
|
||||||
GEMINI_API_KEY: string
|
GEMINI_API_KEY: string
|
||||||
GOOGLE_API_KEY: string
|
GOOGLE_API_KEY: string
|
||||||
|
GEMINI_ACCESS_TOKEN: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
|
||||||
@@ -893,7 +896,9 @@ class OpenAIShimMessages {
|
|||||||
...(options?.headers ?? {}),
|
...(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
|
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
||||||
// path segments like https://evil.com/cognitiveservices.azure.com/
|
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||||
let isAzure = false
|
let isAzure = false
|
||||||
@@ -910,6 +915,14 @@ class OpenAIShimMessages {
|
|||||||
} else {
|
} else {
|
||||||
headers.Authorization = `Bearer ${apiKey}`
|
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) {
|
if (isGithub) {
|
||||||
@@ -1105,6 +1118,7 @@ export function createOpenAIShimClient(options: {
|
|||||||
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
|
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
|
||||||
providerOverride?: { model: string; baseURL: string; apiKey: string }
|
providerOverride?: { model: string; baseURL: string; apiKey: string }
|
||||||
}): unknown {
|
}): unknown {
|
||||||
|
hydrateGeminiAccessTokenFromSecureStorage()
|
||||||
hydrateGithubModelsTokenFromSecureStorage()
|
hydrateGithubModelsTokenFromSecureStorage()
|
||||||
|
|
||||||
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
|
// 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.OPENAI_BASE_URL ??=
|
||||||
process.env.GEMINI_BASE_URL ??
|
process.env.GEMINI_BASE_URL ??
|
||||||
'https://generativelanguage.googleapis.com/v1beta/openai'
|
'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
process.env.OPENAI_API_KEY ??=
|
const geminiApiKey =
|
||||||
process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? ''
|
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) {
|
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
|
||||||
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
|
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
|
||||||
}
|
}
|
||||||
|
|||||||
186
src/utils/geminiAuth.test.ts
Normal file
186
src/utils/geminiAuth.test.ts
Normal 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
216
src/utils/geminiAuth.ts
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/utils/geminiCredentials.test.ts
Normal file
31
src/utils/geminiCredentials.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
|
||||||
76
src/utils/geminiCredentials.ts
Normal file
76
src/utils/geminiCredentials.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -355,11 +355,38 @@ test('gemini profiles accept google api key fallback', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert.deepEqual(env, {
|
assert.deepEqual(env, {
|
||||||
|
GEMINI_AUTH_MODE: 'api-key',
|
||||||
GEMINI_MODEL: 'gemini-2.0-flash',
|
GEMINI_MODEL: 'gemini-2.0-flash',
|
||||||
GEMINI_API_KEY: 'gem-live',
|
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', () => {
|
test('gemini profiles require a key', () => {
|
||||||
const env = buildGeminiProfileEnv({
|
const env = buildGeminiProfileEnv({
|
||||||
processEnv: {},
|
processEnv: {},
|
||||||
@@ -405,6 +432,39 @@ test('buildStartupEnvFromProfile applies persisted gemini settings when no provi
|
|||||||
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
|
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 () => {
|
test('buildStartupEnvFromProfile leaves explicit provider selections untouched', async () => {
|
||||||
const processEnv = {
|
const processEnv = {
|
||||||
CLAUDE_CODE_USE_GEMINI: '1',
|
CLAUDE_CODE_USE_GEMINI: '1',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
normalizeRecommendationGoal,
|
normalizeRecommendationGoal,
|
||||||
type RecommendationGoal,
|
type RecommendationGoal,
|
||||||
} from './providerRecommendation.ts'
|
} from './providerRecommendation.ts'
|
||||||
|
import { readGeminiAccessToken } from './geminiCredentials.ts'
|
||||||
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
|
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
|
||||||
|
|
||||||
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
||||||
@@ -32,6 +33,8 @@ const PROFILE_ENV_KEYS = [
|
|||||||
'CHATGPT_ACCOUNT_ID',
|
'CHATGPT_ACCOUNT_ID',
|
||||||
'CODEX_ACCOUNT_ID',
|
'CODEX_ACCOUNT_ID',
|
||||||
'GEMINI_API_KEY',
|
'GEMINI_API_KEY',
|
||||||
|
'GEMINI_AUTH_MODE',
|
||||||
|
'GEMINI_ACCESS_TOKEN',
|
||||||
'GEMINI_MODEL',
|
'GEMINI_MODEL',
|
||||||
'GEMINI_BASE_URL',
|
'GEMINI_BASE_URL',
|
||||||
'GOOGLE_API_KEY',
|
'GOOGLE_API_KEY',
|
||||||
@@ -54,6 +57,7 @@ export type ProfileEnv = {
|
|||||||
CHATGPT_ACCOUNT_ID?: string
|
CHATGPT_ACCOUNT_ID?: string
|
||||||
CODEX_ACCOUNT_ID?: string
|
CODEX_ACCOUNT_ID?: string
|
||||||
GEMINI_API_KEY?: string
|
GEMINI_API_KEY?: string
|
||||||
|
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
|
||||||
GEMINI_MODEL?: string
|
GEMINI_MODEL?: string
|
||||||
GEMINI_BASE_URL?: string
|
GEMINI_BASE_URL?: string
|
||||||
}
|
}
|
||||||
@@ -220,19 +224,22 @@ export function buildGeminiProfileEnv(options: {
|
|||||||
model?: string | null
|
model?: string | null
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
|
authMode?: 'api-key' | 'access-token' | 'adc'
|
||||||
processEnv?: NodeJS.ProcessEnv
|
processEnv?: NodeJS.ProcessEnv
|
||||||
}): ProfileEnv | null {
|
}): ProfileEnv | null {
|
||||||
const processEnv = options.processEnv ?? process.env
|
const processEnv = options.processEnv ?? process.env
|
||||||
|
const authMode = options.authMode ?? 'api-key'
|
||||||
const key = sanitizeApiKey(
|
const key = sanitizeApiKey(
|
||||||
options.apiKey ??
|
options.apiKey ??
|
||||||
processEnv.GEMINI_API_KEY ??
|
processEnv.GEMINI_API_KEY ??
|
||||||
processEnv.GOOGLE_API_KEY,
|
processEnv.GOOGLE_API_KEY,
|
||||||
)
|
)
|
||||||
if (!key) {
|
if (authMode === 'api-key' && !key) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const env: ProfileEnv = {
|
const env: ProfileEnv = {
|
||||||
|
GEMINI_AUTH_MODE: authMode,
|
||||||
GEMINI_MODEL:
|
GEMINI_MODEL:
|
||||||
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
|
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(
|
||||||
@@ -241,7 +248,10 @@ export function buildGeminiProfileEnv(options: {
|
|||||||
processEnv,
|
processEnv,
|
||||||
) ||
|
) ||
|
||||||
DEFAULT_GEMINI_MODEL,
|
DEFAULT_GEMINI_MODEL,
|
||||||
GEMINI_API_KEY: key,
|
}
|
||||||
|
|
||||||
|
if (authMode === 'api-key' && key) {
|
||||||
|
env.GEMINI_API_KEY = key
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
@@ -422,6 +432,7 @@ export async function buildLaunchEnv(options: {
|
|||||||
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
|
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
|
||||||
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
|
getAtomicChatChatBaseUrl?: (baseUrl?: string) => string
|
||||||
resolveAtomicChatDefaultModel?: () => Promise<string | null>
|
resolveAtomicChatDefaultModel?: () => Promise<string | null>
|
||||||
|
readGeminiAccessToken?: () => string | undefined
|
||||||
}): Promise<NodeJS.ProcessEnv> {
|
}): Promise<NodeJS.ProcessEnv> {
|
||||||
const processEnv = options.processEnv ?? process.env
|
const processEnv = options.processEnv ?? process.env
|
||||||
const persistedEnv =
|
const persistedEnv =
|
||||||
@@ -460,11 +471,16 @@ export async function buildLaunchEnv(options: {
|
|||||||
processEnv.GEMINI_BASE_URL,
|
processEnv.GEMINI_BASE_URL,
|
||||||
processEnv,
|
processEnv,
|
||||||
)
|
)
|
||||||
|
const shellGeminiAccessToken =
|
||||||
|
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
|
||||||
|
const storedGeminiAccessToken =
|
||||||
|
options.readGeminiAccessToken?.() ?? readGeminiAccessToken()
|
||||||
|
|
||||||
const shellGeminiKey = sanitizeApiKey(
|
const shellGeminiKey = sanitizeApiKey(
|
||||||
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
|
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
|
||||||
)
|
)
|
||||||
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
|
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
|
||||||
|
const persistedGeminiAuthMode = persistedEnv.GEMINI_AUTH_MODE
|
||||||
|
|
||||||
if (options.profile === 'gemini') {
|
if (options.profile === 'gemini') {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -484,12 +500,29 @@ export async function buildLaunchEnv(options: {
|
|||||||
persistedGeminiBaseUrl ||
|
persistedGeminiBaseUrl ||
|
||||||
DEFAULT_GEMINI_BASE_URL
|
DEFAULT_GEMINI_BASE_URL
|
||||||
|
|
||||||
|
const geminiAuthMode =
|
||||||
|
persistedGeminiAuthMode === 'access-token' ||
|
||||||
|
persistedGeminiAuthMode === 'adc'
|
||||||
|
? persistedGeminiAuthMode
|
||||||
|
: 'api-key'
|
||||||
const geminiKey = shellGeminiKey || persistedGeminiKey
|
const geminiKey = shellGeminiKey || persistedGeminiKey
|
||||||
if (geminiKey) {
|
if (geminiAuthMode === 'api-key' && geminiKey) {
|
||||||
env.GEMINI_API_KEY = geminiKey
|
env.GEMINI_API_KEY = geminiKey
|
||||||
} else {
|
} else {
|
||||||
delete env.GEMINI_API_KEY
|
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.GOOGLE_API_KEY
|
||||||
delete env.OPENAI_BASE_URL
|
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_GEMINI
|
||||||
delete env.CLAUDE_CODE_USE_GITHUB
|
delete env.CLAUDE_CODE_USE_GITHUB
|
||||||
delete env.GEMINI_API_KEY
|
delete env.GEMINI_API_KEY
|
||||||
|
delete env.GEMINI_AUTH_MODE
|
||||||
|
delete env.GEMINI_ACCESS_TOKEN
|
||||||
delete env.GEMINI_MODEL
|
delete env.GEMINI_MODEL
|
||||||
delete env.GEMINI_BASE_URL
|
delete env.GEMINI_BASE_URL
|
||||||
delete env.GOOGLE_API_KEY
|
delete env.GOOGLE_API_KEY
|
||||||
@@ -624,6 +659,7 @@ export async function buildStartupEnvFromProfile(options?: {
|
|||||||
processEnv?: NodeJS.ProcessEnv
|
processEnv?: NodeJS.ProcessEnv
|
||||||
getOllamaChatBaseUrl?: (baseUrl?: string) => string
|
getOllamaChatBaseUrl?: (baseUrl?: string) => string
|
||||||
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
|
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
|
||||||
|
readGeminiAccessToken?: () => string | undefined
|
||||||
}): Promise<NodeJS.ProcessEnv> {
|
}): Promise<NodeJS.ProcessEnv> {
|
||||||
const processEnv = options?.processEnv ?? process.env
|
const processEnv = options?.processEnv ?? process.env
|
||||||
if (hasExplicitProviderSelection(processEnv)) {
|
if (hasExplicitProviderSelection(processEnv)) {
|
||||||
@@ -645,6 +681,7 @@ export async function buildStartupEnvFromProfile(options?: {
|
|||||||
getOllamaChatBaseUrl:
|
getOllamaChatBaseUrl:
|
||||||
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
|
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
|
||||||
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
|
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
|
||||||
|
readGeminiAccessToken: options?.readGeminiAccessToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
src/utils/providerValidation.test.ts
Normal file
73
src/utils/providerValidation.test.ts
Normal 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.',
|
||||||
|
)
|
||||||
|
})
|
||||||
96
src/utils/providerValidation.ts
Normal file
96
src/utils/providerValidation.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,24 +25,24 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
const testData = {
|
const testData = {
|
||||||
mcpOAuth: {
|
mcpOAuth: {
|
||||||
"test-server": {
|
"test-server": {
|
||||||
accessToken: "secret-token",
|
accessToken: "secret-token",
|
||||||
expiresAt: 123456789,
|
expiresAt: 123456789,
|
||||||
serverName: "test",
|
serverName: "test",
|
||||||
serverUrl: "http://test"
|
serverUrl: "http://test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Config-Dir Isolation", () => {
|
describe("Config-Dir Isolation", () => {
|
||||||
test("service name changes with CLAUDE_CONFIG_DIR", () => {
|
test("service name changes with CLAUDE_CONFIG_DIR", () => {
|
||||||
const defaultName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
const defaultName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
||||||
|
|
||||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/other-config";
|
process.env.CLAUDE_CONFIG_DIR = "/tmp/other-config";
|
||||||
const otherName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
const otherName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
||||||
|
|
||||||
expect(otherName).not.toBe(defaultName);
|
expect(otherName).not.toBe(defaultName);
|
||||||
expect(otherName).toContain("Claude Code");
|
expect(otherName).toContain("Claude Code");
|
||||||
expect(otherName).toContain(CREDENTIALS_SERVICE_SUFFIX);
|
expect(otherName).toContain(CREDENTIALS_SERVICE_SUFFIX);
|
||||||
@@ -51,9 +51,9 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
test("Linux storage uses scoped service name", () => {
|
test("Linux storage uses scoped service name", () => {
|
||||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/linux-scoped";
|
process.env.CLAUDE_CONFIG_DIR = "/tmp/linux-scoped";
|
||||||
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
||||||
|
|
||||||
linuxSecretStorage.update(testData);
|
linuxSecretStorage.update(testData);
|
||||||
|
|
||||||
const args = mockExecaSync.mock.calls[0];
|
const args = mockExecaSync.mock.calls[0];
|
||||||
expect(args[1]).toContain(expectedName);
|
expect(args[1]).toContain(expectedName);
|
||||||
});
|
});
|
||||||
@@ -61,9 +61,9 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
test("Windows storage uses scoped resource name", () => {
|
test("Windows storage uses scoped resource name", () => {
|
||||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/win-scoped";
|
process.env.CLAUDE_CONFIG_DIR = "/tmp/win-scoped";
|
||||||
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
|
||||||
|
|
||||||
windowsCredentialStorage.update(testData);
|
windowsCredentialStorage.update(testData);
|
||||||
|
|
||||||
const script = mockExecaSync.mock.calls[0][1][1];
|
const script = mockExecaSync.mock.calls[0][1][1];
|
||||||
expect(script).toContain(expectedName);
|
expect(script).toContain(expectedName);
|
||||||
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
||||||
@@ -72,19 +72,19 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
|
|
||||||
describe("Windows PowerShell Escaping", () => {
|
describe("Windows PowerShell Escaping", () => {
|
||||||
test("escapes single quotes and prevents $ expansion", () => {
|
test("escapes single quotes and prevents $ expansion", () => {
|
||||||
const dataWithDollar = {
|
const dataWithDollar = {
|
||||||
mcpOAuth: {
|
mcpOAuth: {
|
||||||
"server": {
|
"server": {
|
||||||
accessToken: "token-with-$env:USERNAME",
|
accessToken: "token-with-$env:USERNAME",
|
||||||
expiresAt: 123,
|
expiresAt: 123,
|
||||||
serverName: "s",
|
serverName: "s",
|
||||||
serverUrl: "u"
|
serverUrl: "u"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
windowsCredentialStorage.update(dataWithDollar);
|
windowsCredentialStorage.update(dataWithDollar);
|
||||||
|
|
||||||
const script = mockExecaSync.mock.calls[0][1][1];
|
const script = mockExecaSync.mock.calls[0][1][1];
|
||||||
// Should use single quotes for the payload
|
// Should use single quotes for the payload
|
||||||
expect(script).toMatch(/'\{.*\}'/);
|
expect(script).toMatch(/'\{.*\}'/);
|
||||||
@@ -92,7 +92,7 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
expect(script).not.toContain("'token-with-$env:USERNAME'");
|
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
|
// 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.
|
// 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" } } };
|
const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
|
||||||
windowsCredentialStorage.update(dataWithQuote);
|
windowsCredentialStorage.update(dataWithQuote);
|
||||||
const script2 = mockExecaSync.mock.calls[1][1][1];
|
const script2 = mockExecaSync.mock.calls[1][1][1];
|
||||||
@@ -117,7 +117,7 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
describe("Linux secret-tool Interaction", () => {
|
describe("Linux secret-tool Interaction", () => {
|
||||||
test("update passes payload via stdin", () => {
|
test("update passes payload via stdin", () => {
|
||||||
linuxSecretStorage.update(testData);
|
linuxSecretStorage.update(testData);
|
||||||
|
|
||||||
const options = mockExecaSync.mock.calls[0][2];
|
const options = mockExecaSync.mock.calls[0][2];
|
||||||
expect(options.input).toContain("secret-token");
|
expect(options.input).toContain("secret-token");
|
||||||
});
|
});
|
||||||
@@ -125,7 +125,7 @@ describe("Secure Storage Platform Implementations", () => {
|
|||||||
test("read parses stdout", () => {
|
test("read parses stdout", () => {
|
||||||
mockExecaSync.mockReturnValue({ exitCode: 0, stdout: JSON.stringify(testData) });
|
mockExecaSync.mockReturnValue({ exitCode: 0, stdout: JSON.stringify(testData) });
|
||||||
const result = linuxSecretStorage.read();
|
const result = linuxSecretStorage.read();
|
||||||
|
|
||||||
expect(result).toEqual(testData);
|
expect(result).toEqual(testData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user