fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions (#21045)

This commit is contained in:
Spencer
2026-03-04 15:35:21 -05:00
committed by GitHub
parent 29b3aa860c
commit c59ef74837
5 changed files with 335 additions and 13 deletions
+27 -1
View File
@@ -40,7 +40,10 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { GEMINI_DIR, homedir as pathsHomedir } from '../utils/paths.js';
import { debugLogger } from '../utils/debugLogger.js';
import { writeToStdout } from '../utils/stdio.js';
import { FatalCancellationError } from '../utils/errors.js';
import {
FatalCancellationError,
FatalAuthenticationError,
} from '../utils/errors.js';
import process from 'node:process';
import { coreEvents } from '../utils/events.js';
import { isHeadlessMode } from '../utils/headless.js';
@@ -107,6 +110,7 @@ const mockConfig = {
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
isInteractive: () => true,
} as unknown as Config;
// Mock fetch globally
@@ -316,11 +320,31 @@ describe('oauth2', () => {
await eventPromise;
});
it('should throw FatalAuthenticationError in non-interactive session when manual auth is required', async () => {
const mockConfigNonInteractive = {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => false,
} as unknown as Config;
await expect(
getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive),
).rejects.toThrow(FatalAuthenticationError);
await expect(
getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfigNonInteractive),
).rejects.toThrow(
'Manual authorization is required but the current session is non-interactive.',
);
});
it('should perform login with user code', async () => {
const mockConfigWithNoBrowser = {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => true,
} as unknown as Config;
const mockCodeVerifier = {
@@ -391,6 +415,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => true,
} as unknown as Config;
const mockCodeVerifier = {
@@ -1171,6 +1196,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => true,
} as unknown as Config;
const mockOAuth2Client = {
+18 -1
View File
@@ -226,6 +226,13 @@ async function initOauthClient(
}
if (config.isBrowserLaunchSuppressed()) {
if (!config.isInteractive()) {
throw new FatalAuthenticationError(
'Manual authorization is required but the current session is non-interactive. ' +
'Please run the Gemini CLI in an interactive terminal to log in, ' +
'provide a GEMINI_API_KEY, or ensure Application Default Credentials are configured.',
);
}
let success = false;
const maxRetries = 2;
// Enter alternate buffer
@@ -412,14 +419,24 @@ async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
'\n\n',
);
const code = await new Promise<string>((resolve, _) => {
const code = await new Promise<string>((resolve, reject) => {
const rl = readline.createInterface({
input: process.stdin,
output: createWorkingStdio().stdout,
terminal: true,
});
const timeout = setTimeout(() => {
rl.close();
reject(
new FatalAuthenticationError(
'Authorization timed out after 5 minutes.',
),
);
}, 300000); // 5 minute timeout
rl.question('Enter the authorization code: ', (code) => {
clearTimeout(timeout);
rl.close();
resolve(code.trim());
});