Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/a2a-server/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
AuthType,
isHeadlessMode,
FatalAuthenticationError,
} from '@google/gemini-cli-core';

// Mock dependencies
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
},
}));
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove

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);
});
});
});
});
35 changes: 32 additions & 3 deletions packages/a2a-server/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
Expand Down Expand Up @@ -103,8 +105,8 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
interactive: true,
enableInteractiveShell: true,
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
};

Expand Down Expand Up @@ -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']}`,
);
Expand All @@ -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']);
}
28 changes: 27 additions & 1 deletion packages/core/src/code_assist/oauth2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -380,6 +404,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => true,
} as unknown as Config;

const mockCodeVerifier = {
Expand Down Expand Up @@ -1160,6 +1185,7 @@ describe('oauth2', () => {
getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => true,
isInteractive: () => true,
} as unknown as Config;

const mockOAuth2Client = {
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/code_assist/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -410,14 +417,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());
});
Expand Down
Loading