| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- import fs from "fs/promises"
- import * as path from "path"
- import * as vscode from "vscode"
- import delay from "delay"
- import { CommandExecutionStatus } from "@roo-code/types"
- import { TelemetryService } from "@roo-code/telemetry"
- import { Task } from "../task/Task"
- import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
- import { formatResponse } from "../prompts/responses"
- import { unescapeHtmlEntities } from "../../utils/text-normalization"
- import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
- import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
- import { Terminal } from "../../integrations/terminal/Terminal"
- import { Package } from "../../shared/package"
- import { t } from "../../i18n"
- class ShellIntegrationError extends Error {}
- export async function executeCommandTool(
- cline: Task,
- block: ToolUse,
- askApproval: AskApproval,
- handleError: HandleError,
- pushToolResult: PushToolResult,
- removeClosingTag: RemoveClosingTag,
- ) {
- let command: string | undefined = block.params.command
- const customCwd: string | undefined = block.params.cwd
- try {
- if (block.partial) {
- await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
- return
- } else {
- if (!command) {
- cline.consecutiveMistakeCount++
- cline.recordToolError("execute_command")
- pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
- return
- }
- const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(command)
- if (ignoredFileAttemptedToAccess) {
- await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
- pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
- return
- }
- cline.consecutiveMistakeCount = 0
- command = unescapeHtmlEntities(command) // Unescape HTML entities.
- const didApprove = await askApproval("command", command)
- if (!didApprove) {
- return
- }
- const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
- const clineProvider = await cline.providerRef.deref()
- const clineProviderState = await clineProvider?.getState()
- const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
- // Get command execution timeout from VSCode configuration (in seconds)
- const commandExecutionTimeoutSeconds = vscode.workspace
- .getConfiguration(Package.name)
- .get<number>("commandExecutionTimeout", 0)
- // Get command timeout allowlist from VSCode configuration
- const commandTimeoutAllowlist = vscode.workspace
- .getConfiguration(Package.name)
- .get<string[]>("commandTimeoutAllowlist", [])
- // Check if command matches any prefix in the allowlist
- const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command!.startsWith(prefix.trim()))
- // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted
- const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000
- const options: ExecuteCommandOptions = {
- executionId,
- command,
- customCwd,
- terminalShellIntegrationDisabled,
- terminalOutputLineLimit,
- commandExecutionTimeout,
- }
- try {
- const [rejected, result] = await executeCommand(cline, options)
- if (rejected) {
- cline.didRejectTool = true
- }
- pushToolResult(result)
- } catch (error: unknown) {
- const status: CommandExecutionStatus = { executionId, status: "fallback" }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- await cline.say("shell_integration_warning")
- if (error instanceof ShellIntegrationError) {
- const [rejected, result] = await executeCommand(cline, {
- ...options,
- terminalShellIntegrationDisabled: true,
- })
- if (rejected) {
- cline.didRejectTool = true
- }
- pushToolResult(result)
- } else {
- pushToolResult(`Command failed to execute in terminal due to a shell integration error.`)
- }
- }
- return
- }
- } catch (error) {
- await handleError("executing command", error)
- return
- }
- }
- export type ExecuteCommandOptions = {
- executionId: string
- command: string
- customCwd?: string
- terminalShellIntegrationDisabled?: boolean
- terminalOutputLineLimit?: number
- commandExecutionTimeout?: number
- }
- export async function executeCommand(
- cline: Task,
- {
- executionId,
- command,
- customCwd,
- terminalShellIntegrationDisabled = false,
- terminalOutputLineLimit = 500,
- commandExecutionTimeout = 0,
- }: ExecuteCommandOptions,
- ): Promise<[boolean, ToolResponse]> {
- // Convert milliseconds back to seconds for display purposes
- const commandExecutionTimeoutSeconds = commandExecutionTimeout / 1000
- let workingDir: string
- if (!customCwd) {
- workingDir = cline.cwd
- } else if (path.isAbsolute(customCwd)) {
- workingDir = customCwd
- } else {
- workingDir = path.resolve(cline.cwd, customCwd)
- }
- try {
- await fs.access(workingDir)
- } catch (error) {
- return [false, `Working directory '${workingDir}' does not exist.`]
- }
- let message: { text?: string; images?: string[] } | undefined
- let runInBackground = false
- let completed = false
- let result: string = ""
- let exitDetails: ExitCodeDetails | undefined
- let shellIntegrationError: string | undefined
- const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
- const clineProvider = await cline.providerRef.deref()
- let accumulatedOutput = ""
- const callbacks: RooTerminalCallbacks = {
- onLine: async (lines: string, process: RooTerminalProcess) => {
- accumulatedOutput += lines
- const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
- const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- if (runInBackground) {
- return
- }
- try {
- const { response, text, images } = await cline.ask("command_output", "")
- runInBackground = true
- if (response === "messageResponse") {
- message = { text, images }
- process.continue()
- }
- } catch (_error) {}
- },
- onCompleted: (output: string | undefined) => {
- result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
- cline.say("command_output", result)
- completed = true
- },
- onShellExecutionStarted: (pid: number | undefined) => {
- console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
- const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- },
- onShellExecutionComplete: (details: ExitCodeDetails) => {
- const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- exitDetails = details
- },
- }
- if (terminalProvider === "vscode") {
- callbacks.onNoShellIntegration = async (error: string) => {
- TelemetryService.instance.captureShellIntegrationError(cline.taskId)
- shellIntegrationError = error
- }
- }
- const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
- if (terminal instanceof Terminal) {
- terminal.terminal.show(true)
- // Update the working directory in case the terminal we asked for has
- // a different working directory so that the model will know where the
- // command actually executed.
- workingDir = terminal.getCurrentWorkingDirectory()
- }
- const process = terminal.runCommand(command, callbacks)
- cline.terminalProcess = process
- // Implement command execution timeout (skip if timeout is 0)
- if (commandExecutionTimeout > 0) {
- let timeoutId: NodeJS.Timeout | undefined
- let isTimedOut = false
- const timeoutPromise = new Promise<void>((_, reject) => {
- timeoutId = setTimeout(() => {
- isTimedOut = true
- // Try to abort the process
- if (cline.terminalProcess) {
- cline.terminalProcess.abort()
- }
- reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
- }, commandExecutionTimeout)
- })
- try {
- await Promise.race([process, timeoutPromise])
- } catch (error) {
- if (isTimedOut) {
- // Handle timeout case
- const status: CommandExecutionStatus = { executionId, status: "timeout" }
- clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
- // Add visual feedback for timeout
- await cline.say(
- "error",
- t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }),
- )
- cline.terminalProcess = undefined
- return [
- false,
- `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
- ]
- }
- throw error
- } finally {
- if (timeoutId) {
- clearTimeout(timeoutId)
- }
- cline.terminalProcess = undefined
- }
- } else {
- // No timeout - just wait for the process to complete
- try {
- await process
- } finally {
- cline.terminalProcess = undefined
- }
- }
- if (shellIntegrationError) {
- throw new ShellIntegrationError(shellIntegrationError)
- }
- // Wait for a short delay to ensure all messages are sent to the webview.
- // This delay allows time for non-awaited promises to be created and
- // for their associated messages to be sent to the webview, maintaining
- // the correct order of messages (although the webview is smart about
- // grouping command_output messages despite any gaps anyways).
- await delay(50)
- if (message) {
- const { text, images } = message
- await cline.say("user_feedback", text, images)
- return [
- true,
- formatResponse.toolResult(
- [
- `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`,
- result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
- `The user provided the following feedback:`,
- `<feedback>\n${text}\n</feedback>`,
- ].join("\n"),
- images,
- ),
- ]
- } else if (completed || exitDetails) {
- let exitStatus: string = ""
- if (exitDetails !== undefined) {
- if (exitDetails.signalName) {
- exitStatus = `Process terminated by signal ${exitDetails.signalName}`
- if (exitDetails.coreDumpPossible) {
- exitStatus += " - core dump possible"
- }
- } else if (exitDetails.exitCode === undefined) {
- result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
- exitStatus = `Exit code: <undefined, notify user>`
- } else {
- if (exitDetails.exitCode !== 0) {
- exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
- }
- exitStatus += `Exit code: ${exitDetails.exitCode}`
- }
- } else {
- result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
- exitStatus = `Exit code: <undefined, notify user>`
- }
- let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'`
- return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
- } else {
- return [
- false,
- [
- `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`,
- result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
- "You will be updated on the terminal status and new output in the future.",
- ].join("\n"),
- ]
- }
- }
|