mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(core): switch to subshells for shell tool wrapping to fix heredocs and edge cases (#24024)
This commit is contained in:
@@ -277,7 +277,7 @@ describe('ShellTool', () => {
|
|||||||
|
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
|
|
||||||
const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
const wrappedCommand = `(\n${'my-command &'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
wrappedCommand,
|
wrappedCommand,
|
||||||
tempRootDir,
|
tempRootDir,
|
||||||
@@ -295,6 +295,42 @@ describe('ShellTool', () => {
|
|||||||
expect(fs.existsSync(tmpFile)).toBe(false);
|
expect(fs.existsSync(tmpFile)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add a space when command ends with a backslash to prevent escaping newline', async () => {
|
||||||
|
const invocation = shellTool.build({ command: 'ls\\' });
|
||||||
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
resolveShellExecution();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
|
const wrappedCommand = `(\nls\\ \n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
|
wrappedCommand,
|
||||||
|
tempRootDir,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
false,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle trailing comments correctly by placing them on their own line', async () => {
|
||||||
|
const invocation = shellTool.build({ command: 'ls # comment' });
|
||||||
|
const promise = invocation.execute(mockAbortSignal);
|
||||||
|
resolveShellExecution();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
|
const wrappedCommand = `(\nls # comment\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
|
wrappedCommand,
|
||||||
|
tempRootDir,
|
||||||
|
expect.any(Function),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
false,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use the provided absolute directory as cwd', async () => {
|
it('should use the provided absolute directory as cwd', async () => {
|
||||||
const subdir = path.join(tempRootDir, 'subdir');
|
const subdir = path.join(tempRootDir, 'subdir');
|
||||||
const invocation = shellTool.build({
|
const invocation = shellTool.build({
|
||||||
@@ -306,7 +342,7 @@ describe('ShellTool', () => {
|
|||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
wrappedCommand,
|
wrappedCommand,
|
||||||
subdir,
|
subdir,
|
||||||
@@ -331,7 +367,7 @@ describe('ShellTool', () => {
|
|||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
||||||
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
const wrappedCommand = `(\n${'ls'}\n); __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
||||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||||
wrappedCommand,
|
wrappedCommand,
|
||||||
path.join(tempRootDir, 'subdir'),
|
path.join(tempRootDir, 'subdir'),
|
||||||
|
|||||||
@@ -76,6 +76,33 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
super(params, messageBus, _toolName, _toolDisplayName);
|
super(params, messageBus, _toolName, _toolDisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a command in a subshell `()` to capture background process IDs (PIDs) using pgrep.
|
||||||
|
* Uses newlines to prevent breaking heredocs or trailing comments.
|
||||||
|
*
|
||||||
|
* @param command The raw command string to execute.
|
||||||
|
* @param tempFilePath Path to the temporary file where PIDs will be written.
|
||||||
|
* @param isWindows Whether the current platform is Windows (if true, the command is returned as-is).
|
||||||
|
* @returns The wrapped command string.
|
||||||
|
*/
|
||||||
|
private wrapCommandForPgrep(
|
||||||
|
command: string,
|
||||||
|
tempFilePath: string,
|
||||||
|
isWindows: boolean,
|
||||||
|
): string {
|
||||||
|
if (isWindows) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
let trimmed = command.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith('\\')) {
|
||||||
|
trimmed += ' ';
|
||||||
|
}
|
||||||
|
return `(\n${trimmed}\n); __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||||
|
}
|
||||||
|
|
||||||
private getContextualDetails(): string {
|
private getContextualDetails(): string {
|
||||||
let details = '';
|
let details = '';
|
||||||
// append optional [in directory]
|
// append optional [in directory]
|
||||||
@@ -232,14 +259,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// pgrep is not available on Windows, so we can't get background PIDs
|
// pgrep is not available on Windows, so we can't get background PIDs
|
||||||
const commandToExecute = isWindows
|
const commandToExecute = this.wrapCommandForPgrep(
|
||||||
? strippedCommand
|
strippedCommand,
|
||||||
: (() => {
|
tempFilePath,
|
||||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
isWindows,
|
||||||
let command = strippedCommand.trim();
|
);
|
||||||
if (!command.endsWith('&')) command += ';';
|
|
||||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const cwd = this.params.dir_path
|
const cwd = this.params.dir_path
|
||||||
? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)
|
? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user