test_bash.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. """Bash-related tests for the EventStreamRuntime, which connects to the RuntimeClient running in the sandbox."""
  2. import os
  3. import pytest
  4. from conftest import (
  5. TEST_IN_CI,
  6. _close_test_runtime,
  7. _get_sandbox_folder,
  8. _load_runtime,
  9. )
  10. from openhands.core.logger import openhands_logger as logger
  11. from openhands.events.action import CmdRunAction
  12. from openhands.events.observation import CmdOutputObservation
  13. # ============================================================================================================================
  14. # Bash-specific tests
  15. # ============================================================================================================================
  16. def _run_cmd_action(runtime, custom_command: str, keep_prompt=True):
  17. action = CmdRunAction(command=custom_command, keep_prompt=keep_prompt)
  18. logger.info(action, extra={'msg_type': 'ACTION'})
  19. obs = runtime.run_action(action)
  20. assert isinstance(obs, CmdOutputObservation)
  21. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  22. return obs
  23. def test_bash_command_pexcept(temp_dir, box_class, run_as_openhands):
  24. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  25. try:
  26. # We set env var PS1="\u@\h:\w $"
  27. # and construct the PEXCEPT prompt base on it.
  28. # When run `env`, bad implementation of CmdRunAction will be pexcepted by this
  29. # and failed to pexcept the right content, causing it fail to get error code.
  30. obs = runtime.run_action(CmdRunAction(command='env'))
  31. # For example:
  32. # 02:16:13 - openhands:DEBUG: client.py:78 - Executing command: env
  33. # 02:16:13 - openhands:DEBUG: client.py:82 - Command output: PYTHONUNBUFFERED=1
  34. # CONDA_EXE=/openhands/miniforge3/bin/conda
  35. # [...]
  36. # LC_CTYPE=C.UTF-8
  37. # PS1=\u@\h:\w $
  38. # 02:16:13 - openhands:DEBUG: client.py:89 - Executing command for exit code: env
  39. # 02:16:13 - openhands:DEBUG: client.py:92 - Exit code Output:
  40. # CONDA_DEFAULT_ENV=base
  41. # As long as the exit code is 0, the test will pass.
  42. assert isinstance(
  43. obs, CmdOutputObservation
  44. ), 'The observation should be a CmdOutputObservation.'
  45. assert obs.exit_code == 0, 'The exit code should be 0.'
  46. finally:
  47. _close_test_runtime(runtime)
  48. def test_bash_timeout_and_keyboard_interrupt(temp_dir, box_class, run_as_openhands):
  49. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  50. try:
  51. action = CmdRunAction(command='python -c "import time; time.sleep(10)"')
  52. action.timeout = 1
  53. obs = runtime.run_action(action)
  54. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  55. assert isinstance(obs, CmdOutputObservation)
  56. assert (
  57. '[Command timed out after 1 seconds. SIGINT was sent to interrupt it.]'
  58. in obs.content
  59. )
  60. assert 'KeyboardInterrupt' in obs.content
  61. # follow up command should not be affected
  62. action = CmdRunAction(command='ls')
  63. action.timeout = 1
  64. obs = runtime.run_action(action)
  65. assert isinstance(obs, CmdOutputObservation)
  66. assert obs.exit_code == 0
  67. logger.info(obs, extra={'msg_type': 'OBSERVATION'})
  68. finally:
  69. _close_test_runtime(runtime)
  70. def test_multiline_commands(temp_dir, box_class):
  71. runtime = _load_runtime(temp_dir, box_class)
  72. try:
  73. # single multiline command
  74. obs = _run_cmd_action(runtime, 'echo \\\n -e "foo"')
  75. assert obs.exit_code == 0, 'The exit code should be 0.'
  76. assert 'foo' in obs.content
  77. # test multiline echo
  78. obs = _run_cmd_action(runtime, 'echo -e "hello\nworld"')
  79. assert obs.exit_code == 0, 'The exit code should be 0.'
  80. assert 'hello\r\nworld' in obs.content
  81. # test whitespace
  82. obs = _run_cmd_action(runtime, 'echo -e "\\n\\n\\n"')
  83. assert obs.exit_code == 0, 'The exit code should be 0.'
  84. assert '\r\n\r\n\r\n' in obs.content
  85. finally:
  86. _close_test_runtime(runtime)
  87. def test_multiple_multiline_commands(temp_dir, box_class, run_as_openhands):
  88. cmds = [
  89. 'ls -l',
  90. 'echo -e "hello\nworld"',
  91. """
  92. echo -e "hello it\\'s me"
  93. """.strip(),
  94. """
  95. echo \\
  96. -e 'hello' \\
  97. -v
  98. """.strip(),
  99. """
  100. echo -e 'hello\\nworld\\nare\\nyou\\nthere?'
  101. """.strip(),
  102. """
  103. echo -e 'hello
  104. world
  105. are
  106. you\\n
  107. there?'
  108. """.strip(),
  109. """
  110. echo -e 'hello
  111. world "
  112. '
  113. """.strip(),
  114. ]
  115. joined_cmds = '\n'.join(cmds)
  116. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  117. try:
  118. obs = _run_cmd_action(runtime, joined_cmds)
  119. assert obs.exit_code == 0, 'The exit code should be 0.'
  120. assert 'total 0' in obs.content
  121. assert 'hello\r\nworld' in obs.content
  122. assert "hello it\\'s me" in obs.content
  123. assert 'hello -v' in obs.content
  124. assert 'hello\r\nworld\r\nare\r\nyou\r\nthere?' in obs.content
  125. assert 'hello\r\nworld\r\nare\r\nyou\r\n\r\nthere?' in obs.content
  126. assert 'hello\r\nworld "\r\n' in obs.content
  127. finally:
  128. _close_test_runtime(runtime)
  129. def test_no_ps2_in_output(temp_dir, box_class, run_as_openhands):
  130. """Test that the PS2 sign is not added to the output of a multiline command."""
  131. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  132. try:
  133. obs = _run_cmd_action(runtime, 'echo -e "hello\nworld"')
  134. assert obs.exit_code == 0, 'The exit code should be 0.'
  135. assert 'hello\r\nworld' in obs.content
  136. assert '>' not in obs.content
  137. finally:
  138. _close_test_runtime(runtime)
  139. def test_multiline_command_loop(temp_dir, box_class):
  140. # https://github.com/All-Hands-AI/OpenHands/issues/3143
  141. init_cmd = """
  142. mkdir -p _modules && \
  143. for month in {01..04}; do
  144. for day in {01..05}; do
  145. touch "_modules/2024-${month}-${day}-sample.md"
  146. done
  147. done
  148. echo "created files"
  149. """
  150. follow_up_cmd = """
  151. for file in _modules/*.md; do
  152. new_date=$(echo $file | sed -E 's/2024-(01|02|03|04)-/2024-/;s/2024-01/2024-08/;s/2024-02/2024-09/;s/2024-03/2024-10/;s/2024-04/2024-11/')
  153. mv "$file" "$new_date"
  154. done
  155. echo "success"
  156. """
  157. runtime = _load_runtime(temp_dir, box_class)
  158. try:
  159. obs = _run_cmd_action(runtime, init_cmd)
  160. assert obs.exit_code == 0, 'The exit code should be 0.'
  161. assert 'created files' in obs.content
  162. obs = _run_cmd_action(runtime, follow_up_cmd)
  163. assert obs.exit_code == 0, 'The exit code should be 0.'
  164. assert 'success' in obs.content
  165. finally:
  166. _close_test_runtime(runtime)
  167. def test_cmd_run(temp_dir, box_class, run_as_openhands):
  168. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  169. try:
  170. obs = _run_cmd_action(runtime, 'ls -l /openhands/workspace')
  171. assert obs.exit_code == 0
  172. obs = _run_cmd_action(runtime, 'ls -l')
  173. assert obs.exit_code == 0
  174. assert 'total 0' in obs.content
  175. obs = _run_cmd_action(runtime, 'mkdir test')
  176. assert obs.exit_code == 0
  177. obs = _run_cmd_action(runtime, 'ls -l')
  178. assert obs.exit_code == 0
  179. if run_as_openhands:
  180. assert 'openhands' in obs.content
  181. else:
  182. assert 'root' in obs.content
  183. assert 'test' in obs.content
  184. obs = _run_cmd_action(runtime, 'touch test/foo.txt')
  185. assert obs.exit_code == 0
  186. obs = _run_cmd_action(runtime, 'ls -l test')
  187. assert obs.exit_code == 0
  188. assert 'foo.txt' in obs.content
  189. # clean up: this is needed, since CI will not be
  190. # run as root, and this test may leave a file
  191. # owned by root
  192. _run_cmd_action(runtime, 'rm -rf test')
  193. assert obs.exit_code == 0
  194. finally:
  195. _close_test_runtime(runtime)
  196. def test_run_as_user_correct_home_dir(temp_dir, box_class, run_as_openhands):
  197. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  198. try:
  199. obs = _run_cmd_action(runtime, 'cd ~ && pwd')
  200. assert obs.exit_code == 0
  201. if run_as_openhands:
  202. assert '/home/openhands' in obs.content
  203. else:
  204. assert '/root' in obs.content
  205. finally:
  206. _close_test_runtime(runtime)
  207. def test_multi_cmd_run_in_single_line(temp_dir, box_class):
  208. runtime = _load_runtime(temp_dir, box_class)
  209. try:
  210. obs = _run_cmd_action(runtime, 'pwd && ls -l')
  211. assert obs.exit_code == 0
  212. assert '/workspace' in obs.content
  213. assert 'total 0' in obs.content
  214. finally:
  215. _close_test_runtime(runtime)
  216. def test_stateful_cmd(temp_dir, box_class):
  217. runtime = _load_runtime(temp_dir, box_class)
  218. sandbox_dir = _get_sandbox_folder(runtime)
  219. try:
  220. obs = _run_cmd_action(runtime, 'mkdir -p test')
  221. assert obs.exit_code == 0, 'The exit code should be 0.'
  222. obs = _run_cmd_action(runtime, 'cd test')
  223. assert obs.exit_code == 0, 'The exit code should be 0.'
  224. obs = _run_cmd_action(runtime, 'pwd')
  225. assert obs.exit_code == 0, 'The exit code should be 0.'
  226. assert f'{sandbox_dir}/test' in obs.content
  227. finally:
  228. _close_test_runtime(runtime)
  229. def test_failed_cmd(temp_dir, box_class):
  230. runtime = _load_runtime(temp_dir, box_class)
  231. try:
  232. obs = _run_cmd_action(runtime, 'non_existing_command')
  233. assert obs.exit_code != 0, 'The exit code should not be 0 for a failed command.'
  234. finally:
  235. _close_test_runtime(runtime)
  236. def _create_test_file(host_temp_dir):
  237. # Single file
  238. with open(os.path.join(host_temp_dir, 'test_file.txt'), 'w') as f:
  239. f.write('Hello, World!')
  240. def test_copy_single_file(temp_dir, box_class):
  241. runtime = _load_runtime(temp_dir, box_class)
  242. try:
  243. sandbox_dir = _get_sandbox_folder(runtime)
  244. sandbox_file = os.path.join(sandbox_dir, 'test_file.txt')
  245. _create_test_file(temp_dir)
  246. runtime.copy_to(os.path.join(temp_dir, 'test_file.txt'), sandbox_dir)
  247. obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}')
  248. assert obs.exit_code == 0
  249. assert 'test_file.txt' in obs.content
  250. obs = _run_cmd_action(runtime, f'cat {sandbox_file}')
  251. assert obs.exit_code == 0
  252. assert 'Hello, World!' in obs.content
  253. finally:
  254. _close_test_runtime(runtime)
  255. def _create_host_test_dir_with_files(test_dir):
  256. logger.debug(f'creating `{test_dir}`')
  257. if not os.path.isdir(test_dir):
  258. os.makedirs(test_dir, exist_ok=True)
  259. logger.debug('creating test files in `test_dir`')
  260. with open(os.path.join(test_dir, 'file1.txt'), 'w') as f:
  261. f.write('File 1 content')
  262. with open(os.path.join(test_dir, 'file2.txt'), 'w') as f:
  263. f.write('File 2 content')
  264. def test_copy_directory_recursively(temp_dir, box_class):
  265. runtime = _load_runtime(temp_dir, box_class)
  266. sandbox_dir = _get_sandbox_folder(runtime)
  267. try:
  268. temp_dir_copy = os.path.join(temp_dir, 'test_dir')
  269. # We need a separate directory, since temp_dir is mounted to /workspace
  270. _create_host_test_dir_with_files(temp_dir_copy)
  271. runtime.copy_to(temp_dir_copy, sandbox_dir, recursive=True)
  272. obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}')
  273. assert obs.exit_code == 0
  274. assert 'test_dir' in obs.content
  275. assert 'file1.txt' not in obs.content
  276. assert 'file2.txt' not in obs.content
  277. obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}/test_dir')
  278. assert obs.exit_code == 0
  279. assert 'file1.txt' in obs.content
  280. assert 'file2.txt' in obs.content
  281. obs = _run_cmd_action(runtime, f'cat {sandbox_dir}/test_dir/file1.txt')
  282. assert obs.exit_code == 0
  283. assert 'File 1 content' in obs.content
  284. finally:
  285. _close_test_runtime(runtime)
  286. def test_copy_to_non_existent_directory(temp_dir, box_class):
  287. runtime = _load_runtime(temp_dir, box_class)
  288. try:
  289. sandbox_dir = _get_sandbox_folder(runtime)
  290. _create_test_file(temp_dir)
  291. runtime.copy_to(
  292. os.path.join(temp_dir, 'test_file.txt'), f'{sandbox_dir}/new_dir'
  293. )
  294. obs = _run_cmd_action(runtime, f'cat {sandbox_dir}/new_dir/test_file.txt')
  295. assert obs.exit_code == 0
  296. assert 'Hello, World!' in obs.content
  297. finally:
  298. _close_test_runtime(runtime)
  299. def test_overwrite_existing_file(temp_dir, box_class):
  300. runtime = _load_runtime(temp_dir, box_class)
  301. try:
  302. sandbox_dir = _get_sandbox_folder(runtime)
  303. obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}')
  304. assert obs.exit_code == 0
  305. obs = _run_cmd_action(runtime, f'touch {sandbox_dir}/test_file.txt')
  306. assert obs.exit_code == 0
  307. obs = _run_cmd_action(runtime, f'ls -alh {sandbox_dir}')
  308. assert obs.exit_code == 0
  309. obs = _run_cmd_action(runtime, f'cat {sandbox_dir}/test_file.txt')
  310. assert obs.exit_code == 0
  311. assert 'Hello, World!' not in obs.content
  312. _create_test_file(temp_dir)
  313. runtime.copy_to(os.path.join(temp_dir, 'test_file.txt'), sandbox_dir)
  314. obs = _run_cmd_action(runtime, f'cat {sandbox_dir}/test_file.txt')
  315. assert obs.exit_code == 0
  316. assert 'Hello, World!' in obs.content
  317. finally:
  318. _close_test_runtime(runtime)
  319. def test_copy_non_existent_file(temp_dir, box_class):
  320. runtime = _load_runtime(temp_dir, box_class)
  321. try:
  322. sandbox_dir = _get_sandbox_folder(runtime)
  323. with pytest.raises(FileNotFoundError):
  324. runtime.copy_to(
  325. os.path.join(sandbox_dir, 'non_existent_file.txt'),
  326. f'{sandbox_dir}/should_not_exist.txt',
  327. )
  328. obs = _run_cmd_action(runtime, f'ls {sandbox_dir}/should_not_exist.txt')
  329. assert obs.exit_code != 0 # File should not exist
  330. finally:
  331. _close_test_runtime(runtime)
  332. def test_keep_prompt(box_class, temp_dir):
  333. runtime = _load_runtime(
  334. temp_dir,
  335. box_class=box_class,
  336. run_as_openhands=False,
  337. )
  338. try:
  339. sandbox_dir = _get_sandbox_folder(runtime)
  340. obs = _run_cmd_action(runtime, f'touch {sandbox_dir}/test_file.txt')
  341. assert obs.exit_code == 0
  342. assert 'root@' in obs.content
  343. obs = _run_cmd_action(
  344. runtime, f'cat {sandbox_dir}/test_file.txt', keep_prompt=False
  345. )
  346. assert obs.exit_code == 0
  347. assert 'root@' not in obs.content
  348. finally:
  349. _close_test_runtime(runtime)
  350. @pytest.mark.skipif(
  351. TEST_IN_CI != 'True',
  352. reason='This test is not working in WSL (file ownership)',
  353. )
  354. def test_git_operation(box_class):
  355. # do not mount workspace, since workspace mount by tests will be owned by root
  356. # while the user_id we get via os.getuid() is different from root
  357. # which causes permission issues
  358. runtime = _load_runtime(
  359. temp_dir=None,
  360. box_class=box_class,
  361. # Need to use non-root user to expose issues
  362. run_as_openhands=True,
  363. )
  364. # this will happen if permission of runtime is not properly configured
  365. # fatal: detected dubious ownership in repository at '/workspace'
  366. try:
  367. # check the ownership of the current directory
  368. obs = _run_cmd_action(runtime, 'ls -alh .')
  369. assert obs.exit_code == 0
  370. # drwx--S--- 2 openhands root 64 Aug 7 23:32 .
  371. # drwxr-xr-x 1 root root 4.0K Aug 7 23:33 ..
  372. for line in obs.content.split('\r\n'):
  373. if ' ..' in line:
  374. # parent directory should be owned by root
  375. assert 'root' in line
  376. assert 'openhands' not in line
  377. elif ' .' in line:
  378. # current directory should be owned by openhands
  379. # and its group should be root
  380. assert 'openhands' in line
  381. assert 'root' in line
  382. # make sure all git operations are allowed
  383. obs = _run_cmd_action(runtime, 'git init')
  384. assert obs.exit_code == 0
  385. # create a file
  386. obs = _run_cmd_action(runtime, 'echo "hello" > test_file.txt')
  387. assert obs.exit_code == 0
  388. # git add
  389. obs = _run_cmd_action(runtime, 'git add test_file.txt')
  390. assert obs.exit_code == 0
  391. # git diff
  392. obs = _run_cmd_action(runtime, 'git diff')
  393. assert obs.exit_code == 0
  394. # git commit
  395. obs = _run_cmd_action(runtime, 'git commit -m "test commit"')
  396. assert obs.exit_code == 0
  397. finally:
  398. _close_test_runtime(runtime)
  399. def test_python_version(temp_dir, box_class, run_as_openhands):
  400. runtime = _load_runtime(temp_dir, box_class, run_as_openhands)
  401. try:
  402. obs = runtime.run_action(CmdRunAction(command='python --version'))
  403. assert isinstance(
  404. obs, CmdOutputObservation
  405. ), 'The observation should be a CmdOutputObservation.'
  406. assert obs.exit_code == 0, 'The exit code should be 0.'
  407. assert 'Python 3' in obs.content, 'The output should contain "Python 3".'
  408. finally:
  409. _close_test_runtime(runtime)