diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index a6a6d4e5dd8..87f659ae269 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -306,6 +306,105 @@ describe('github.ts', () => { ExtensionUpdateState.UPDATE_AVAILABLE, ); }); + + it('sets non-interactive environment variables on git instance', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://example.com/repo.git' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash123\tHEAD'); + mockGit.revparse.mockResolvedValue('hash123'); + + const extension = { + name: 'test-ext', + path: '/path/to/ext', + installMetadata: { + type: 'git', + source: 'https://example.com/repo.git', + }, + version: '1.0.0', + config: { name: 'test-ext', version: '1.0.0' }, + } as unknown as GeminiCLIExtension; + + const state = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(mockGit.env).toHaveBeenCalledWith( + expect.objectContaining({ + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: expect.stringContaining('BatchMode=yes'), + }), + ); + expect(state).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('gracefully handles terminal prompt suppression error without throwing', async () => { + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://example.com/private-repo.git' }, + }, + ]); + mockGit.listRemote.mockRejectedValue( + new Error('fatal: could not read Username: terminal prompts disabled'), + ); + + const extension = { + name: 'private-ext', + path: '/path/to/ext', + installMetadata: { + type: 'git', + source: 'https://example.com/private-repo.git', + }, + version: '1.0.0', + config: { name: 'private-ext', version: '1.0.0' }, + } as unknown as GeminiCLIExtension; + + const state = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(state).toBe(ExtensionUpdateState.ERROR); + }); + + it('should clear the timeout timer when git.listRemote resolves', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'url' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash\tHEAD'); + mockGit.revparse.mockResolvedValue('hash'); + + const ext = { + path: '/path', + installMetadata: { type: 'git', source: 'url' }, + } as unknown as GeminiCLIExtension; + + await checkForExtensionUpdate(ext, mockExtensionManager); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + it('should clear the timeout timer when git.listRemote rejects', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'url' } }, + ]); + mockGit.listRemote.mockRejectedValue(new Error('git error')); + + const ext = { + path: '/path', + installMetadata: { type: 'git', source: 'url' }, + } as unknown as GeminiCLIExtension; + + await checkForExtensionUpdate(ext, mockExtensionManager); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); }); describe('downloadFromGitHubRelease', () => { diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts index 46d52dd0e4b..e12a4138066 100644 --- a/packages/cli/src/config/extensions/github.ts +++ b/packages/cli/src/config/extensions/github.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { simpleGit } from 'simple-git'; +import { simpleGit, type SimpleGit } from 'simple-git'; import { debugLogger, getErrorMessage, @@ -24,6 +24,33 @@ import type { ExtensionConfig } from '../extension.js'; import type { ExtensionManager } from '../extension-manager.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +const GIT_BACKGROUND_TIMEOUT_MS = 15_000; + +/** + * Instantiates a SimpleGit client configured for non-interactive execution. + * + * Prevents git and ssh subprocesses from opening /dev/tty or blocking on stdin + * during background extension operations. + */ +export function createNonInteractiveGit(baseDir: string): SimpleGit { + const safeEnv = getSafeGitEnv(); + + // Preserve existing GIT_SSH_COMMAND if configured by user, but enforce BatchMode=yes + const existingSsh = + safeEnv['GIT_SSH_COMMAND'] || process.env['GIT_SSH_COMMAND']; + const sshCommand = existingSsh + ? `${existingSsh} -o BatchMode=yes` + : 'ssh -o BatchMode=yes'; + + return simpleGit(baseDir).env({ + ...safeEnv, + // Native Git flag (>=2.3) disabling terminal prompts + GIT_TERMINAL_PROMPT: '0', + // Prevents SSH from prompting for passphrases or host key confirmation + GIT_SSH_COMMAND: sshCommand, + }); +} + /** * Clones a Git repository to a specified local path. * @param installMetadata The metadata for the extension to install. @@ -34,7 +61,7 @@ export async function cloneFromGit( destination: string, ): Promise { try { - const git = simpleGit(destination).env(getSafeGitEnv()); + const git = createNonInteractiveGit(destination); let sourceUrl = installMetadata.source; const token = getGitHubToken(); if (token) { @@ -224,7 +251,7 @@ export async function checkForExtensionUpdate( try { if (installMetadata.type === 'git') { - const git = simpleGit(extension.path).env(getSafeGitEnv()); + const git = createNonInteractiveGit(extension.path); const remotes = await git.getRemotes(true); if (remotes.length === 0) { debugLogger.error('No git remotes found.'); @@ -241,7 +268,27 @@ export async function checkForExtensionUpdate( // Determine the ref to check on the remote. const refToCheck = installMetadata.ref || 'HEAD'; - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => + reject( + new Error( + `git ls-remote timed out after ${GIT_BACKGROUND_TIMEOUT_MS}ms`, + ), + ), + GIT_BACKGROUND_TIMEOUT_MS, + ); + }); + + const lsRemoteOutput = await Promise.race([ + git.listRemote([remoteUrl, refToCheck]), + timeoutPromise, + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { debugLogger.error(`Git ref ${refToCheck} not found.`);