test_bash.py 16 KB

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