|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import fs from 'fs'; |
|
|
import path from 'path'; |
|
|
import os from 'os'; |
|
|
import crypto from 'crypto'; |
|
|
import { Config } from '../config/config.js'; |
|
|
import { |
|
|
BaseTool, |
|
|
ToolResult, |
|
|
ToolCallConfirmationDetails, |
|
|
ToolExecuteConfirmationDetails, |
|
|
ToolConfirmationOutcome, |
|
|
} from './tools.js'; |
|
|
import { SchemaValidator } from '../utils/schemaValidator.js'; |
|
|
import { getErrorMessage } from '../utils/errors.js'; |
|
|
import stripAnsi from 'strip-ansi'; |
|
|
|
|
|
export interface ShellToolParams { |
|
|
command: string; |
|
|
description?: string; |
|
|
directory?: string; |
|
|
} |
|
|
import { spawn } from 'child_process'; |
|
|
|
|
|
const OUTPUT_UPDATE_INTERVAL_MS = 1000; |
|
|
|
|
|
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { |
|
|
static Name: string = 'run_shell_command'; |
|
|
private whitelist: Set<string> = new Set(); |
|
|
|
|
|
constructor(private readonly config: Config) { |
|
|
const toolDisplayName = 'Shell'; |
|
|
|
|
|
let toolDescription: string; |
|
|
let toolParameterSchema: Record<string, unknown>; |
|
|
|
|
|
try { |
|
|
const descriptionUrl = new URL('shell.md', import.meta.url); |
|
|
toolDescription = fs.readFileSync(descriptionUrl, 'utf-8'); |
|
|
const schemaUrl = new URL('shell.json', import.meta.url); |
|
|
toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8')); |
|
|
} catch { |
|
|
|
|
|
toolDescription = 'Execute shell commands'; |
|
|
toolParameterSchema = { |
|
|
type: 'object', |
|
|
properties: { |
|
|
command: { type: 'string', description: 'Command to execute' }, |
|
|
description: { type: 'string', description: 'Command description' }, |
|
|
directory: { type: 'string', description: 'Working directory' }, |
|
|
}, |
|
|
required: ['command'], |
|
|
}; |
|
|
} |
|
|
|
|
|
super( |
|
|
ShellTool.Name, |
|
|
toolDisplayName, |
|
|
toolDescription, |
|
|
toolParameterSchema, |
|
|
false, |
|
|
true, |
|
|
); |
|
|
} |
|
|
|
|
|
getDescription(params: ShellToolParams): string { |
|
|
let description = `${params.command}`; |
|
|
|
|
|
|
|
|
if (params.directory) { |
|
|
description += ` [in ${params.directory}]`; |
|
|
} |
|
|
|
|
|
if (params.description) { |
|
|
description += ` (${params.description.replace(/\n/g, ' ')})`; |
|
|
} |
|
|
return description; |
|
|
} |
|
|
|
|
|
getCommandRoot(command: string): string | undefined { |
|
|
return command |
|
|
.trim() |
|
|
.replace(/[{}()]/g, '') |
|
|
.split(/[\s;&|]+/)[0] |
|
|
?.split(/[/\\]/) |
|
|
.pop(); |
|
|
} |
|
|
|
|
|
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.'; |
|
|
} |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
async shouldConfirmExecute( |
|
|
params: ShellToolParams, |
|
|
_abortSignal: AbortSignal, |
|
|
): Promise<ToolCallConfirmationDetails | false> { |
|
|
if (this.validateToolParams(params)) { |
|
|
return false; |
|
|
} |
|
|
const rootCommand = this.getCommandRoot(params.command)!; |
|
|
if (this.whitelist.has(rootCommand)) { |
|
|
return false; |
|
|
} |
|
|
const confirmationDetails: ToolExecuteConfirmationDetails = { |
|
|
type: 'exec', |
|
|
title: 'Confirm Shell Command', |
|
|
command: params.command, |
|
|
rootCommand, |
|
|
onConfirm: async (outcome: ToolConfirmationOutcome) => { |
|
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) { |
|
|
this.whitelist.add(rootCommand); |
|
|
} |
|
|
}, |
|
|
}; |
|
|
return confirmationDetails; |
|
|
} |
|
|
|
|
|
async execute( |
|
|
params: ShellToolParams, |
|
|
abortSignal: AbortSignal, |
|
|
updateOutput?: (chunk: string) => void, |
|
|
): Promise<ToolResult> { |
|
|
const validationError = this.validateToolParams(params); |
|
|
if (validationError) { |
|
|
return { |
|
|
llmContent: [ |
|
|
`Command rejected: ${params.command}`, |
|
|
`Reason: ${validationError}`, |
|
|
].join('\n'), |
|
|
returnDisplay: `Error: ${validationError}`, |
|
|
}; |
|
|
} |
|
|
|
|
|
if (abortSignal.aborted) { |
|
|
return { |
|
|
llmContent: 'Command was cancelled by user before it could start.', |
|
|
returnDisplay: 'Command cancelled by user.', |
|
|
}; |
|
|
} |
|
|
|
|
|
const isWindows = os.platform() === 'win32'; |
|
|
const tempFileName = `shell_pgrep_${crypto |
|
|
.randomBytes(6) |
|
|
.toString('hex')}.tmp`; |
|
|
const tempFilePath = path.join(os.tmpdir(), tempFileName); |
|
|
|
|
|
|
|
|
const command = isWindows |
|
|
? params.command |
|
|
: (() => { |
|
|
|
|
|
let command = params.command.trim(); |
|
|
if (!command.endsWith('&')) command += ';'; |
|
|
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; |
|
|
})(); |
|
|
|
|
|
|
|
|
const shell = isWindows |
|
|
? spawn('cmd.exe', ['/c', command], { |
|
|
stdio: ['ignore', 'pipe', 'pipe'], |
|
|
|
|
|
cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), |
|
|
}) |
|
|
: spawn('bash', ['-c', command], { |
|
|
stdio: ['ignore', 'pipe', 'pipe'], |
|
|
detached: true, |
|
|
cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), |
|
|
}); |
|
|
|
|
|
let exited = false; |
|
|
let stdout = ''; |
|
|
let output = ''; |
|
|
let lastUpdateTime = Date.now(); |
|
|
|
|
|
const appendOutput = (str: string) => { |
|
|
output += str; |
|
|
if ( |
|
|
updateOutput && |
|
|
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS |
|
|
) { |
|
|
updateOutput(output); |
|
|
lastUpdateTime = Date.now(); |
|
|
} |
|
|
}; |
|
|
|
|
|
shell.stdout.on('data', (data: Buffer) => { |
|
|
|
|
|
|
|
|
|
|
|
if (!exited) { |
|
|
const str = stripAnsi(data.toString()); |
|
|
stdout += str; |
|
|
appendOutput(str); |
|
|
} |
|
|
}); |
|
|
|
|
|
let stderr = ''; |
|
|
shell.stderr.on('data', (data: Buffer) => { |
|
|
if (!exited) { |
|
|
const str = stripAnsi(data.toString()); |
|
|
stderr += str; |
|
|
appendOutput(str); |
|
|
} |
|
|
}); |
|
|
|
|
|
let error: Error | null = null; |
|
|
shell.on('error', (err: Error) => { |
|
|
error = err; |
|
|
|
|
|
error.message = error.message.replace(command, params.command); |
|
|
}); |
|
|
|
|
|
let code: number | null = null; |
|
|
let processSignal: NodeJS.Signals | null = null; |
|
|
const exitHandler = ( |
|
|
_code: number | null, |
|
|
_signal: NodeJS.Signals | null, |
|
|
) => { |
|
|
exited = true; |
|
|
code = _code; |
|
|
processSignal = _signal; |
|
|
}; |
|
|
shell.on('exit', exitHandler); |
|
|
|
|
|
const abortHandler = async () => { |
|
|
if (shell.pid && !exited) { |
|
|
if (os.platform() === 'win32') { |
|
|
|
|
|
spawn('taskkill', ['/pid', shell.pid.toString(), '/f', '/t']); |
|
|
} else { |
|
|
try { |
|
|
|
|
|
|
|
|
process.kill(-shell.pid, 'SIGTERM'); |
|
|
await new Promise((resolve) => setTimeout(resolve, 200)); |
|
|
if (shell.pid && !exited) { |
|
|
process.kill(-shell.pid, 'SIGKILL'); |
|
|
} |
|
|
} catch (_e) { |
|
|
|
|
|
try { |
|
|
if (shell.pid) { |
|
|
shell.kill('SIGKILL'); |
|
|
} |
|
|
} catch (_e) { |
|
|
console.error(`failed to kill shell process ${shell.pid}: ${_e}`); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}; |
|
|
abortSignal.addEventListener('abort', abortHandler); |
|
|
|
|
|
|
|
|
try { |
|
|
await new Promise((resolve) => shell.on('exit', resolve)); |
|
|
} finally { |
|
|
abortSignal.removeEventListener('abort', abortHandler); |
|
|
} |
|
|
|
|
|
|
|
|
const backgroundPIDs: number[] = []; |
|
|
if (os.platform() !== 'win32') { |
|
|
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); |
|
|
|
|
|
if (pid !== shell.pid) { |
|
|
backgroundPIDs.push(pid); |
|
|
} |
|
|
} |
|
|
fs.unlinkSync(tempFilePath); |
|
|
} else { |
|
|
if (!abortSignal.aborted) { |
|
|
console.error('missing pgrep output'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
let llmContent = ''; |
|
|
if (abortSignal.aborted) { |
|
|
llmContent = 'Command was cancelled by user before it could complete.'; |
|
|
if (output.trim()) { |
|
|
llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`; |
|
|
} else { |
|
|
llmContent += ' There was no output before it was cancelled.'; |
|
|
} |
|
|
} 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)'}`, |
|
|
`Process Group PGID: ${shell.pid ?? '(none)'}`, |
|
|
].join('\n'); |
|
|
} |
|
|
|
|
|
let returnDisplayMessage = ''; |
|
|
if (this.config.getDebugMode()) { |
|
|
returnDisplayMessage = llmContent; |
|
|
} else { |
|
|
if (output.trim()) { |
|
|
returnDisplayMessage = output; |
|
|
} else { |
|
|
|
|
|
if (abortSignal.aborted) { |
|
|
returnDisplayMessage = 'Command cancelled by user.'; |
|
|
} else if (processSignal) { |
|
|
returnDisplayMessage = `Command terminated by signal: ${processSignal}`; |
|
|
} else if (error) { |
|
|
|
|
|
returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`; |
|
|
} else if (code !== null && code !== 0) { |
|
|
returnDisplayMessage = `Command exited with code: ${code}`; |
|
|
} |
|
|
|
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
return { llmContent, returnDisplay: returnDisplayMessage }; |
|
|
} |
|
|
} |
|
|
|