fix(auth): resolve TLS socket disconnect in proxy environments

This commit is contained in:
ruchid123123 2026-04-11 09:28:28 +08:00
parent 0957f7d3e2
commit 64a64e7eef
13 changed files with 247 additions and 32 deletions

View file

@ -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

View file

@ -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(),
},
});
}

View file

@ -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;
}

View file

@ -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(

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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(),
},
});
}

View file

@ -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 {

View file

@ -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> {

View file

@ -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) {

View file

@ -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),
}),
);
}

View 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;
}