From ff2ff511bdc49057da48e805168510ffa260e266 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 4 Mar 2026 02:10:46 +0000 Subject: [PATCH 1/4] fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions --- packages/a2a-server/src/config/config.test.ts | 104 ++++++++++++++++++ packages/a2a-server/src/config/config.ts | 35 +++++- packages/core/src/code_assist/oauth2.test.ts | 28 ++++- packages/core/src/code_assist/oauth2.ts | 19 +++- 4 files changed, 181 insertions(+), 5 deletions(-) diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index c676e462899..515e0722be7 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -16,6 +16,9 @@ import { ExperimentFlags, fetchAdminControlsOnce, type FetchAdminControlsResponse, + AuthType, + isHeadlessMode, + FatalAuthenticationError, } from '@google/gemini-cli-core'; // Mock dependencies @@ -50,6 +53,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { startupProfiler: { flush: vi.fn(), }, + isHeadlessMode: vi.fn().mockReturnValue(false), FileDiscoveryService: vi.fn(), getCodeAssistServer: vi.fn(), fetchAdminControlsOnce: vi.fn(), @@ -62,6 +66,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('../utils/logger.js', () => ({ logger: { info: vi.fn(), + warn: vi.fn(), error: vi.fn(), }, })); @@ -311,5 +316,104 @@ describe('loadConfig', () => { }), ); }); + + describe('interactivity', () => { + it('should set interactive true when not headless', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + enableInteractiveShell: true, + }), + ); + }); + + it('should set interactive false when headless', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: false, + enableInteractiveShell: false, + }), + ); + }); + }); + + describe('authentication fallback', () => { + beforeEach(() => { + process.env['USE_CCPA'] = 'true'; + delete process.env['GEMINI_API_KEY']; + }); + + afterEach(() => { + delete process.env['USE_CCPA']; + delete process.env['CLOUD_SHELL']; + delete process.env['GEMINI_CLI_USE_COMPUTE_ADC']; + }); + + it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { + process.env['CLOUD_SHELL'] = 'true'; + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('Non-interactive session'); + } + return Promise.resolve(); + }); + + // Update the mock implementation for this test + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => { + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('Non-interactive session'); + } + return Promise.resolve(); + }); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow('Non-interactive session'); + + expect(refreshAuthMock).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + }); }); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index f3100bce4dc..5055b32e921 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -23,6 +23,8 @@ import { fetchAdminControlsOnce, getCodeAssistServer, ExperimentFlags, + isHeadlessMode, + FatalAuthenticationError, type TelemetryTarget, type ConfigParameters, type ExtensionLoader, @@ -103,8 +105,8 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - interactive: true, - enableInteractiveShell: true, + interactive: !isHeadlessMode(), + enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', }; @@ -255,7 +257,30 @@ async function refreshAuthentication( `[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`, ); } - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + try { + await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + } catch (e) { + if ( + e instanceof FatalAuthenticationError && + (isCloudEnvironment() || + process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true') + ) { + logger.warn( + `[${logPrefix}] LOGIN_WITH_GOOGLE failed in non-interactive mode. Attempting COMPUTE_ADC fallback.`, + ); + try { + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); + } catch (adcError) { + logger.error( + `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, + ); + throw e; // throw the original error + } + } else { + throw e; + } + } logger.info( `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`, ); @@ -268,3 +293,7 @@ async function refreshAuthentication( throw new Error(errorMessage); } } + +function isCloudEnvironment(): boolean { + return !!(process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']); +} diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index c1fe162e632..73636db1f80 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -29,7 +29,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'; @@ -96,6 +99,7 @@ const mockConfig = { getProxy: () => 'http://test.proxy.com:8080', isBrowserLaunchSuppressed: () => false, getExperimentalZedIntegration: () => false, + isInteractive: () => true, } as unknown as Config; // Mock fetch globally @@ -305,11 +309,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 = { @@ -380,6 +404,7 @@ describe('oauth2', () => { getNoBrowser: () => true, getProxy: () => 'http://test.proxy.com:8080', isBrowserLaunchSuppressed: () => true, + isInteractive: () => true, } as unknown as Config; const mockCodeVerifier = { @@ -1160,6 +1185,7 @@ describe('oauth2', () => { getNoBrowser: () => true, getProxy: () => 'http://test.proxy.com:8080', isBrowserLaunchSuppressed: () => true, + isInteractive: () => true, } as unknown as Config; const mockOAuth2Client = { diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 31bc3c0e5eb..53356851272 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -224,6 +224,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 @@ -410,14 +417,24 @@ async function authWithUserCode(client: OAuth2Client): Promise { '\n\n', ); - const code = await new Promise((resolve, _) => { + const code = await new Promise((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()); }); From be91032e664f3214c28a0e2f9f666120b53e3198 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 4 Mar 2026 02:19:49 +0000 Subject: [PATCH 2/4] fix(a2a-server): improve error message when COMPUTE_ADC fallback fails --- packages/a2a-server/src/config/config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 5055b32e921..fba85943b24 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -275,7 +275,12 @@ async function refreshAuthentication( logger.error( `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, ); - throw e; // throw the original error + const originalMessage = e instanceof Error ? e.message : String(e); + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + throw new FatalAuthenticationError( + `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, + ); } } else { throw e; From a8131d256ee4cf537a1779b5ae4ee36485277d90 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 4 Mar 2026 03:00:49 +0000 Subject: [PATCH 3/4] refactor: address review comments for a2a-server auth and testing standards --- packages/a2a-server/src/config/config.test.ts | 23 ++++++++----------- packages/a2a-server/src/config/config.ts | 8 ++----- packages/core/src/index.ts | 6 ++++- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 515e0722be7..1cc83269d69 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -78,12 +78,11 @@ describe('loadConfig', () => { beforeEach(() => { vi.clearAllMocks(); - process.env['GEMINI_API_KEY'] = 'test-key'; + vi.stubEnv('GEMINI_API_KEY', 'test-key'); }); afterEach(() => { - delete process.env['CUSTOM_IGNORE_FILE_PATHS']; - delete process.env['GEMINI_API_KEY']; + vi.unstubAllEnvs(); }); describe('admin settings overrides', () => { @@ -204,7 +203,7 @@ describe('loadConfig', () => { it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => { const testPath = '/tmp/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ @@ -229,7 +228,7 @@ describe('loadConfig', () => { it('should merge customIgnoreFilePaths from settings and env var', async () => { const envPath = '/env/ignore'; const settingsPath = '/settings/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath); const settings: Settings = { fileFiltering: { customIgnoreFilePaths: [settingsPath], @@ -245,7 +244,7 @@ describe('loadConfig', () => { it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { const paths = ['/path/one', '/path/two']; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter); + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter)); const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); @@ -259,7 +258,7 @@ describe('loadConfig', () => { it('should initialize FileDiscoveryService with correct options', async () => { const testPath = '/tmp/ignore'; - process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); const settings: Settings = { fileFiltering: { respectGitIgnore: false, @@ -343,18 +342,16 @@ describe('loadConfig', () => { describe('authentication fallback', () => { beforeEach(() => { - process.env['USE_CCPA'] = 'true'; - delete process.env['GEMINI_API_KEY']; + vi.stubEnv('USE_CCPA', 'true'); + vi.stubEnv('GEMINI_API_KEY', ''); }); afterEach(() => { - delete process.env['USE_CCPA']; - delete process.env['CLOUD_SHELL']; - delete process.env['GEMINI_CLI_USE_COMPUTE_ADC']; + vi.unstubAllEnvs(); }); it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { - process.env['CLOUD_SHELL'] = 'true'; + vi.stubEnv('CLOUD_SHELL', 'true'); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('Non-interactive session'); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index fba85943b24..9d4d5fe9ab7 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -25,6 +25,7 @@ import { ExperimentFlags, isHeadlessMode, FatalAuthenticationError, + isCloudShell, type TelemetryTarget, type ConfigParameters, type ExtensionLoader, @@ -262,8 +263,7 @@ async function refreshAuthentication( } catch (e) { if ( e instanceof FatalAuthenticationError && - (isCloudEnvironment() || - process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true') + (isCloudShell() || process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true') ) { logger.warn( `[${logPrefix}] LOGIN_WITH_GOOGLE failed in non-interactive mode. Attempting COMPUTE_ADC fallback.`, @@ -298,7 +298,3 @@ async function refreshAuthentication( throw new Error(errorMessage); } } - -function isCloudEnvironment(): boolean { - return !!(process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']); -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ce5e77d813..64749da8334 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,7 +128,11 @@ export * from './skills/skillLoader.js'; export * from './ide/ide-client.js'; export * from './ide/ideContext.js'; export * from './ide/ide-installer.js'; -export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; +export { + IDE_DEFINITIONS, + type IdeInfo, + isCloudShell, +} from './ide/detect-ide.js'; export * from './ide/constants.js'; export * from './ide/types.js'; From 3775c21a69aec2136df037d425dfc5866a66605a Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 4 Mar 2026 20:15:02 +0000 Subject: [PATCH 4/4] fix(a2a-server): prevent hang during OAuth in non-interactive sessions - Introduced a pre-check for headless mode and GEMINI_CLI_USE_COMPUTE_ADC to avoid blocking OAuth prompts in non-interactive environments. - Updated refreshAuthentication to skip LOGIN_WITH_GOOGLE and prefer COMPUTE_ADC directly when headless or explicitly requested. - Improved error reporting by including both original failure and fallback failure details in FatalAuthenticationError. - Added comprehensive test cases in config.test.ts for headless success, ADC override, and detailed error reporting. - Verified fix with full monorepo preflight check. Fixes #21045 --- packages/a2a-server/src/config/config.test.ts | 117 ++++++++++++++++++ packages/a2a-server/src/config/config.ts | 59 ++++++--- 2 files changed, 160 insertions(+), 16 deletions(-) diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 1cc83269d69..ee63df36f7c 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -352,6 +352,7 @@ describe('loadConfig', () => { it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => { vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('Non-interactive session'); @@ -382,6 +383,7 @@ describe('loadConfig', () => { }); it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); const refreshAuthMock = vi.fn().mockImplementation((authType) => { if (authType === AuthType.LOGIN_WITH_GOOGLE) { throw new FatalAuthenticationError('Non-interactive session'); @@ -411,6 +413,121 @@ describe('loadConfig', () => { ); expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC); }); + + it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => { + vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + }); + + it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.', + ); + + expect(refreshAuthMock).not.toHaveBeenCalled(); + }); + + it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + vi.mocked(isHeadlessMode).mockReturnValue(false); + + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('OAuth failed'); + } + if (authType === AuthType.COMPUTE_ADC) { + throw new Error('ADC failed'); + } + return Promise.resolve(); + }); + + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed', + ); + }); }); }); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 9d4d5fe9ab7..1b236f9ac76 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -258,32 +258,59 @@ async function refreshAuthentication( `[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`, ); } - try { - await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); - } catch (e) { - if ( - e instanceof FatalAuthenticationError && - (isCloudShell() || process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true') - ) { - logger.warn( - `[${logPrefix}] LOGIN_WITH_GOOGLE failed in non-interactive mode. Attempting COMPUTE_ADC fallback.`, + + const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; + const isHeadless = isHeadlessMode(); + const shouldSkipOauth = isHeadless || useComputeAdc; + + if (shouldSkipOauth) { + if (isCloudShell() || useComputeAdc) { + logger.info( + `[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`, ); try { await config.refreshAuth(AuthType.COMPUTE_ADC); - logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); + logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); } catch (adcError) { - logger.error( - `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, - ); - const originalMessage = e instanceof Error ? e.message : String(e); const adcMessage = adcError instanceof Error ? adcError.message : String(adcError); throw new FatalAuthenticationError( - `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, + `COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`, ); } } else { - throw e; + throw new FatalAuthenticationError( + `Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`, + ); + } + } else { + try { + await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + } catch (e) { + if ( + e instanceof FatalAuthenticationError && + (isCloudShell() || useComputeAdc) + ) { + logger.warn( + `[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`, + ); + try { + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`); + } catch (adcError) { + logger.error( + `[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`, + ); + const originalMessage = e instanceof Error ? e.message : String(e); + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + throw new FatalAuthenticationError( + `${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`, + ); + } + } else { + throw e; + } } } logger.info(