bash.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import os
  2. import re
  3. import bashlex
  4. import pexpect
  5. from openhands.core.logger import openhands_logger as logger
  6. from openhands.events.action import CmdRunAction
  7. from openhands.events.event import EventSource
  8. from openhands.events.observation import (
  9. CmdOutputObservation,
  10. ErrorObservation,
  11. )
  12. SOFT_TIMEOUT_SECONDS = 5
  13. def split_bash_commands(commands):
  14. if not commands.strip():
  15. return ['']
  16. try:
  17. parsed = bashlex.parse(commands)
  18. except bashlex.errors.ParsingError as e:
  19. logger.debug(
  20. f'Failed to parse bash commands\n'
  21. f'[input]: {commands}\n'
  22. f'[warning]: {e}\n'
  23. f'The original command will be returned as is.'
  24. )
  25. # If parsing fails, return the original commands
  26. return [commands]
  27. result: list[str] = []
  28. last_end = 0
  29. for node in parsed:
  30. start, end = node.pos
  31. # Include any text between the last command and this one
  32. if start > last_end:
  33. between = commands[last_end:start]
  34. logger.debug(f'BASH PARSING between: {between}')
  35. if result:
  36. result[-1] += between.rstrip()
  37. elif between.strip():
  38. # THIS SHOULD NOT HAPPEN
  39. result.append(between.rstrip())
  40. # Extract the command, preserving original formatting
  41. command = commands[start:end].rstrip()
  42. logger.debug(f'BASH PARSING command: {command}')
  43. result.append(command)
  44. last_end = end
  45. # Add any remaining text after the last command to the last command
  46. remaining = commands[last_end:].rstrip()
  47. logger.debug(f'BASH PARSING remaining: {remaining}')
  48. if last_end < len(commands) and result:
  49. result[-1] += remaining
  50. logger.debug(f'BASH PARSING result[-1] += remaining: {result[-1]}')
  51. elif last_end < len(commands):
  52. if remaining:
  53. result.append(remaining)
  54. logger.debug(f'BASH PARSING result.append(remaining): {result[-1]}')
  55. return result
  56. class BashSession:
  57. """A class that maintains a pexpect process and provides a simple interface for running commands and interacting with the shell."""
  58. def __init__(self, work_dir: str, username: str):
  59. self._pwd = work_dir
  60. self.shell = pexpect.spawn(
  61. f'su {username}',
  62. encoding='utf-8',
  63. codec_errors='replace',
  64. echo=False,
  65. )
  66. self._init_bash_shell(work_dir)
  67. def close(self):
  68. self.shell.close()
  69. @property
  70. def pwd(self):
  71. return self._pwd
  72. @property
  73. def workdir(self):
  74. return self._get_working_directory()
  75. def _get_working_directory(self):
  76. # NOTE: this is part of initialization, so we hard code the timeout
  77. result, exit_code = self._execute_bash('pwd', timeout=60, keep_prompt=False)
  78. if exit_code != 0:
  79. raise RuntimeError(
  80. f'Failed to get working directory (exit code: {exit_code}): {result}'
  81. )
  82. return result.strip()
  83. def _init_bash_shell(self, work_dir: str):
  84. self.__bash_PS1 = (
  85. r'[PEXPECT_BEGIN]\n'
  86. r'$(which python >/dev/null 2>&1 && echo "[Python Interpreter: $(which python)]\n")'
  87. r'\u@\h:\w\n'
  88. r'[PEXPECT_END]'
  89. )
  90. # This should NOT match "PS1=\u@\h:\w [PEXPECT]$" when `env` is executed
  91. self.__bash_expect_regex = r'\[PEXPECT_BEGIN\]\s*(.*?)\s*([a-z0-9_-]*)@([a-zA-Z0-9.-]*):(.+)\s*\[PEXPECT_END\]'
  92. # Set umask to allow group write permissions
  93. self.shell.sendline(f'umask 002; export PS1="{self.__bash_PS1}"; export PS2=""')
  94. self.shell.expect(self.__bash_expect_regex)
  95. self.shell.sendline(
  96. f'if [ ! -d "{work_dir}" ]; then mkdir -p "{work_dir}"; fi && cd "{work_dir}"'
  97. )
  98. self.shell.expect(self.__bash_expect_regex)
  99. logger.debug(
  100. f'Bash initialized. Working directory: {work_dir}. Output: [{self.shell.before}]'
  101. )
  102. # Ensure the group has write permissions on the working directory
  103. self.shell.sendline(f'chmod g+rw "{work_dir}"')
  104. self.shell.expect(self.__bash_expect_regex)
  105. def _get_bash_prompt_and_update_pwd(self):
  106. ps1 = self.shell.after
  107. if ps1 == pexpect.EOF:
  108. logger.error(f'Bash shell EOF! {self.shell.after=}, {self.shell.before=}')
  109. raise RuntimeError('Bash shell EOF')
  110. if ps1 == pexpect.TIMEOUT:
  111. logger.warning('Bash shell timeout')
  112. return ''
  113. # begin at the last occurrence of '[PEXPECT_BEGIN]'.
  114. # In multi-line bash commands, the prompt will be repeated
  115. # and the matched regex captures all of them
  116. # - we only want the last one (newest prompt)
  117. _begin_pos = ps1.rfind('[PEXPECT_BEGIN]')
  118. if _begin_pos != -1:
  119. ps1 = ps1[_begin_pos:]
  120. # parse the ps1 to get username, hostname, and working directory
  121. matched = re.match(self.__bash_expect_regex, ps1)
  122. assert (
  123. matched is not None
  124. ), f'Failed to parse bash prompt: {ps1}. This should not happen.'
  125. other_info, username, hostname, working_dir = matched.groups()
  126. working_dir = working_dir.rstrip()
  127. self._pwd = os.path.expanduser(working_dir)
  128. # re-assemble the prompt
  129. # ignore the hostname AND use 'openhands-workspace'
  130. prompt = f'{other_info.strip()}\n{username}@openhands-workspace:{working_dir} '
  131. if username == 'root':
  132. prompt += '#'
  133. else:
  134. prompt += '$'
  135. return prompt + ' '
  136. def _execute_bash(
  137. self,
  138. command: str,
  139. timeout: int,
  140. keep_prompt: bool = True,
  141. kill_on_timeout: bool = True,
  142. ) -> tuple[str, int]:
  143. logger.debug(f'Executing command: {command}')
  144. self.shell.sendline(command)
  145. return self._continue_bash(
  146. timeout=timeout, keep_prompt=keep_prompt, kill_on_timeout=kill_on_timeout
  147. )
  148. def _interrupt_bash(
  149. self,
  150. action_timeout: int | None,
  151. interrupt_timeout: int | None = None,
  152. max_retries: int = 2,
  153. ) -> tuple[str, int]:
  154. interrupt_timeout = interrupt_timeout or 1 # default timeout for SIGINT
  155. # try to interrupt the bash shell use SIGINT
  156. while max_retries > 0:
  157. self.shell.sendintr() # send SIGINT to the shell
  158. logger.debug('Sent SIGINT to bash. Waiting for output...')
  159. try:
  160. self.shell.expect(self.__bash_expect_regex, timeout=interrupt_timeout)
  161. output = self.shell.before
  162. logger.debug(f'Received output after SIGINT: {output}')
  163. exit_code = 130 # SIGINT
  164. _additional_msg = ''
  165. if action_timeout is not None:
  166. _additional_msg = (
  167. f'Command timed out after {action_timeout} seconds. '
  168. )
  169. output += (
  170. '\r\n\r\n'
  171. + f'[{_additional_msg}SIGINT was sent to interrupt the command.]'
  172. )
  173. return output, exit_code
  174. except pexpect.TIMEOUT as e:
  175. logger.warning(f'Bash pexpect.TIMEOUT while waiting for SIGINT: {e}')
  176. max_retries -= 1
  177. # fall back to send control-z
  178. logger.error(
  179. 'Failed to get output after SIGINT. Max retries reached. Sending control-z...'
  180. )
  181. self.shell.sendcontrol('z')
  182. self.shell.expect(self.__bash_expect_regex)
  183. output = self.shell.before
  184. logger.debug(f'Received output after control-z: {output}')
  185. # Try to kill the job
  186. self.shell.sendline('kill -9 %1')
  187. self.shell.expect(self.__bash_expect_regex)
  188. logger.debug(f'Received output after killing job %1: {self.shell.before}')
  189. output += self.shell.before
  190. _additional_msg = ''
  191. if action_timeout is not None:
  192. _additional_msg = f'Command timed out after {action_timeout} seconds. '
  193. output += (
  194. '\r\n\r\n'
  195. + f'[{_additional_msg}SIGINT was sent to interrupt the command, but failed. The command was killed.]'
  196. )
  197. # Try to get the exit code again
  198. self.shell.sendline('echo $?')
  199. self.shell.expect(self.__bash_expect_regex)
  200. _exit_code_output = self.shell.before
  201. exit_code = self._parse_exit_code(_exit_code_output)
  202. return output, exit_code
  203. def _parse_exit_code(self, output: str) -> int:
  204. try:
  205. exit_code = int(output.strip().split()[0])
  206. except Exception:
  207. logger.error('Error getting exit code from bash script')
  208. # If we try to run an invalid shell script the output sometimes includes error text
  209. # rather than the error code - we assume this is an error
  210. exit_code = 2
  211. return exit_code
  212. def _continue_bash(
  213. self,
  214. timeout: int,
  215. keep_prompt: bool = True,
  216. kill_on_timeout: bool = True,
  217. ) -> tuple[str, int]:
  218. logger.debug(f'Continuing bash with timeout={timeout}')
  219. try:
  220. self.shell.expect(self.__bash_expect_regex, timeout=timeout)
  221. output = self.shell.before
  222. # Get exit code
  223. self.shell.sendline('echo $?')
  224. logger.debug('Requesting exit code...')
  225. self.shell.expect(self.__bash_expect_regex, timeout=timeout)
  226. _exit_code_output = self.shell.before
  227. exit_code = self._parse_exit_code(_exit_code_output)
  228. except pexpect.TIMEOUT as e:
  229. logger.warning(f'Bash pexpect.TIMEOUT while executing bash command: {e}')
  230. if kill_on_timeout:
  231. output, exit_code = self._interrupt_bash(action_timeout=timeout)
  232. else:
  233. output = self.shell.before or ''
  234. exit_code = -1
  235. finally:
  236. bash_prompt = self._get_bash_prompt_and_update_pwd()
  237. if keep_prompt:
  238. output += '\r\n' + bash_prompt
  239. return output, exit_code
  240. def run(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
  241. try:
  242. assert (
  243. action.timeout is not None
  244. ), f'Timeout argument is required for CmdRunAction: {action}'
  245. commands = split_bash_commands(action.command)
  246. all_output = ''
  247. python_interpreter = ''
  248. for command in commands:
  249. if command == '':
  250. output, exit_code = self._continue_bash(
  251. timeout=SOFT_TIMEOUT_SECONDS,
  252. keep_prompt=action.keep_prompt,
  253. kill_on_timeout=False,
  254. )
  255. elif command.lower() == 'ctrl+c':
  256. output, exit_code = self._interrupt_bash(
  257. action_timeout=None, # intentionally None
  258. )
  259. else:
  260. output, exit_code = self._execute_bash(
  261. command,
  262. timeout=SOFT_TIMEOUT_SECONDS
  263. if not action.blocking
  264. else action.timeout,
  265. keep_prompt=action.keep_prompt,
  266. kill_on_timeout=False if not action.blocking else True,
  267. )
  268. # Get rid of the python interpreter string from each line of the output.
  269. # We need it only once at the end.
  270. parts = output.rsplit('[Python Interpreter: ', 1)
  271. output = parts[0]
  272. if len(parts) == 2:
  273. python_interpreter = '[Python Interpreter: ' + parts[1]
  274. if all_output:
  275. # previous output already exists so we add a newline
  276. all_output += '\r\n'
  277. # If the command originated with the agent, append the command that was run...
  278. if action.source == EventSource.AGENT:
  279. all_output += command + '\r\n'
  280. all_output += str(output)
  281. if exit_code != 0:
  282. break
  283. return CmdOutputObservation(
  284. command_id=-1,
  285. content=all_output.rstrip('\r\n'),
  286. command=action.command,
  287. hidden=action.hidden,
  288. exit_code=exit_code,
  289. interpreter_details=python_interpreter,
  290. )
  291. except UnicodeDecodeError as e:
  292. return ErrorObservation(
  293. f'Runtime bash execution failed: Command output could not be decoded as utf-8. {str(e)}',
  294. )