mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os, { EOL } from 'node:os';
|
|
import crypto from 'node:crypto';
|
|
import type { Config } from '../config/config.js';
|
|
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
|
import { ToolErrorType } from './tool-error.js';
|
|
import type {
|
|
ToolInvocation,
|
|
ToolResult,
|
|
ToolResultDisplay,
|
|
ToolCallConfirmationDetails,
|
|
ToolExecuteConfirmationDetails,
|
|
ToolConfirmationPayload,
|
|
} from './tools.js';
|
|
import {
|
|
BaseDeclarativeTool,
|
|
BaseToolInvocation,
|
|
ToolConfirmationOutcome,
|
|
Kind,
|
|
} from './tools.js';
|
|
import { getErrorMessage } from '../utils/errors.js';
|
|
import { summarizeToolOutput } from '../utils/summarizer.js';
|
|
import type {
|
|
ShellExecutionConfig,
|
|
ShellOutputEvent,
|
|
} from '../services/shellExecutionService.js';
|
|
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
|
import { formatMemoryUsage } from '../utils/formatters.js';
|
|
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
|
import {
|
|
getCommandRoots,
|
|
isCommandAllowed,
|
|
isCommandNeedsPermission,
|
|
stripShellWrapper,
|
|
} from '../utils/shell-utils.js';
|
|
|
|
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
|
|
|
export interface ShellToolParams {
|
|
command: string;
|
|
is_background: boolean;
|
|
description?: string;
|
|
directory?: string;
|
|
}
|
|
|
|
export class ShellToolInvocation extends BaseToolInvocation<
|
|
ShellToolParams,
|
|
ToolResult
|
|
> {
|
|
constructor(
|
|
private readonly config: Config,
|
|
params: ShellToolParams,
|
|
private readonly allowlist: Set<string>,
|
|
) {
|
|
super(params);
|
|
}
|
|
|
|
getDescription(): string {
|
|
let description = `${this.params.command}`;
|
|
// append optional [in directory]
|
|
// note description is needed even if validation fails due to absolute path
|
|
if (this.params.directory) {
|
|
description += ` [in ${this.params.directory}]`;
|
|
}
|
|
// append background indicator
|
|
if (this.params.is_background) {
|
|
description += ` [background]`;
|
|
}
|
|
// append optional (description), replacing any line breaks with spaces
|
|
if (this.params.description) {
|
|
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
|
}
|
|
return description;
|
|
}
|
|
|
|
override async shouldConfirmExecute(
|
|
_abortSignal: AbortSignal,
|
|
): Promise<ToolCallConfirmationDetails | false> {
|
|
const command = stripShellWrapper(this.params.command);
|
|
const rootCommands = [...new Set(getCommandRoots(command))];
|
|
const commandsToConfirm = rootCommands.filter(
|
|
(command) => !this.allowlist.has(command),
|
|
);
|
|
|
|
if (commandsToConfirm.length === 0) {
|
|
return false; // already approved and allowlisted
|
|
}
|
|
|
|
const permissionCheck = isCommandNeedsPermission(command);
|
|
if (!permissionCheck.requiresPermission) {
|
|
return false;
|
|
}
|
|
|
|
const confirmationDetails: ToolExecuteConfirmationDetails = {
|
|
type: 'exec',
|
|
title: 'Confirm Shell Command',
|
|
command: this.params.command,
|
|
rootCommand: commandsToConfirm.join(', '),
|
|
onConfirm: async (
|
|
outcome: ToolConfirmationOutcome,
|
|
_payload?: ToolConfirmationPayload,
|
|
) => {
|
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
|
commandsToConfirm.forEach((command) => this.allowlist.add(command));
|
|
}
|
|
},
|
|
};
|
|
return confirmationDetails;
|
|
}
|
|
|
|
async execute(
|
|
signal: AbortSignal,
|
|
updateOutput?: (output: ToolResultDisplay) => void,
|
|
shellExecutionConfig?: ShellExecutionConfig,
|
|
setPidCallback?: (pid: number) => void,
|
|
): Promise<ToolResult> {
|
|
const strippedCommand = stripShellWrapper(this.params.command);
|
|
|
|
if (signal.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);
|
|
|
|
try {
|
|
// Add co-author to git commit commands
|
|
const processedCommand = this.addCoAuthorToGitCommit(strippedCommand);
|
|
|
|
const shouldRunInBackground = this.params.is_background;
|
|
let finalCommand = processedCommand;
|
|
|
|
// If explicitly marked as background and doesn't already end with &, add it
|
|
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
|
|
finalCommand = finalCommand.trim() + ' &';
|
|
}
|
|
|
|
// pgrep is not available on Windows, so we can't get background PIDs
|
|
const commandToExecute = isWindows
|
|
? finalCommand
|
|
: (() => {
|
|
// wrap command to append subprocess pids (via pgrep) to temporary file
|
|
let command = finalCommand.trim();
|
|
if (!command.endsWith('&')) command += ';';
|
|
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
|
})();
|
|
|
|
const cwd = this.params.directory || this.config.getTargetDir();
|
|
|
|
let cumulativeOutput: string | AnsiOutput = '';
|
|
let lastUpdateTime = Date.now();
|
|
let isBinaryStream = false;
|
|
|
|
const { result: resultPromise, pid } =
|
|
await ShellExecutionService.execute(
|
|
commandToExecute,
|
|
cwd,
|
|
(event: ShellOutputEvent) => {
|
|
if (!updateOutput) {
|
|
return;
|
|
}
|
|
|
|
let shouldUpdate = false;
|
|
|
|
switch (event.type) {
|
|
case 'data':
|
|
if (isBinaryStream) break;
|
|
cumulativeOutput = event.chunk;
|
|
shouldUpdate = true;
|
|
break;
|
|
case 'binary_detected':
|
|
isBinaryStream = true;
|
|
cumulativeOutput =
|
|
'[Binary output detected. Halting stream...]';
|
|
shouldUpdate = true;
|
|
break;
|
|
case 'binary_progress':
|
|
isBinaryStream = true;
|
|
cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(
|
|
event.bytesReceived,
|
|
)} received]`;
|
|
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
|
shouldUpdate = true;
|
|
}
|
|
break;
|
|
default: {
|
|
throw new Error('An unhandled ShellOutputEvent was found.');
|
|
}
|
|
}
|
|
|
|
if (shouldUpdate) {
|
|
updateOutput(
|
|
typeof cumulativeOutput === 'string'
|
|
? cumulativeOutput
|
|
: { ansiOutput: cumulativeOutput },
|
|
);
|
|
lastUpdateTime = Date.now();
|
|
}
|
|
},
|
|
signal,
|
|
this.config.getShouldUseNodePtyShell(),
|
|
shellExecutionConfig ?? {},
|
|
);
|
|
|
|
if (pid && setPidCallback) {
|
|
setPidCallback(pid);
|
|
}
|
|
|
|
const result = await resultPromise;
|
|
|
|
const backgroundPIDs: number[] = [];
|
|
if (os.platform() !== 'win32') {
|
|
if (fs.existsSync(tempFilePath)) {
|
|
const pgrepLines = fs
|
|
.readFileSync(tempFilePath, 'utf8')
|
|
.split(EOL)
|
|
.filter(Boolean);
|
|
for (const line of pgrepLines) {
|
|
if (!/^\d+$/.test(line)) {
|
|
console.error(`pgrep: ${line}`);
|
|
}
|
|
const pid = Number(line);
|
|
if (pid !== result.pid) {
|
|
backgroundPIDs.push(pid);
|
|
}
|
|
}
|
|
} else {
|
|
if (!signal.aborted) {
|
|
console.error('missing pgrep output');
|
|
}
|
|
}
|
|
}
|
|
|
|
let llmContent = '';
|
|
if (result.aborted) {
|
|
llmContent = 'Command was cancelled by user before it could complete.';
|
|
if (result.output.trim()) {
|
|
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
|
} else {
|
|
llmContent += ' There was no output before it was cancelled.';
|
|
}
|
|
} else {
|
|
// Create a formatted error string for display, replacing the wrapper command
|
|
// with the user-facing command.
|
|
const finalError = result.error
|
|
? result.error.message.replace(commandToExecute, this.params.command)
|
|
: '(none)';
|
|
|
|
llmContent = [
|
|
`Command: ${this.params.command}`,
|
|
`Directory: ${this.params.directory || '(root)'}`,
|
|
`Output: ${result.output || '(empty)'}`,
|
|
`Error: ${finalError}`, // Use the cleaned error string.
|
|
`Exit Code: ${result.exitCode ?? '(none)'}`,
|
|
`Signal: ${result.signal ?? '(none)'}`,
|
|
`Background PIDs: ${
|
|
backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'
|
|
}`,
|
|
`Process Group PGID: ${result.pid ?? '(none)'}`,
|
|
].join('\n');
|
|
}
|
|
|
|
let returnDisplayMessage = '';
|
|
if (this.config.getDebugMode()) {
|
|
returnDisplayMessage = llmContent;
|
|
} else {
|
|
if (result.output.trim()) {
|
|
returnDisplayMessage = result.output;
|
|
} else {
|
|
if (result.aborted) {
|
|
returnDisplayMessage = 'Command cancelled by user.';
|
|
} else if (result.signal) {
|
|
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
|
} else if (result.error) {
|
|
returnDisplayMessage = `Command failed: ${getErrorMessage(
|
|
result.error,
|
|
)}`;
|
|
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
|
returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
|
|
}
|
|
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
|
// returnDisplayMessage will remain empty, which is fine.
|
|
}
|
|
}
|
|
|
|
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
|
|
const executionError = result.error
|
|
? {
|
|
error: {
|
|
message: result.error.message,
|
|
type: ToolErrorType.SHELL_EXECUTE_ERROR,
|
|
},
|
|
}
|
|
: {};
|
|
if (summarizeConfig && summarizeConfig[ShellTool.Name]) {
|
|
const summary = await summarizeToolOutput(
|
|
llmContent,
|
|
this.config.getGeminiClient(),
|
|
signal,
|
|
summarizeConfig[ShellTool.Name].tokenBudget,
|
|
);
|
|
return {
|
|
llmContent: summary,
|
|
returnDisplay: returnDisplayMessage,
|
|
...executionError,
|
|
};
|
|
}
|
|
|
|
return {
|
|
llmContent,
|
|
returnDisplay: returnDisplayMessage,
|
|
...executionError,
|
|
};
|
|
} finally {
|
|
if (fs.existsSync(tempFilePath)) {
|
|
fs.unlinkSync(tempFilePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private addCoAuthorToGitCommit(command: string): string {
|
|
// Check if co-author feature is enabled
|
|
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
|
|
|
if (!gitCoAuthorSettings.enabled) {
|
|
return command;
|
|
}
|
|
|
|
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
|
|
const gitCommitPattern = /\bgit\s+commit\b/;
|
|
if (!gitCommitPattern.test(command)) {
|
|
return command;
|
|
}
|
|
|
|
// Define the co-author line using configuration
|
|
const coAuthor = `
|
|
|
|
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
|
|
|
// Handle different git commit patterns:
|
|
// Match -m "message" or -m 'message', including combined flags like -am
|
|
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
|
|
//
|
|
// Pattern breakdown:
|
|
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
|
|
// \s+ matches whitespace after the flag
|
|
// [^"\\] matches any char except double-quote and backslash
|
|
// \\. matches escape sequences like \" or \\
|
|
// (?:...|...)* matches normal chars or escapes, repeated
|
|
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
|
|
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
|
|
const doubleMatch = command.match(doubleQuotePattern);
|
|
const singleMatch = command.match(singleQuotePattern);
|
|
const match = doubleMatch ?? singleMatch;
|
|
const quote = doubleMatch ? '"' : "'";
|
|
|
|
if (match) {
|
|
const [fullMatch, prefix, existingMessage] = match;
|
|
const newMessage = existingMessage + coAuthor;
|
|
const replacement = prefix + quote + newMessage + quote;
|
|
|
|
return command.replace(fullMatch, replacement);
|
|
}
|
|
|
|
// If no -m flag found, the command might open an editor
|
|
// In this case, we can't easily modify it, so return as-is
|
|
return command;
|
|
}
|
|
}
|
|
|
|
function getShellToolDescription(): string {
|
|
const toolDescription = `
|
|
|
|
**Background vs Foreground Execution:**
|
|
You should decide whether commands should run in background or foreground based on their nature:
|
|
|
|
**Use background execution (is_background: true) for:**
|
|
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
|
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
|
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
|
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
|
- Any command expected to run indefinitely until manually stopped
|
|
|
|
**Use foreground execution (is_background: false) for:**
|
|
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
|
- Build commands: \`npm run build\`, \`make\`
|
|
- Installation commands: \`npm install\`, \`pip install\`
|
|
- Git operations: \`git commit\`, \`git push\`
|
|
- Test runs: \`npm test\`, \`pytest\`
|
|
|
|
The following information is returned:
|
|
|
|
Command: Executed command.
|
|
Directory: Directory where command was executed, or \`(root)\`.
|
|
Stdout: Output on stdout stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
|
Stderr: Output on stderr stream. Can be \`(empty)\` or partial on error and for any unwaited background processes.
|
|
Error: Error or \`(none)\` if no error was reported for the subprocess.
|
|
Exit Code: Exit code or \`(none)\` if terminated by signal.
|
|
Signal: Signal number or \`(none)\` if no signal was received.
|
|
Background PIDs: List of background processes started or \`(none)\`.
|
|
Process Group PGID: Process group started or \`(none)\``;
|
|
|
|
if (os.platform() === 'win32') {
|
|
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
|
|
} else {
|
|
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
|
|
}
|
|
}
|
|
|
|
function getCommandDescription(): string {
|
|
const cmd_substitution_warning =
|
|
'\n*** WARNING: Command substitution using $(), `` ` ``, <(), or >() is not allowed for security reasons.';
|
|
if (os.platform() === 'win32') {
|
|
return (
|
|
'Exact command to execute as `cmd.exe /c <command>`' +
|
|
cmd_substitution_warning
|
|
);
|
|
} else {
|
|
return (
|
|
'Exact bash command to execute as `bash -c <command>`' +
|
|
cmd_substitution_warning
|
|
);
|
|
}
|
|
}
|
|
|
|
export class ShellTool extends BaseDeclarativeTool<
|
|
ShellToolParams,
|
|
ToolResult
|
|
> {
|
|
static Name: string = ToolNames.SHELL;
|
|
private allowlist: Set<string> = new Set();
|
|
|
|
constructor(private readonly config: Config) {
|
|
super(
|
|
ShellTool.Name,
|
|
ToolDisplayNames.SHELL,
|
|
getShellToolDescription(),
|
|
Kind.Execute,
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
command: {
|
|
type: 'string',
|
|
description: getCommandDescription(),
|
|
},
|
|
is_background: {
|
|
type: 'boolean',
|
|
description:
|
|
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
|
|
},
|
|
description: {
|
|
type: 'string',
|
|
description:
|
|
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
|
},
|
|
directory: {
|
|
type: 'string',
|
|
description:
|
|
'(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
|
|
},
|
|
},
|
|
required: ['command', 'is_background'],
|
|
},
|
|
false, // output is not markdown
|
|
true, // output can be updated
|
|
);
|
|
}
|
|
|
|
protected override validateToolParamValues(
|
|
params: ShellToolParams,
|
|
): string | null {
|
|
const commandCheck = isCommandAllowed(params.command, this.config);
|
|
if (!commandCheck.allowed) {
|
|
if (!commandCheck.reason) {
|
|
console.error(
|
|
'Unexpected: isCommandAllowed returned false without a reason',
|
|
);
|
|
return `Command is not allowed: ${params.command}`;
|
|
}
|
|
return commandCheck.reason;
|
|
}
|
|
if (!params.command.trim()) {
|
|
return 'Command cannot be empty.';
|
|
}
|
|
if (getCommandRoots(params.command).length === 0) {
|
|
return 'Could not identify command root to obtain permission from user.';
|
|
}
|
|
if (params.directory) {
|
|
if (!path.isAbsolute(params.directory)) {
|
|
return 'Directory must be an absolute path.';
|
|
}
|
|
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
|
const isWithinWorkspace = workspaceDirs.some((wsDir) =>
|
|
params.directory!.startsWith(wsDir),
|
|
);
|
|
|
|
if (!isWithinWorkspace) {
|
|
return `Directory '${params.directory}' is not within any of the registered workspace directories.`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
protected createInvocation(
|
|
params: ShellToolParams,
|
|
): ToolInvocation<ShellToolParams, ToolResult> {
|
|
return new ShellToolInvocation(this.config, params, this.allowlist);
|
|
}
|
|
}
|