mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
fix(auth): resolve TLS socket disconnect in proxy environments
This commit is contained in:
parent
0957f7d3e2
commit
64a64e7eef
13 changed files with 247 additions and 32 deletions
|
|
@ -34,6 +34,7 @@ import {
|
|||
debugLogger,
|
||||
isHeadlessMode,
|
||||
Storage,
|
||||
getProxyAgent,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
|
|
@ -245,6 +246,7 @@ export async function startInteractiveUI(
|
|||
}
|
||||
|
||||
export async function main() {
|
||||
getProxyAgent(); // Initialize proxy agent from environment variables
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { HttpHeaders } from '@a2a-js/sdk/client';
|
|||
import { BaseA2AAuthProvider } from './base-provider.js';
|
||||
import type { GoogleCredentialsAuthConfig } from './types.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { getProxyAgent } from '../../utils/proxy.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from '../../mcp/oauth-utils.js';
|
||||
|
||||
|
|
@ -65,6 +66,9 @@ export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
|||
|
||||
this.auth = new GoogleAuth({
|
||||
scopes,
|
||||
clientOptions: {
|
||||
agent: getProxyAgent(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { getClientMetadata } from './client_metadata.js';
|
|||
import type { ListExperimentsResponse, Flag } from './types.js';
|
||||
import * as fs from 'node:fs';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { isNetworkError, getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
export interface Experiments {
|
||||
flags: Record<string, Flag>;
|
||||
|
|
@ -55,9 +56,21 @@ export async function getExperiments(
|
|||
return { flags: {}, experimentIds: [] };
|
||||
}
|
||||
|
||||
const metadata = await getClientMetadata();
|
||||
const response = await server.listExperiments(metadata);
|
||||
return parseExperiments(response);
|
||||
try {
|
||||
const metadata = await getClientMetadata();
|
||||
const response = await server.listExperiments(metadata);
|
||||
return parseExperiments(response);
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
debugLogger.warn(
|
||||
'Network error while fetching experiments. Experiments will be disabled for this session.',
|
||||
getErrorMessage(e),
|
||||
);
|
||||
} else {
|
||||
debugLogger.error('Failed to fetch experiments', e);
|
||||
}
|
||||
return { flags: {}, experimentIds: [] };
|
||||
}
|
||||
})();
|
||||
return experimentsPromise;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import readline from 'node:readline';
|
|||
import { Storage } from '../config/storage.js';
|
||||
import { OAuthCredentialStorage } from './oauth-credential-storage.js';
|
||||
import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
|
||||
import { getProxyAgent, withProxy, shouldProxy } from '../utils/proxy.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
writeToStdout,
|
||||
|
|
@ -124,6 +125,9 @@ async function initOauthClient(
|
|||
) {
|
||||
const auth = new GoogleAuth({
|
||||
scopes: OAUTH_SCOPE,
|
||||
clientOptions: {
|
||||
agent: getProxyAgent(),
|
||||
},
|
||||
});
|
||||
const byoidClient = auth.fromJSON({
|
||||
...credentials,
|
||||
|
|
@ -141,6 +145,7 @@ async function initOauthClient(
|
|||
clientSecret: OAUTH_CLIENT_SECRET,
|
||||
transporterOptions: {
|
||||
proxy: config.getProxy(),
|
||||
agent: getProxyAgent(),
|
||||
},
|
||||
});
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
|
@ -501,7 +506,8 @@ async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
|
|||
|
||||
async function authWithWeb(client: OAuth2Client): Promise<OauthWebLogin> {
|
||||
const port = await getAvailablePort();
|
||||
// The hostname used for the HTTP server binding (e.g., '0.0.0.0' in Docker).
|
||||
// Ensure we bind to 127.0.0.1 by default to prevent hijacking from external interfaces.
|
||||
// In Docker, OAUTH_CALLBACK_HOST might be 0.0.0.0, but for local auth loopback is preferred.
|
||||
const host = process.env['OAUTH_CALLBACK_HOST'] || '127.0.0.1';
|
||||
// The `redirectUri` sent to Google's authorization server MUST use a loopback IP literal
|
||||
// (i.e., 'localhost' or '127.0.0.1'). This is a strict security policy for credentials of
|
||||
|
|
@ -715,14 +721,15 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
const url = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
||||
const fetchOptions = withProxy({
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
debugLogger.log(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import {
|
|||
} from './types.js';
|
||||
import { CodeAssistServer, type HttpOptions } from './server.js';
|
||||
import type { AuthClient } from 'google-auth-library';
|
||||
import { ChangeAuthRequestedError } from '../utils/errors.js';
|
||||
import {
|
||||
ChangeAuthRequestedError,
|
||||
isNetworkError,
|
||||
getErrorMessage,
|
||||
} from '../utils/errors.js';
|
||||
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { createCache, type CacheService } from '../utils/cache.js';
|
||||
|
|
@ -161,13 +165,25 @@ async function _doSetupUser(
|
|||
|
||||
let loadRes: LoadCodeAssistResponse;
|
||||
while (true) {
|
||||
loadRes = await caServer.loadCodeAssist({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
...coreClientMetadata,
|
||||
duetProject: projectId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
loadRes = await caServer.loadCodeAssist({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
...coreClientMetadata,
|
||||
duetProject: projectId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
debugLogger.warn(
|
||||
'Network error while loading user info from Code Assist. Retrying in 5 seconds...',
|
||||
getErrorMessage(e),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
validateLoadCodeAssistResponse(loadRes);
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
|||
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isNetworkError, getErrorMessage } from '../utils/errors.js';
|
||||
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
|
|
@ -2142,7 +2143,14 @@ export class Config implements McpContext, AgentLoopContext {
|
|||
this.setHasAccessToPreviewModel(hasAccess);
|
||||
return quota;
|
||||
} catch (e) {
|
||||
debugLogger.debug('Failed to retrieve user quota', e);
|
||||
if (isNetworkError(e)) {
|
||||
debugLogger.warn(
|
||||
'Network error while retrieving user quota. Some features may be limited.',
|
||||
getErrorMessage(e),
|
||||
);
|
||||
} else {
|
||||
debugLogger.debug('Failed to retrieve user quota', e);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export * from './utils/partUtils.js';
|
|||
export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/proxy.js';
|
||||
export * from './utils/debugLogger.js';
|
||||
export * from './utils/events.js';
|
||||
export * from './utils/extensionLoader.js';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { getProxyAgent } from '../utils/proxy.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
|
|
@ -57,6 +58,9 @@ export class GoogleCredentialProvider implements McpAuthProvider {
|
|||
}
|
||||
this.auth = new GoogleAuth({
|
||||
scopes,
|
||||
clientOptions: {
|
||||
agent: getProxyAgent(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { getProxyAgent } from '../utils/proxy.js';
|
||||
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
import type { McpAuthProvider } from './auth-provider.js';
|
||||
|
|
@ -62,7 +63,11 @@ export class ServiceAccountImpersonationProvider implements McpAuthProvider {
|
|||
}
|
||||
this.targetServiceAccount = config.targetServiceAccount;
|
||||
|
||||
this.auth = new GoogleAuth();
|
||||
this.auth = new GoogleAuth({
|
||||
clientOptions: {
|
||||
agent: getProxyAgent(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,13 @@ export class KeychainService {
|
|||
* @throws Error if the keychain is unavailable.
|
||||
*/
|
||||
async getPassword(account: string): Promise<string | null> {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return keychain.getPassword(this.serviceName, account);
|
||||
try {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return await keychain.getPassword(this.serviceName, account);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Native keychain getPassword failed, falling back to FileKeychain:', e);
|
||||
return await (new FileKeychain()).getPassword(this.serviceName, account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,8 +64,13 @@ export class KeychainService {
|
|||
* @throws Error if the keychain is unavailable.
|
||||
*/
|
||||
async setPassword(account: string, value: string): Promise<void> {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
await keychain.setPassword(this.serviceName, account, value);
|
||||
try {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
await keychain.setPassword(this.serviceName, account, value);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Native keychain setPassword failed (possibly D-Bus issue or locked), falling back to FileKeychain:', e);
|
||||
await (new FileKeychain()).setPassword(this.serviceName, account, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,19 +79,27 @@ export class KeychainService {
|
|||
* @throws Error if the keychain is unavailable.
|
||||
*/
|
||||
async deletePassword(account: string): Promise<boolean> {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return keychain.deletePassword(this.serviceName, account);
|
||||
try {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return await keychain.deletePassword(this.serviceName, account);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Native keychain deletePassword failed, falling back to FileKeychain:', e);
|
||||
return await (new FileKeychain()).deletePassword(this.serviceName, account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all account/secret pairs stored under this service.
|
||||
* @throws Error if the keychain is unavailable.
|
||||
*/
|
||||
async findCredentials(): Promise<
|
||||
Array<{ account: string; password: string }>
|
||||
> {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return keychain.findCredentials(this.serviceName);
|
||||
async findCredentials(): Promise<Array<{ account: string; password: string }>> {
|
||||
try {
|
||||
const keychain = await this.getKeychainOrThrow();
|
||||
return await keychain.findCredentials(this.serviceName);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Native keychain findCredentials failed, falling back to FileKeychain:', e);
|
||||
return await (new FileKeychain()).findCredentials(this.serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
private async getKeychainOrThrow(): Promise<Keychain> {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,35 @@ export function isAbortError(error: unknown): boolean {
|
|||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is a network error (e.g. timeout, connection reset, etc.).
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (isNodeError(error)) {
|
||||
const code = error.code;
|
||||
return (
|
||||
code === 'ECONNRESET' ||
|
||||
code === 'ETIMEDOUT' ||
|
||||
code === 'ECONNREFUSED' ||
|
||||
code === 'ENOTFOUND' ||
|
||||
code === 'ENETUNREACH' ||
|
||||
code === 'EAI_AGAIN' ||
|
||||
code === 'EAI_FAIL' ||
|
||||
code === 'UND_ERR_CONNECT_TIMEOUT' ||
|
||||
code === 'UND_ERR_HEADERS_TIMEOUT' ||
|
||||
code === 'UND_ERR_SOCKET'
|
||||
);
|
||||
}
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
message.includes('network error') ||
|
||||
message.includes('disconnected') ||
|
||||
message.includes('socket disconnected') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('etimedout')
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
const friendlyError = toFriendlyError(error);
|
||||
if (friendlyError instanceof Error) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { URL } from 'node:url';
|
|||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { shouldProxy } from './proxy.js';
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
|
|
@ -216,6 +217,9 @@ export function setGlobalProxy(proxy: string) {
|
|||
uri: proxy,
|
||||
headersTimeout: defaultTimeout,
|
||||
bodyTimeout: defaultTimeout,
|
||||
// undici ProxyAgent supports skipProxy to avoid proxying for specific hosts.
|
||||
// We use our shouldProxy utility which handles NO_PROXY and loopback.
|
||||
skipProxy: (url) => !shouldProxy(url),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
104
packages/core/src/utils/proxy.ts
Normal file
104
packages/core/src/utils/proxy.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
let memoizedAgent: HttpsProxyAgent<string> | undefined;
|
||||
let agentChecked = false;
|
||||
|
||||
/**
|
||||
* Checks if a given URL should be proxied based on NO_PROXY/no_proxy environment variables.
|
||||
* Also strictly ignores localhost and 127.0.0.1 by default.
|
||||
*/
|
||||
export function shouldProxy(urlStr: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlStr);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Strictly ignore loopback addresses
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]' ||
|
||||
hostname === '0.0.0.0'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||
if (!noProxy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (noProxy === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const noProxyList = noProxy.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
for (const rule of noProxyList) {
|
||||
if (rule.startsWith('.')) {
|
||||
if (hostname.endsWith(rule)) {
|
||||
return false;
|
||||
}
|
||||
} else if (hostname === rule || hostname.endsWith('.' + rule)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error parsing URL in shouldProxy: ${urlStr}`, error);
|
||||
return true; // Default to proxying if URL is weird, but usually it shouldn't be.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an HttpsProxyAgent if a proxy is configured in the environment.
|
||||
*/
|
||||
export function getProxyAgent(): HttpsProxyAgent<string> | undefined {
|
||||
if (agentChecked) {
|
||||
return memoizedAgent;
|
||||
}
|
||||
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY || process.env.http_proxy;
|
||||
|
||||
if (proxy) {
|
||||
try {
|
||||
debugLogger.debug(`Proxy detected: ${proxy}. Creating HttpsProxyAgent.`);
|
||||
memoizedAgent = new HttpsProxyAgent(proxy);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Failed to create HttpsProxyAgent for proxy ${proxy}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
agentChecked = true;
|
||||
return memoizedAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures a gaxios-compatible options object with the proxy agent if available
|
||||
* and if the URL should be proxied.
|
||||
*/
|
||||
export function withProxy<T extends { agent?: any; url?: string }>(options: T): T {
|
||||
if (options.url && !shouldProxy(options.url)) {
|
||||
// Force no agent (default) for loopback/no_proxy URLs
|
||||
const { agent, ...rest } = options;
|
||||
return rest as T;
|
||||
}
|
||||
|
||||
const agent = getProxyAgent();
|
||||
if (agent) {
|
||||
return {
|
||||
...options,
|
||||
agent,
|
||||
};
|
||||
}
|
||||
return options;
|
||||
}
|
||||
Loading…
Reference in a new issue