2025-04-24 18:03:33 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from 'fs';
|
2025-04-27 18:57:10 -07:00
|
|
|
import path from 'path';
|
2025-05-06 10:44:40 -07:00
|
|
|
import os from 'os';
|
|
|
|
|
import crypto from 'crypto';
|
2025-04-24 18:03:33 -07:00
|
|
|
import { Config } from '../config/config.js';
|
2025-04-25 14:05:58 -07:00
|
|
|
import {
|
|
|
|
|
BaseTool,
|
|
|
|
|
ToolResult,
|
|
|
|
|
ToolCallConfirmationDetails,
|
|
|
|
|
ToolExecuteConfirmationDetails,
|
|
|
|
|
ToolConfirmationOutcome,
|
|
|
|
|
} from './tools.js';
|
2025-04-27 18:57:10 -07:00
|
|
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
fix(shell): Improve error reporting for shell command failures
This commit enhances the tool to provide more informative feedback to the user when a shell command fails, especially in non-debug mode.
Previously, if a command terminated due to a signal (e.g., SIGPIPE during a with no upstream) or failed without producing stdout/stderr, the user would see no output, making it difficult to diagnose the issue.
Changes:
- Modified to update the logic.
- If a command produces no direct output but results in an error, signal, non-zero exit code, or user cancellation, a concise message indicating this outcome is now shown in .
- Utilized the existing utility from for consistent error message formatting, which also resolved previous TypeScript type inference issues.
This ensures users receive clearer feedback on command execution status, improving the tool's usability and aiding in troubleshooting.
Fixes https://b.corp.google.com/issues/417998119
2025-05-18 00:23:57 -07:00
|
|
|
import { getErrorMessage } from '../utils/errors.js';
|
2025-04-24 18:03:33 -07:00
|
|
|
export interface ShellToolParams {
|
|
|
|
|
command: string;
|
|
|
|
|
description?: string;
|
2025-04-27 18:57:10 -07:00
|
|
|
directory?: string;
|
2025-04-24 18:03:33 -07:00
|
|
|
}
|
2025-04-27 18:57:10 -07:00
|
|
|
import { spawn } from 'child_process';
|
2025-04-24 18:03:33 -07:00
|
|
|
|
|
|
|
|
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
|
|
|
|
static Name: string = 'execute_bash_command';
|
2025-04-25 14:05:58 -07:00
|
|
|
private whitelist: Set<string> = new Set();
|
2025-04-24 18:03:33 -07:00
|
|
|
|
2025-05-02 09:31:18 -07:00
|
|
|
constructor(private readonly config: Config) {
|
2025-04-24 18:03:33 -07:00
|
|
|
const toolDisplayName = 'Shell';
|
|
|
|
|
const descriptionUrl = new URL('shell.md', import.meta.url);
|
|
|
|
|
const toolDescription = fs.readFileSync(descriptionUrl, 'utf-8');
|
2025-05-08 14:14:09 -07:00
|
|
|
const schemaUrl = new URL('shell.json', import.meta.url);
|
|
|
|
|
const toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8'));
|
2025-04-24 18:03:33 -07:00
|
|
|
super(
|
|
|
|
|
ShellTool.Name,
|
|
|
|
|
toolDisplayName,
|
|
|
|
|
toolDescription,
|
|
|
|
|
toolParameterSchema,
|
|
|
|
|
);
|
2025-04-25 14:05:58 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDescription(params: ShellToolParams): string {
|
2025-04-27 18:57:10 -07:00
|
|
|
let description = `${params.command}`;
|
2025-04-30 12:27:56 -07:00
|
|
|
// append optional [in directory]
|
2025-04-29 15:31:46 -07:00
|
|
|
// note description is needed even if validation fails due to absolute path
|
2025-04-28 08:17:52 -07:00
|
|
|
if (params.directory) {
|
2025-04-30 12:27:56 -07:00
|
|
|
description += ` [in ${params.directory}]`;
|
2025-04-28 08:17:52 -07:00
|
|
|
}
|
|
|
|
|
// append optional (description), replacing any line breaks with spaces
|
2025-04-27 18:57:10 -07:00
|
|
|
if (params.description) {
|
|
|
|
|
description += ` (${params.description.replace(/\n/g, ' ')})`;
|
|
|
|
|
}
|
|
|
|
|
return description;
|
2025-04-25 14:05:58 -07:00
|
|
|
}
|
|
|
|
|
|
2025-04-27 18:57:10 -07:00
|
|
|
getCommandRoot(command: string): string | undefined {
|
|
|
|
|
return command
|
|
|
|
|
.trim() // remove leading and trailing whitespace
|
|
|
|
|
.replace(/[{}()]/g, '') // remove all grouping operators
|
|
|
|
|
.split(/[\s;&|]+/)[0] // split on any whitespace or separator or chaining operators and take first part
|
|
|
|
|
?.split(/[/\\]/) // split on any path separators (or return undefined if previous line was undefined)
|
|
|
|
|
.pop(); // take last part and return command root (or undefined if previous line was empty)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateToolParams(params: ShellToolParams): string | null {
|
|
|
|
|
if (
|
|
|
|
|
!SchemaValidator.validate(
|
|
|
|
|
this.parameterSchema as Record<string, unknown>,
|
|
|
|
|
params,
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return `Parameters failed schema validation.`;
|
|
|
|
|
}
|
|
|
|
|
if (!params.command.trim()) {
|
|
|
|
|
return 'Command cannot be empty.';
|
|
|
|
|
}
|
|
|
|
|
if (!this.getCommandRoot(params.command)) {
|
|
|
|
|
return 'Could not identify command root to obtain permission from user.';
|
|
|
|
|
}
|
|
|
|
|
if (params.directory) {
|
|
|
|
|
if (path.isAbsolute(params.directory)) {
|
|
|
|
|
return 'Directory cannot be absolute. Must be relative to the project root directory.';
|
|
|
|
|
}
|
|
|
|
|
const directory = path.resolve(
|
|
|
|
|
this.config.getTargetDir(),
|
|
|
|
|
params.directory,
|
|
|
|
|
);
|
|
|
|
|
if (!fs.existsSync(directory)) {
|
|
|
|
|
return 'Directory must exist.';
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-04-25 14:05:58 -07:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async shouldConfirmExecute(
|
|
|
|
|
params: ShellToolParams,
|
|
|
|
|
): Promise<ToolCallConfirmationDetails | false> {
|
2025-04-27 18:57:10 -07:00
|
|
|
if (this.validateToolParams(params)) {
|
|
|
|
|
return false; // skip confirmation, execute call will fail immediately
|
|
|
|
|
}
|
|
|
|
|
const rootCommand = this.getCommandRoot(params.command)!; // must be non-empty string post-validation
|
2025-04-25 14:05:58 -07:00
|
|
|
if (this.whitelist.has(rootCommand)) {
|
2025-04-27 18:57:10 -07:00
|
|
|
return false; // already approved and whitelisted
|
2025-04-25 14:05:58 -07:00
|
|
|
}
|
|
|
|
|
const confirmationDetails: ToolExecuteConfirmationDetails = {
|
2025-05-22 06:00:36 +00:00
|
|
|
type: 'exec',
|
2025-04-25 14:05:58 -07:00
|
|
|
title: 'Confirm Shell Command',
|
|
|
|
|
command: params.command,
|
|
|
|
|
rootCommand,
|
|
|
|
|
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
|
|
|
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
|
|
|
|
this.whitelist.add(rootCommand);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return confirmationDetails;
|
2025-04-24 18:03:33 -07:00
|
|
|
}
|
|
|
|
|
|
2025-05-09 23:29:02 -07:00
|
|
|
async execute(
|
|
|
|
|
params: ShellToolParams,
|
|
|
|
|
abortSignal: AbortSignal,
|
|
|
|
|
): Promise<ToolResult> {
|
2025-04-27 18:57:10 -07:00
|
|
|
const validationError = this.validateToolParams(params);
|
|
|
|
|
if (validationError) {
|
|
|
|
|
return {
|
|
|
|
|
llmContent: [
|
|
|
|
|
`Command rejected: ${params.command}`,
|
|
|
|
|
`Reason: ${validationError}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
returnDisplay: `Error: ${validationError}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-06 10:44:40 -07:00
|
|
|
// wrap command to append subprocess pids (via pgrep) to temporary file
|
|
|
|
|
const tempFileName = `shell_pgrep_${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
|
|
|
const tempFilePath = path.join(os.tmpdir(), tempFileName);
|
|
|
|
|
|
2025-04-27 18:57:10 -07:00
|
|
|
let command = params.command.trim();
|
|
|
|
|
if (!command.endsWith('&')) command += ';';
|
2025-05-06 10:44:40 -07:00
|
|
|
// note the final echo is only to trigger the stderr handler below
|
2025-05-21 08:51:22 -07:00
|
|
|
command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; ( trap '' PIPE ; echo >&2 ); exit $__code;`;
|
2025-04-27 18:57:10 -07:00
|
|
|
|
|
|
|
|
// spawn command in specified directory (or project root if not specified)
|
|
|
|
|
const shell = spawn('bash', ['-c', command], {
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
detached: true, // ensure subprocess starts its own process group (esp. in Linux)
|
|
|
|
|
cwd: path.resolve(this.config.getTargetDir(), params.directory || ''),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stdout = '';
|
|
|
|
|
let output = '';
|
|
|
|
|
shell.stdout.on('data', (data: Buffer) => {
|
2025-05-06 10:44:40 -07:00
|
|
|
const str = data.toString();
|
|
|
|
|
stdout += str;
|
|
|
|
|
output += str;
|
2025-04-27 18:57:10 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stderr = '';
|
|
|
|
|
shell.stderr.on('data', (data: Buffer) => {
|
2025-05-19 13:16:11 -07:00
|
|
|
let str = data.toString();
|
|
|
|
|
// if the temporary file exists, close the streams and finalize stdout/stderr
|
|
|
|
|
// otherwise these streams can delay termination ('close' event) until all background processes exit
|
2025-05-06 10:44:40 -07:00
|
|
|
if (fs.existsSync(tempFilePath)) {
|
2025-04-27 18:57:10 -07:00
|
|
|
shell.stdout.destroy();
|
|
|
|
|
shell.stderr.destroy();
|
2025-05-19 13:16:11 -07:00
|
|
|
// exclude final \n, which should be from echo >&2 unless there are background processes writing to stderr
|
|
|
|
|
if (str.endsWith('\n')) {
|
|
|
|
|
str = str.slice(0, -1);
|
|
|
|
|
}
|
2025-04-27 18:57:10 -07:00
|
|
|
}
|
2025-05-19 13:16:11 -07:00
|
|
|
stderr += str;
|
|
|
|
|
output += str;
|
2025-04-27 18:57:10 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let error: Error | null = null;
|
|
|
|
|
shell.on('error', (err: Error) => {
|
|
|
|
|
error = err;
|
2025-05-21 09:31:13 -07:00
|
|
|
// remove wrapper from user's command in error message
|
|
|
|
|
error.message = error.message.replace(command, params.command);
|
2025-04-27 18:57:10 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let code: number | null = null;
|
2025-05-09 23:29:02 -07:00
|
|
|
let processSignal: NodeJS.Signals | null = null;
|
|
|
|
|
const closeHandler = (
|
|
|
|
|
_code: number | null,
|
|
|
|
|
_signal: NodeJS.Signals | null,
|
|
|
|
|
) => {
|
|
|
|
|
code = _code;
|
|
|
|
|
processSignal = _signal;
|
|
|
|
|
};
|
|
|
|
|
shell.on('close', closeHandler);
|
|
|
|
|
|
|
|
|
|
const abortHandler = () => {
|
|
|
|
|
if (shell.pid) {
|
|
|
|
|
try {
|
|
|
|
|
// Kill the entire process group
|
|
|
|
|
process.kill(-shell.pid, 'SIGTERM');
|
|
|
|
|
} catch (_e) {
|
|
|
|
|
// Fallback to killing the main process if group kill fails
|
|
|
|
|
try {
|
|
|
|
|
shell.kill('SIGKILL'); // or 'SIGTERM'
|
|
|
|
|
} catch (_killError) {
|
|
|
|
|
// Ignore errors if the process is already dead
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
abortSignal.addEventListener('abort', abortHandler);
|
2025-04-27 18:57:10 -07:00
|
|
|
|
|
|
|
|
// wait for the shell to exit
|
|
|
|
|
await new Promise((resolve) => shell.on('close', resolve));
|
|
|
|
|
|
2025-05-09 23:29:02 -07:00
|
|
|
abortSignal.removeEventListener('abort', abortHandler);
|
|
|
|
|
|
2025-05-06 10:44:40 -07:00
|
|
|
// parse pids (pgrep output) from temporary file and remove it
|
|
|
|
|
const backgroundPIDs: number[] = [];
|
|
|
|
|
if (fs.existsSync(tempFilePath)) {
|
|
|
|
|
const pgrepLines = fs
|
|
|
|
|
.readFileSync(tempFilePath, 'utf8')
|
|
|
|
|
.split('\n')
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
for (const line of pgrepLines) {
|
|
|
|
|
if (!/^\d+$/.test(line)) {
|
|
|
|
|
console.error(`pgrep: ${line}`);
|
|
|
|
|
}
|
|
|
|
|
const pid = Number(line);
|
|
|
|
|
// exclude the shell subprocess pid
|
|
|
|
|
if (pid !== shell.pid) {
|
|
|
|
|
backgroundPIDs.push(pid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fs.unlinkSync(tempFilePath);
|
|
|
|
|
} else {
|
2025-05-09 23:29:02 -07:00
|
|
|
if (!abortSignal.aborted) {
|
|
|
|
|
console.error('missing pgrep output');
|
|
|
|
|
}
|
2025-05-06 10:44:40 -07:00
|
|
|
}
|
|
|
|
|
|
2025-05-09 23:29:02 -07:00
|
|
|
let llmContent = '';
|
|
|
|
|
if (abortSignal.aborted) {
|
|
|
|
|
llmContent = 'Command did not complete, it was cancelled by the user';
|
|
|
|
|
} else {
|
|
|
|
|
llmContent = [
|
|
|
|
|
`Command: ${params.command}`,
|
|
|
|
|
`Directory: ${params.directory || '(root)'}`,
|
|
|
|
|
`Stdout: ${stdout || '(empty)'}`,
|
|
|
|
|
`Stderr: ${stderr || '(empty)'}`,
|
|
|
|
|
`Error: ${error ?? '(none)'}`,
|
|
|
|
|
`Exit Code: ${code ?? '(none)'}`,
|
|
|
|
|
`Signal: ${processSignal ?? '(none)'}`,
|
|
|
|
|
`Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`,
|
|
|
|
|
].join('\n');
|
|
|
|
|
}
|
2025-04-28 15:05:36 -07:00
|
|
|
|
fix(shell): Improve error reporting for shell command failures
This commit enhances the tool to provide more informative feedback to the user when a shell command fails, especially in non-debug mode.
Previously, if a command terminated due to a signal (e.g., SIGPIPE during a with no upstream) or failed without producing stdout/stderr, the user would see no output, making it difficult to diagnose the issue.
Changes:
- Modified to update the logic.
- If a command produces no direct output but results in an error, signal, non-zero exit code, or user cancellation, a concise message indicating this outcome is now shown in .
- Utilized the existing utility from for consistent error message formatting, which also resolved previous TypeScript type inference issues.
This ensures users receive clearer feedback on command execution status, improving the tool's usability and aiding in troubleshooting.
Fixes https://b.corp.google.com/issues/417998119
2025-05-18 00:23:57 -07:00
|
|
|
let returnDisplayMessage = '';
|
|
|
|
|
if (this.config.getDebugMode()) {
|
|
|
|
|
returnDisplayMessage = llmContent;
|
|
|
|
|
} else {
|
|
|
|
|
if (output.trim()) {
|
|
|
|
|
returnDisplayMessage = output;
|
|
|
|
|
} else {
|
|
|
|
|
// Output is empty, let's provide a reason if the command failed or was cancelled
|
|
|
|
|
if (abortSignal.aborted) {
|
|
|
|
|
returnDisplayMessage = 'Command cancelled by user.';
|
|
|
|
|
} else if (processSignal) {
|
|
|
|
|
returnDisplayMessage = `Command terminated by signal: ${processSignal}`;
|
|
|
|
|
} else if (error) {
|
|
|
|
|
// If error is not null, it's an Error object (or other truthy value)
|
|
|
|
|
returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`;
|
|
|
|
|
} else if (code !== null && code !== 0) {
|
|
|
|
|
returnDisplayMessage = `Command exited with code: ${code}`;
|
|
|
|
|
}
|
|
|
|
|
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
|
|
|
|
// returnDisplayMessage will remain empty, which is fine.
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-04-28 15:05:36 -07:00
|
|
|
|
fix(shell): Improve error reporting for shell command failures
This commit enhances the tool to provide more informative feedback to the user when a shell command fails, especially in non-debug mode.
Previously, if a command terminated due to a signal (e.g., SIGPIPE during a with no upstream) or failed without producing stdout/stderr, the user would see no output, making it difficult to diagnose the issue.
Changes:
- Modified to update the logic.
- If a command produces no direct output but results in an error, signal, non-zero exit code, or user cancellation, a concise message indicating this outcome is now shown in .
- Utilized the existing utility from for consistent error message formatting, which also resolved previous TypeScript type inference issues.
This ensures users receive clearer feedback on command execution status, improving the tool's usability and aiding in troubleshooting.
Fixes https://b.corp.google.com/issues/417998119
2025-05-18 00:23:57 -07:00
|
|
|
return { llmContent, returnDisplay: returnDisplayMessage };
|
2025-04-24 18:03:33 -07:00
|
|
|
}
|
|
|
|
|
}
|