Automatically detect non-interactive environments and fall back to a manual, code-based authentication flow (#4475)
This commit is contained in:
parent
003609239f
commit
5b7b6fe608
|
@ -37,6 +37,7 @@ import {
|
||||||
logUserPrompt,
|
logUserPrompt,
|
||||||
AuthType,
|
AuthType,
|
||||||
getOauthClient,
|
getOauthClient,
|
||||||
|
shouldAttemptBrowserLaunch,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||||
|
@ -184,7 +185,7 @@ export async function main() {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
|
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||||
config.getNoBrowser()
|
(config.getNoBrowser() || !shouldAttemptBrowserLaunch())
|
||||||
) {
|
) {
|
||||||
// Do oauth before app renders to make copying the link possible.
|
// Do oauth before app renders to make copying the link possible.
|
||||||
await getOauthClient(settings.merged.selectedAuthType, config);
|
await getOauthClient(settings.merged.selectedAuthType, config);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Config,
|
Config,
|
||||||
clearCachedCredentialFile,
|
clearCachedCredentialFile,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
shouldAttemptBrowserLaunch,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||||
|
|
||||||
|
@ -56,7 +57,10 @@ export const useAuthCommand = (
|
||||||
if (authType) {
|
if (authType) {
|
||||||
await clearCachedCredentialFile();
|
await clearCachedCredentialFile();
|
||||||
settings.setValue(scope, 'selectedAuthType', authType);
|
settings.setValue(scope, 'selectedAuthType', authType);
|
||||||
if (authType === AuthType.LOGIN_WITH_GOOGLE && config.getNoBrowser()) {
|
if (
|
||||||
|
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||||
|
(config.getNoBrowser() || !shouldAttemptBrowserLaunch())
|
||||||
|
) {
|
||||||
runExitCleanup();
|
runExitCleanup();
|
||||||
console.log(
|
console.log(
|
||||||
`
|
`
|
||||||
|
|
|
@ -31,6 +31,9 @@ vi.mock('http');
|
||||||
vi.mock('open');
|
vi.mock('open');
|
||||||
vi.mock('crypto');
|
vi.mock('crypto');
|
||||||
vi.mock('node:readline');
|
vi.mock('node:readline');
|
||||||
|
vi.mock('../utils/browser.js', () => ({
|
||||||
|
shouldAttemptBrowserLaunch: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
getNoBrowser: () => false,
|
getNoBrowser: () => false,
|
||||||
|
@ -83,7 +86,7 @@ describe('oauth2', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never);
|
vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never);
|
||||||
(open as Mock).mockImplementation(async () => ({}) as never);
|
(open as Mock).mockImplementation(async () => ({ on: vi.fn() }) as never);
|
||||||
|
|
||||||
// Mock the UserInfo API response
|
// Mock the UserInfo API response
|
||||||
(global.fetch as Mock).mockResolvedValue({
|
(global.fetch as Mock).mockResolvedValue({
|
||||||
|
@ -236,7 +239,7 @@ describe('oauth2', () => {
|
||||||
expect(mockGetToken).toHaveBeenCalledWith({
|
expect(mockGetToken).toHaveBeenCalledWith({
|
||||||
code: mockCode,
|
code: mockCode,
|
||||||
codeVerifier: mockCodeVerifier.codeVerifier,
|
codeVerifier: mockCodeVerifier.codeVerifier,
|
||||||
redirect_uri: 'https://sdk.cloud.google.com/authcode_cloudcode.html',
|
redirect_uri: 'https://codeassist.google.com/authcode',
|
||||||
});
|
});
|
||||||
expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens);
|
expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens);
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {
|
||||||
clearCachedGoogleAccount,
|
clearCachedGoogleAccount,
|
||||||
} from '../utils/user_account.js';
|
} from '../utils/user_account.js';
|
||||||
import { AuthType } from '../core/contentGenerator.js';
|
import { AuthType } from '../core/contentGenerator.js';
|
||||||
|
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||||
import readline from 'node:readline';
|
import readline from 'node:readline';
|
||||||
|
|
||||||
// OAuth Client ID used to initiate OAuth2Client class.
|
// OAuth Client ID used to initiate OAuth2Client class.
|
||||||
|
@ -121,7 +122,7 @@ export async function getOauthClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.getNoBrowser()) {
|
if (config.getNoBrowser() || !shouldAttemptBrowserLaunch()) {
|
||||||
let success = false;
|
let success = false;
|
||||||
const maxRetries = 2;
|
const maxRetries = 2;
|
||||||
for (let i = 0; !success && i < maxRetries; i++) {
|
for (let i = 0; !success && i < maxRetries; i++) {
|
||||||
|
@ -156,15 +157,17 @@ export async function getOauthClient(
|
||||||
// causing the entire Node.js process to crash.
|
// causing the entire Node.js process to crash.
|
||||||
childProcess.on('error', (_) => {
|
childProcess.on('error', (_) => {
|
||||||
console.error(
|
console.error(
|
||||||
'Failed to open browser automatically. Please open the URL manually:',
|
'Failed to open browser automatically. Please try running again with NO_BROWSER=true set.',
|
||||||
);
|
);
|
||||||
console.error(webLogin.authUrl);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'An unexpected error occurred while trying to open the browser:',
|
'An unexpected error occurred while trying to open the browser:',
|
||||||
err,
|
err,
|
||||||
|
'\nPlease try running again with NO_BROWSER=true set.',
|
||||||
);
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('Waiting for authentication...');
|
console.log('Waiting for authentication...');
|
||||||
|
|
||||||
|
@ -175,7 +178,7 @@ export async function getOauthClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
|
async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
|
||||||
const redirectUri = 'https://sdk.cloud.google.com/authcode_cloudcode.html';
|
const redirectUri = 'https://codeassist.google.com/authcode';
|
||||||
const codeVerifier = await client.generateCodeVerifierAsync();
|
const codeVerifier = await client.generateCodeVerifierAsync();
|
||||||
const state = crypto.randomBytes(32).toString('hex');
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
const authUrl: string = client.generateAuthUrl({
|
const authUrl: string = client.generateAuthUrl({
|
||||||
|
|
|
@ -80,3 +80,4 @@ export { OAuthUtils } from './mcp/oauth-utils.js';
|
||||||
// Export telemetry functions
|
// Export telemetry functions
|
||||||
export * from './telemetry/index.js';
|
export * from './telemetry/index.js';
|
||||||
export { sessionId } from './utils/session.js';
|
export { sessionId } from './utils/session.js';
|
||||||
|
export * from './utils/browser.js';
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if we should attempt to launch a browser for authentication
|
||||||
|
* based on the user's environment.
|
||||||
|
*
|
||||||
|
* This is an adaptation of the logic from the Google Cloud SDK.
|
||||||
|
* @returns True if the tool should attempt to launch a browser.
|
||||||
|
*/
|
||||||
|
export function shouldAttemptBrowserLaunch(): boolean {
|
||||||
|
// A list of browser names that indicate we should not attempt to open a
|
||||||
|
// web browser for the user.
|
||||||
|
const browserBlocklist = ['www-browser'];
|
||||||
|
const browserEnv = process.env.BROWSER;
|
||||||
|
if (browserEnv && browserBlocklist.includes(browserEnv)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Common environment variables used in CI/CD or other non-interactive shells.
|
||||||
|
if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The presence of SSH_CONNECTION indicates a remote session.
|
||||||
|
// We should not attempt to launch a browser unless a display is explicitly available
|
||||||
|
// (checked below for Linux).
|
||||||
|
const isSSH = !!process.env.SSH_CONNECTION;
|
||||||
|
|
||||||
|
// On Linux, the presence of a display server is a strong indicator of a GUI.
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
// These are environment variables that can indicate a running compositor on
|
||||||
|
// Linux.
|
||||||
|
const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];
|
||||||
|
const hasDisplay = displayVariables.some((v) => !!process.env[v]);
|
||||||
|
if (!hasDisplay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.
|
||||||
|
// The Linux case is handled above (it's allowed if DISPLAY is set).
|
||||||
|
if (isSSH && process.platform !== 'linux') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-Linux OSes, we generally assume a GUI is available
|
||||||
|
// unless other signals (like SSH) suggest otherwise.
|
||||||
|
// The `open` command's error handling will catch final edge cases.
|
||||||
|
return true;
|
||||||
|
}
|
Loading…
Reference in New Issue