diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d1dfc415b7..78b54fe297 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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'), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 0b4760ccc7..3a70de3ea4 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -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)