diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 4161fb8999..8399bb6d0c 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -1754,4 +1754,44 @@ describe('ShellExecutionService environment variables', () => { vi.unstubAllEnvs(); }); + + it('should NOT include headless git and gh environment variables in interactive fallback mode', async () => { + vi.resetModules(); + vi.stubEnv('GIT_TERMINAL_PROMPT', undefined); + vi.stubEnv('GIT_ASKPASS', undefined); + vi.stubEnv('SSH_ASKPASS', undefined); + vi.stubEnv('GH_PROMPT_DISABLED', undefined); + vi.stubEnv('GCM_INTERACTIVE', undefined); + vi.stubEnv('GIT_CONFIG_COUNT', undefined); + + const { ShellExecutionService } = await import( + './shellExecutionService.js' + ); + + mockGetPty.mockResolvedValue(null); // Force child_process fallback + await ShellExecutionService.execute( + 'test-cp-interactive-fallback', + '/', + vi.fn(), + new AbortController().signal, + true, // isInteractive (shouldUseNodePty) + shellExecutionConfig, + ); + + expect(mockCpSpawn).toHaveBeenCalled(); + const cpEnv = mockCpSpawn.mock.calls[0][2].env; + expect(cpEnv).not.toHaveProperty('GIT_TERMINAL_PROMPT'); + expect(cpEnv).not.toHaveProperty('GIT_ASKPASS'); + expect(cpEnv).not.toHaveProperty('SSH_ASKPASS'); + expect(cpEnv).not.toHaveProperty('GH_PROMPT_DISABLED'); + expect(cpEnv).not.toHaveProperty('GCM_INTERACTIVE'); + expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT'); + + // Ensure child_process exits + mockChildProcess.emit('exit', 0, null); + mockChildProcess.emit('close', 0, null); + await new Promise(process.nextTick); + + vi.unstubAllEnvs(); + }); }); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 3c64356944..f3358af992 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -252,6 +252,7 @@ export class ShellExecutionService { onOutputEvent, abortSignal, shellExecutionConfig.sanitizationConfig, + shouldUseNodePty, ); } @@ -298,6 +299,7 @@ export class ShellExecutionService { onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, sanitizationConfig: EnvironmentSanitizationConfig, + isInteractive: boolean, ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; @@ -305,23 +307,34 @@ export class ShellExecutionService { const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const spawnArgs = [...argsPrefix, guardedCommand]; - const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig); - const gitConfigCount = parseInt( - sanitizedEnv['GIT_CONFIG_COUNT'] || '0', - 10, - ); + // Specifically allow GIT_CONFIG_* variables to pass through sanitization + // in non-interactive mode so we can safely append our overrides. + const gitConfigKeys = !isInteractive + ? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_')) + : []; + const sanitizedEnv = sanitizeEnvironment(process.env, { + ...sanitizationConfig, + allowedEnvironmentVariables: [ + ...(sanitizationConfig.allowedEnvironmentVariables || []), + ...gitConfigKeys, + ], + }); - const child = cpSpawn(executable, spawnArgs, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - windowsVerbatimArguments: isWindows ? false : undefined, - shell: false, - detached: !isWindows, - env: { - ...sanitizedEnv, - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - TERM: 'xterm-256color', + const env: NodeJS.ProcessEnv = { + ...sanitizedEnv, + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + TERM: 'xterm-256color', + PAGER: 'cat', + GIT_PAGER: 'cat', + }; + + if (!isInteractive) { + const gitConfigCount = parseInt( + sanitizedEnv['GIT_CONFIG_COUNT'] || '0', + 10, + ); + Object.assign(env, { // Disable interactive prompts and session-linked credential helpers // in non-interactive mode to prevent hangs in detached process groups. GIT_TERMINAL_PROMPT: '0', @@ -334,9 +347,16 @@ export class ShellExecutionService { GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), [`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper', [`GIT_CONFIG_VALUE_${gitConfigCount}`]: '', - PAGER: 'cat', - GIT_PAGER: 'cat', - }, + }); + } + + const child = cpSpawn(executable, spawnArgs, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + windowsVerbatimArguments: isWindows ? false : undefined, + shell: false, + detached: !isWindows, + env, }); const state = {