executeCommandTool.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import fs from "fs/promises"
  2. import * as path from "path"
  3. import * as vscode from "vscode"
  4. import delay from "delay"
  5. import { CommandExecutionStatus } from "@roo-code/types"
  6. import { TelemetryService } from "@roo-code/telemetry"
  7. import { Task } from "../task/Task"
  8. import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
  9. import { formatResponse } from "../prompts/responses"
  10. import { unescapeHtmlEntities } from "../../utils/text-normalization"
  11. import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
  12. import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
  13. import { Terminal } from "../../integrations/terminal/Terminal"
  14. import { Package } from "../../shared/package"
  15. import { t } from "../../i18n"
  16. class ShellIntegrationError extends Error {}
  17. export async function executeCommandTool(
  18. cline: Task,
  19. block: ToolUse,
  20. askApproval: AskApproval,
  21. handleError: HandleError,
  22. pushToolResult: PushToolResult,
  23. removeClosingTag: RemoveClosingTag,
  24. ) {
  25. let command: string | undefined = block.params.command
  26. const customCwd: string | undefined = block.params.cwd
  27. try {
  28. if (block.partial) {
  29. await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
  30. return
  31. } else {
  32. if (!command) {
  33. cline.consecutiveMistakeCount++
  34. cline.recordToolError("execute_command")
  35. pushToolResult(await cline.sayAndCreateMissingParamError("execute_command", "command"))
  36. return
  37. }
  38. const ignoredFileAttemptedToAccess = cline.rooIgnoreController?.validateCommand(command)
  39. if (ignoredFileAttemptedToAccess) {
  40. await cline.say("rooignore_error", ignoredFileAttemptedToAccess)
  41. pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess)))
  42. return
  43. }
  44. cline.consecutiveMistakeCount = 0
  45. command = unescapeHtmlEntities(command) // Unescape HTML entities.
  46. const didApprove = await askApproval("command", command)
  47. if (!didApprove) {
  48. return
  49. }
  50. const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
  51. const clineProvider = await cline.providerRef.deref()
  52. const clineProviderState = await clineProvider?.getState()
  53. const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
  54. // Get command execution timeout from VSCode configuration (in seconds)
  55. const commandExecutionTimeoutSeconds = vscode.workspace
  56. .getConfiguration(Package.name)
  57. .get<number>("commandExecutionTimeout", 0)
  58. // Get command timeout allowlist from VSCode configuration
  59. const commandTimeoutAllowlist = vscode.workspace
  60. .getConfiguration(Package.name)
  61. .get<string[]>("commandTimeoutAllowlist", [])
  62. // Check if command matches any prefix in the allowlist
  63. const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command!.startsWith(prefix.trim()))
  64. // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted
  65. const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000
  66. const options: ExecuteCommandOptions = {
  67. executionId,
  68. command,
  69. customCwd,
  70. terminalShellIntegrationDisabled,
  71. terminalOutputLineLimit,
  72. commandExecutionTimeout,
  73. }
  74. try {
  75. const [rejected, result] = await executeCommand(cline, options)
  76. if (rejected) {
  77. cline.didRejectTool = true
  78. }
  79. pushToolResult(result)
  80. } catch (error: unknown) {
  81. const status: CommandExecutionStatus = { executionId, status: "fallback" }
  82. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  83. await cline.say("shell_integration_warning")
  84. if (error instanceof ShellIntegrationError) {
  85. const [rejected, result] = await executeCommand(cline, {
  86. ...options,
  87. terminalShellIntegrationDisabled: true,
  88. })
  89. if (rejected) {
  90. cline.didRejectTool = true
  91. }
  92. pushToolResult(result)
  93. } else {
  94. pushToolResult(`Command failed to execute in terminal due to a shell integration error.`)
  95. }
  96. }
  97. return
  98. }
  99. } catch (error) {
  100. await handleError("executing command", error)
  101. return
  102. }
  103. }
  104. export type ExecuteCommandOptions = {
  105. executionId: string
  106. command: string
  107. customCwd?: string
  108. terminalShellIntegrationDisabled?: boolean
  109. terminalOutputLineLimit?: number
  110. commandExecutionTimeout?: number
  111. }
  112. export async function executeCommand(
  113. cline: Task,
  114. {
  115. executionId,
  116. command,
  117. customCwd,
  118. terminalShellIntegrationDisabled = false,
  119. terminalOutputLineLimit = 500,
  120. commandExecutionTimeout = 0,
  121. }: ExecuteCommandOptions,
  122. ): Promise<[boolean, ToolResponse]> {
  123. // Convert milliseconds back to seconds for display purposes
  124. const commandExecutionTimeoutSeconds = commandExecutionTimeout / 1000
  125. let workingDir: string
  126. if (!customCwd) {
  127. workingDir = cline.cwd
  128. } else if (path.isAbsolute(customCwd)) {
  129. workingDir = customCwd
  130. } else {
  131. workingDir = path.resolve(cline.cwd, customCwd)
  132. }
  133. try {
  134. await fs.access(workingDir)
  135. } catch (error) {
  136. return [false, `Working directory '${workingDir}' does not exist.`]
  137. }
  138. let message: { text?: string; images?: string[] } | undefined
  139. let runInBackground = false
  140. let completed = false
  141. let result: string = ""
  142. let exitDetails: ExitCodeDetails | undefined
  143. let shellIntegrationError: string | undefined
  144. const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
  145. const clineProvider = await cline.providerRef.deref()
  146. let accumulatedOutput = ""
  147. const callbacks: RooTerminalCallbacks = {
  148. onLine: async (lines: string, process: RooTerminalProcess) => {
  149. accumulatedOutput += lines
  150. const compressedOutput = Terminal.compressTerminalOutput(accumulatedOutput, terminalOutputLineLimit)
  151. const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
  152. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  153. if (runInBackground) {
  154. return
  155. }
  156. try {
  157. const { response, text, images } = await cline.ask("command_output", "")
  158. runInBackground = true
  159. if (response === "messageResponse") {
  160. message = { text, images }
  161. process.continue()
  162. }
  163. } catch (_error) {}
  164. },
  165. onCompleted: (output: string | undefined) => {
  166. result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
  167. cline.say("command_output", result)
  168. completed = true
  169. },
  170. onShellExecutionStarted: (pid: number | undefined) => {
  171. console.log(`[executeCommand] onShellExecutionStarted: ${pid}`)
  172. const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
  173. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  174. },
  175. onShellExecutionComplete: (details: ExitCodeDetails) => {
  176. const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
  177. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  178. exitDetails = details
  179. },
  180. }
  181. if (terminalProvider === "vscode") {
  182. callbacks.onNoShellIntegration = async (error: string) => {
  183. TelemetryService.instance.captureShellIntegrationError(cline.taskId)
  184. shellIntegrationError = error
  185. }
  186. }
  187. const terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!customCwd, cline.taskId, terminalProvider)
  188. if (terminal instanceof Terminal) {
  189. terminal.terminal.show(true)
  190. // Update the working directory in case the terminal we asked for has
  191. // a different working directory so that the model will know where the
  192. // command actually executed.
  193. workingDir = terminal.getCurrentWorkingDirectory()
  194. }
  195. const process = terminal.runCommand(command, callbacks)
  196. cline.terminalProcess = process
  197. // Implement command execution timeout (skip if timeout is 0)
  198. if (commandExecutionTimeout > 0) {
  199. let timeoutId: NodeJS.Timeout | undefined
  200. let isTimedOut = false
  201. const timeoutPromise = new Promise<void>((_, reject) => {
  202. timeoutId = setTimeout(() => {
  203. isTimedOut = true
  204. // Try to abort the process
  205. if (cline.terminalProcess) {
  206. cline.terminalProcess.abort()
  207. }
  208. reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`))
  209. }, commandExecutionTimeout)
  210. })
  211. try {
  212. await Promise.race([process, timeoutPromise])
  213. } catch (error) {
  214. if (isTimedOut) {
  215. // Handle timeout case
  216. const status: CommandExecutionStatus = { executionId, status: "timeout" }
  217. clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
  218. // Add visual feedback for timeout
  219. await cline.say(
  220. "error",
  221. t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }),
  222. )
  223. cline.terminalProcess = undefined
  224. return [
  225. false,
  226. `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`,
  227. ]
  228. }
  229. throw error
  230. } finally {
  231. if (timeoutId) {
  232. clearTimeout(timeoutId)
  233. }
  234. cline.terminalProcess = undefined
  235. }
  236. } else {
  237. // No timeout - just wait for the process to complete
  238. try {
  239. await process
  240. } finally {
  241. cline.terminalProcess = undefined
  242. }
  243. }
  244. if (shellIntegrationError) {
  245. throw new ShellIntegrationError(shellIntegrationError)
  246. }
  247. // Wait for a short delay to ensure all messages are sent to the webview.
  248. // This delay allows time for non-awaited promises to be created and
  249. // for their associated messages to be sent to the webview, maintaining
  250. // the correct order of messages (although the webview is smart about
  251. // grouping command_output messages despite any gaps anyways).
  252. await delay(50)
  253. if (message) {
  254. const { text, images } = message
  255. await cline.say("user_feedback", text, images)
  256. return [
  257. true,
  258. formatResponse.toolResult(
  259. [
  260. `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`,
  261. result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
  262. `The user provided the following feedback:`,
  263. `<feedback>\n${text}\n</feedback>`,
  264. ].join("\n"),
  265. images,
  266. ),
  267. ]
  268. } else if (completed || exitDetails) {
  269. let exitStatus: string = ""
  270. if (exitDetails !== undefined) {
  271. if (exitDetails.signalName) {
  272. exitStatus = `Process terminated by signal ${exitDetails.signalName}`
  273. if (exitDetails.coreDumpPossible) {
  274. exitStatus += " - core dump possible"
  275. }
  276. } else if (exitDetails.exitCode === undefined) {
  277. result += "<VSCE exit code is undefined: terminal output and command execution status is unknown.>"
  278. exitStatus = `Exit code: <undefined, notify user>`
  279. } else {
  280. if (exitDetails.exitCode !== 0) {
  281. exitStatus += "Command execution was not successful, inspect the cause and adjust as needed.\n"
  282. }
  283. exitStatus += `Exit code: ${exitDetails.exitCode}`
  284. }
  285. } else {
  286. result += "<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>"
  287. exitStatus = `Exit code: <undefined, notify user>`
  288. }
  289. let workingDirInfo = ` within working directory '${terminal.getCurrentWorkingDirectory().toPosix()}'`
  290. return [false, `Command executed in terminal ${workingDirInfo}. ${exitStatus}\nOutput:\n${result}`]
  291. } else {
  292. return [
  293. false,
  294. [
  295. `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`,
  296. result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n",
  297. "You will be updated on the terminal status and new output in the future.",
  298. ].join("\n"),
  299. ]
  300. }
  301. }