diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index b6b26f280ab..38ca8462014 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -30,6 +30,7 @@ import { UserAccountManager } from '../utils/userAccountManager.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import http from 'node:http'; +import * as https from 'node:https'; import open from 'open'; import crypto from 'node:crypto'; import * as os from 'node:os'; @@ -410,6 +411,78 @@ describe('oauth2', () => { expect(mockOAuth2Client.setCredentials).toHaveBeenCalledWith(mockTokens); }); + describe('transporter keep-alive (nodejs/node#63989 workaround)', () => { + const setupUserCodeClient = () => { + const mockOAuth2Client = { + generateAuthUrl: vi.fn().mockReturnValue('https://example.com/auth'), + getToken: vi.fn().mockResolvedValue({ + tokens: { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + }, + }), + generateCodeVerifierAsync: vi.fn().mockResolvedValue({ + codeChallenge: 'test-challenge', + codeVerifier: 'test-verifier', + }), + getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), + setCredentials: vi.fn(), + on: vi.fn(), + credentials: {}, + } as unknown as OAuth2Client; + vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); + (readline.createInterface as Mock).mockReturnValue({ + question: vi.fn((_query, callback) => callback('test-user-code')), + close: vi.fn(), + on: vi.fn(), + }); + }; + + it('disables HTTP keep-alive when no proxy is configured', async () => { + setupUserCodeClient(); + const config = { + getNoBrowser: () => true, + getProxy: () => undefined, + isBrowserLaunchSuppressed: () => true, + isInteractive: () => true, + } as unknown as Config; + + await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config); + + const transporterOptions = ( + vi.mocked(OAuth2Client).mock.calls[0]?.[0] as unknown as { + transporterOptions?: { + agent?: { keepAlive?: boolean }; + proxy?: string; + }; + } + )?.transporterOptions; + expect(transporterOptions?.agent).toBeInstanceOf(https.Agent); + expect(transporterOptions?.agent?.keepAlive).toBe(false); + expect(transporterOptions?.proxy).toBeUndefined(); + }); + + it('keeps the proxy and no custom agent when a proxy is configured', async () => { + setupUserCodeClient(); + const config = { + getNoBrowser: () => true, + getProxy: () => 'http://test.proxy.com:8080', + isBrowserLaunchSuppressed: () => true, + isInteractive: () => true, + } as unknown as Config; + + await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, config); + + const transporterOptions = ( + vi.mocked(OAuth2Client).mock.calls[0]?.[0] as unknown as { + transporterOptions?: { agent?: unknown; proxy?: string }; + } + )?.transporterOptions; + expect(transporterOptions?.proxy).toBe('http://test.proxy.com:8080'); + expect(transporterOptions?.agent).toBeUndefined(); + }); + }); + it('should cache Google Account when logging in with user code', async () => { const mockConfigWithNoBrowser = { getNoBrowser: () => true, diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index a8d6f61b4a5..296b63d37a2 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -14,6 +14,7 @@ import { type JWTInput, } from 'google-auth-library'; import * as http from 'node:http'; +import * as https from 'node:https'; import url from 'node:url'; import crypto from 'node:crypto'; import * as net from 'node:net'; @@ -140,12 +141,21 @@ async function initOauthClient( } } + // gaxios/node-fetch reuse Node's global keep-alive agent for the token + // exchange. On Node >= 24.17.0 a regression in http.Agent socket reuse + // ("fix response queue poisoning") makes node-fetch throw a spurious + // ERR_STREAM_PREMATURE_CLOSE ("Premature close") when a pooled socket is + // reused, breaking OAuth sign-in. Disabling keep-alive forces a fresh + // connection per request and avoids the poisoned-socket race. When a proxy + // is configured we keep `proxy` instead, since passing our own agent would + // make gaxios ignore the proxy. See https://github.com/nodejs/node/issues/63989 + const proxy = config.getProxy(); const client = new OAuth2Client({ clientId: OAUTH_CLIENT_ID, clientSecret: OAUTH_CLIENT_SECRET, - transporterOptions: { - proxy: config.getProxy(), - }, + transporterOptions: proxy + ? { proxy } + : { agent: new https.Agent({ keepAlive: false }) }, }); const useEncryptedStorage = getUseEncryptedStorageFlag();