fix(cli): use newline in shell command wrapping to avoid breaking heredocs (#25537)

This commit is contained in:
Coco Sheng
2026-04-21 15:12:50 -04:00
committed by GitHub
parent cdc5cccc13
commit 93a8d9001c
4 changed files with 138 additions and 73 deletions
@@ -16,7 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
import { NoopSandboxManager, escapeShellArg } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -76,7 +76,21 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mockFs = {
...actual,
existsSync: vi.fn(),
mkdtempSync: vi.fn(),
unlinkSync: vi.fn(),
readFileSync: vi.fn(),
rmSync: vi.fn(),
};
return {
...mockFs,
default: mockFs,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -154,6 +168,7 @@ describe('useExecutionLifecycle', () => {
);
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-shell-abcdef');
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
@@ -239,8 +254,9 @@ describe('useExecutionLifecycle', () => {
}),
],
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nls -l\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -349,11 +365,9 @@ describe('useExecutionLifecycle', () => {
);
});
// Verify it's using the non-pty shell
const wrappedCommand = `{ stream; }; __code=$?; pwd > "${path.join(
os.tmpdir(),
'shell_pwd_abcdef.tmp',
)}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nstream\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -644,7 +658,7 @@ describe('useExecutionLifecycle', () => {
type: 'error',
text: 'An unexpected error occurred: Synchronous spawn error',
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
@@ -652,7 +666,7 @@ describe('useExecutionLifecycle', () => {
describe('Directory Change Warning', () => {
it('should show a warning if the working directory changes', async () => {
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory
@@ -20,12 +20,12 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
escapeShellArg,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { formatBytes } from '../utils/formatters.js';
import crypto from 'node:crypto';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -362,18 +362,6 @@ export const useExecutionLifecycle = (
let commandToExecute = rawQuery;
let pwdFilePath: string | undefined;
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
// Ensure command ends with a separator before adding our own.
if (!command.endsWith(';') && !command.endsWith('&')) {
command += ';';
}
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
@@ -403,9 +391,23 @@ export const useExecutionLifecycle = (
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
try {
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
if (command.endsWith('\\')) {
command += ' ';
}
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-shell-'),
);
pwdFilePath = path.join(tmpDir, 'pwd.tmp');
const escapedPwdFilePath = escapeShellArg(pwdFilePath, 'bash');
commandToExecute = `{\n${command}\n}\n__code=$?; pwd > ${escapedPwdFilePath}; exit $__code`;
}
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
const activeTheme = themeManager.getActiveTheme();
const shellExecutionConfig = {
...config.getShellExecutionConfig(),
@@ -630,8 +632,18 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
const tmpDir = path.dirname(pwdFilePath);
try {
if (fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });