fix(core): switch to subshells for shell tool wrapping to fix heredocs and edge cases (#24024)

This commit is contained in:
Abhi
2026-03-27 16:34:39 -04:00
committed by GitHub
parent 320c8aba4c
commit ba71ffa736
2 changed files with 71 additions and 11 deletions

View File

@@ -277,7 +277,7 @@ describe('ShellTool', () => {
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(
wrappedCommand,
tempRootDir,
@@ -295,6 +295,42 @@ describe('ShellTool', () => {
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 () => {
const subdir = path.join(tempRootDir, 'subdir');
const invocation = shellTool.build({
@@ -306,7 +342,7 @@ describe('ShellTool', () => {
await promise;
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(
wrappedCommand,
subdir,
@@ -331,7 +367,7 @@ describe('ShellTool', () => {
await promise;
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(
wrappedCommand,
path.join(tempRootDir, 'subdir'),

View File

@@ -76,6 +76,33 @@ export class ShellToolInvocation extends BaseToolInvocation<
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 {
let details = '';
// append optional [in directory]
@@ -232,14 +259,11 @@ export class ShellToolInvocation extends BaseToolInvocation<
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? strippedCommand
: (() => {
// wrap command to append subprocess pids (via pgrep) to temporary file
let command = strippedCommand.trim();
if (!command.endsWith('&')) command += ';';
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
})();
const commandToExecute = this.wrapCommandForPgrep(
strippedCommand,
tempFilePath,
isWindows,
);
const cwd = this.params.dir_path
? path.resolve(this.context.config.getTargetDir(), this.params.dir_path)