mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-22 07:41:23 -07:00
fix(auth): prioritize GEMINI_API_KEY env var and skip unnecessary key… (#14745)
This commit is contained in:
@@ -582,6 +582,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
settings.merged.security?.auth?.selectedType &&
|
settings.merged.security?.auth?.selectedType &&
|
||||||
!settings.merged.security?.auth?.useExternal
|
!settings.merged.security?.auth?.useExternal
|
||||||
) {
|
) {
|
||||||
|
// We skip validation for Gemini API key here because it might be stored
|
||||||
|
// in the keychain, which we can't check synchronously.
|
||||||
|
// The useAuth hook handles validation for this case.
|
||||||
|
if (settings.merged.security.auth.selectedType === AuthType.USE_GEMINI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const error = validateAuthMethod(
|
const error = validateAuthMethod(
|
||||||
settings.merged.security.auth.selectedType,
|
settings.merged.security.auth.selectedType,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ import {
|
|||||||
useTextBuffer,
|
useTextBuffer,
|
||||||
type TextBuffer,
|
type TextBuffer,
|
||||||
} from '../components/shared/text-buffer.js';
|
} from '../components/shared/text-buffer.js';
|
||||||
|
import { clearApiKey } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
clearApiKey: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../hooks/useKeypress.js', () => ({
|
vi.mock('../hooks/useKeypress.js', () => ({
|
||||||
useKeypress: vi.fn(),
|
useKeypress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -37,7 +47,8 @@ describe('ApiAuthDialog', () => {
|
|||||||
let mockBuffer: TextBuffer;
|
let mockBuffer: TextBuffer;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.stubEnv('GEMINI_API_KEY', '');
|
||||||
mockBuffer = {
|
mockBuffer = {
|
||||||
text: '',
|
text: '',
|
||||||
lines: [''],
|
lines: [''],
|
||||||
@@ -91,7 +102,9 @@ describe('ApiAuthDialog', () => {
|
|||||||
({ keyName, sequence, expectedCall, args }) => {
|
({ keyName, sequence, expectedCall, args }) => {
|
||||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||||
|
// calls[1] is the TextInput's useKeypress (typing handler)
|
||||||
|
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
|
||||||
|
|
||||||
keypressHandler({
|
keypressHandler({
|
||||||
name: keyName,
|
name: keyName,
|
||||||
@@ -117,4 +130,20 @@ describe('ApiAuthDialog', () => {
|
|||||||
|
|
||||||
expect(lastFrame()).toContain('Invalid API Key');
|
expect(lastFrame()).toContain('Invalid API Key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||||
|
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||||
|
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||||
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
|
||||||
|
await keypressHandler({
|
||||||
|
name: 'c',
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearApiKey).toHaveBeenCalled();
|
||||||
|
expect(mockBuffer.setText).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { TextInput } from '../components/shared/TextInput.js';
|
import { TextInput } from '../components/shared/TextInput.js';
|
||||||
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
|
||||||
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
|
|
||||||
interface ApiAuthDialogProps {
|
interface ApiAuthDialogProps {
|
||||||
onSubmit: (apiKey: string) => void;
|
onSubmit: (apiKey: string) => void;
|
||||||
@@ -27,9 +31,20 @@ export function ApiAuthDialog({
|
|||||||
const { mainAreaWidth } = useUIState();
|
const { mainAreaWidth } = useUIState();
|
||||||
const viewportWidth = mainAreaWidth - 8;
|
const viewportWidth = mainAreaWidth - 8;
|
||||||
|
|
||||||
|
const pendingPromise = useRef<{ cancel: () => void } | null>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
pendingPromise.current?.cancel();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialApiKey = defaultValue;
|
||||||
|
|
||||||
const buffer = useTextBuffer({
|
const buffer = useTextBuffer({
|
||||||
initialText: defaultValue || '',
|
initialText: initialApiKey || '',
|
||||||
initialCursorOffset: defaultValue?.length || 0,
|
initialCursorOffset: initialApiKey?.length || 0,
|
||||||
viewport: {
|
viewport: {
|
||||||
width: viewportWidth,
|
width: viewportWidth,
|
||||||
height: 4,
|
height: 4,
|
||||||
@@ -44,6 +59,41 @@ export function ApiAuthDialog({
|
|||||||
onSubmit(value);
|
onSubmit(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
pendingPromise.current?.cancel();
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
const wrappedPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
clearApiKey().then(
|
||||||
|
() => !isCancelled && resolve(),
|
||||||
|
(error) => !isCancelled && reject(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingPromise.current = {
|
||||||
|
cancel: () => {
|
||||||
|
isCancelled = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrappedPromise
|
||||||
|
.then(() => {
|
||||||
|
buffer.setText('');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debugLogger.debug('Failed to clear API key:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useKeypress(
|
||||||
|
async (key) => {
|
||||||
|
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||||
|
await handleClear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
@@ -89,7 +139,7 @@ export function ApiAuthDialog({
|
|||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
(Press Enter to submit, Esc to cancel)
|
(Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key)
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -232,6 +232,21 @@ describe('AuthDialog', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips API key dialog if env var is present but empty', async () => {
|
||||||
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
|
process.env['GEMINI_API_KEY'] = ''; // Empty string
|
||||||
|
// props.settings.merged.security.auth.selectedType is undefined here
|
||||||
|
|
||||||
|
renderWithProviders(<AuthDialog {...props} />);
|
||||||
|
const { onSelect: handleAuthSelect } =
|
||||||
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
|
|
||||||
|
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||||
|
AuthState.Unauthenticated,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows API key dialog on initial setup if no env var is present', async () => {
|
it('shows API key dialog on initial setup if no env var is present', async () => {
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
// process.env['GEMINI_API_KEY'] is not set
|
// process.env['GEMINI_API_KEY'] is not set
|
||||||
@@ -247,7 +262,7 @@ describe('AuthDialog', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows API key dialog on re-auth to allow editing', async () => {
|
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
|
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
|
||||||
// Simulate that the user has already authenticated once
|
// Simulate that the user has already authenticated once
|
||||||
@@ -260,7 +275,7 @@ describe('AuthDialog', () => {
|
|||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
|
|
||||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||||
AuthState.AwaitingApiKeyInput,
|
AuthState.Unauthenticated,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -116,9 +116,6 @@ export function AuthDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authType) {
|
if (authType) {
|
||||||
const isInitialAuthSelection =
|
|
||||||
!settings.merged.security?.auth?.selectedType;
|
|
||||||
|
|
||||||
await clearCachedCredentialFile();
|
await clearCachedCredentialFile();
|
||||||
|
|
||||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||||
@@ -135,7 +132,7 @@ export function AuthDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authType === AuthType.USE_GEMINI) {
|
if (authType === AuthType.USE_GEMINI) {
|
||||||
if (isInitialAuthSelection && process.env['GEMINI_API_KEY']) {
|
if (process.env['GEMINI_API_KEY'] !== undefined) {
|
||||||
setAuthState(AuthState.Unauthenticated);
|
setAuthState(AuthState.Unauthenticated);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ exports[`ApiAuthDialog > renders correctly 1`] = `
|
|||||||
│ │ Paste your API key here │ │
|
│ │ Paste your API key here │ │
|
||||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||||
│ │
|
│ │
|
||||||
│ (Press Enter to submit, Esc to cancel) │
|
│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │
|
||||||
│ │
|
│ │
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ vi.mock('../../config/auth.js', () => ({
|
|||||||
describe('useAuth', () => {
|
describe('useAuth', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
process.env['GEMINI_API_KEY'] = '';
|
delete process.env['GEMINI_API_KEY'];
|
||||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
|
delete process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -55,11 +55,15 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const reloadApiKey = useCallback(async () => {
|
const reloadApiKey = useCallback(async () => {
|
||||||
|
const envKey = process.env['GEMINI_API_KEY'];
|
||||||
|
if (envKey !== undefined) {
|
||||||
|
setApiKeyDefaultValue(envKey);
|
||||||
|
return envKey;
|
||||||
|
}
|
||||||
|
|
||||||
const storedKey = (await loadApiKey()) ?? '';
|
const storedKey = (await loadApiKey()) ?? '';
|
||||||
const envKey = process.env['GEMINI_API_KEY'] ?? '';
|
setApiKeyDefaultValue(storedKey);
|
||||||
const key = envKey || storedKey;
|
return storedKey;
|
||||||
setApiKeyDefaultValue(key);
|
|
||||||
return key; // Return the key for immediate use
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const DialogManager = ({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<ApiAuthDialog
|
<ApiAuthDialog
|
||||||
|
key={uiState.apiKeyDefaultValue}
|
||||||
onSubmit={uiActions.handleApiKeySubmit}
|
onSubmit={uiActions.handleApiKeySubmit}
|
||||||
onCancel={uiActions.handleApiKeyCancel}
|
onCancel={uiActions.handleApiKeyCancel}
|
||||||
error={uiState.authError}
|
error={uiState.authError}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function createContentGeneratorConfig(
|
|||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
): Promise<ContentGeneratorConfig> {
|
): Promise<ContentGeneratorConfig> {
|
||||||
const geminiApiKey =
|
const geminiApiKey =
|
||||||
(await loadApiKey()) || process.env['GEMINI_API_KEY'] || undefined;
|
process.env['GEMINI_API_KEY'] || (await loadApiKey()) || undefined;
|
||||||
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
|
||||||
const googleCloudProject =
|
const googleCloudProject =
|
||||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||||
|
|||||||
Reference in New Issue
Block a user