test_sandbox.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import os
  2. import pathlib
  3. import tempfile
  4. from unittest.mock import patch
  5. import pytest
  6. from opendevin.core.config import config
  7. from opendevin.runtime.docker.local_box import LocalBox
  8. from opendevin.runtime.docker.ssh_box import DockerSSHBox, split_bash_commands
  9. from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
  10. @pytest.fixture
  11. def temp_dir(monkeypatch):
  12. # get a temporary directory
  13. with tempfile.TemporaryDirectory() as temp_dir:
  14. pathlib.Path().mkdir(parents=True, exist_ok=True)
  15. yield temp_dir
  16. def test_env_vars(temp_dir):
  17. os.environ['SANDBOX_ENV_FOOBAR'] = 'BAZ'
  18. for box_class in [DockerSSHBox, LocalBox]:
  19. box = box_class()
  20. box.add_to_env('QUUX', 'abc"def')
  21. assert box._env['FOOBAR'] == 'BAZ'
  22. assert box._env['QUUX'] == 'abc"def'
  23. exit_code, output = box.execute('echo $FOOBAR $QUUX')
  24. assert exit_code == 0, 'The exit code should be 0.'
  25. assert output.strip() == 'BAZ abc"def', f'Output: {output} for {box_class}'
  26. def test_split_commands():
  27. cmds = [
  28. 'ls -l',
  29. 'echo -e "hello\nworld"',
  30. """
  31. echo -e 'hello it\\'s me'
  32. """.strip(),
  33. """
  34. echo \\
  35. -e 'hello' \\
  36. -v
  37. """.strip(),
  38. """
  39. echo -e 'hello\\nworld\\nare\\nyou\\nthere?'
  40. """.strip(),
  41. """
  42. echo -e 'hello
  43. world
  44. are
  45. you\\n
  46. there?'
  47. """.strip(),
  48. """
  49. echo -e 'hello
  50. world "
  51. '
  52. """.strip(),
  53. """
  54. kubectl apply -f - <<EOF
  55. apiVersion: v1
  56. kind: Pod
  57. metadata:
  58. name: busybox-sleep
  59. spec:
  60. containers:
  61. - name: busybox
  62. image: busybox:1.28
  63. args:
  64. - sleep
  65. - "1000000"
  66. EOF
  67. """.strip(),
  68. ]
  69. joined_cmds = '\n'.join(cmds)
  70. split_cmds = split_bash_commands(joined_cmds)
  71. for s in split_cmds:
  72. print('\nCMD')
  73. print(s)
  74. cmds = [
  75. c.replace('\\\n', '') for c in cmds
  76. ] # The function strips escaped newlines, but this shouldn't matter
  77. assert (
  78. split_cmds == cmds
  79. ), 'The split commands should be the same as the input commands.'
  80. def test_ssh_box_run_as_devin(temp_dir):
  81. # get a temporary directory
  82. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  83. config, 'workspace_mount_path', new=temp_dir
  84. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  85. config.sandbox, 'box_type', new='ssh'
  86. ):
  87. for box in [
  88. DockerSSHBox()
  89. ]: # FIXME: permission error on mkdir test for exec box
  90. exit_code, output = box.execute('ls -l')
  91. assert exit_code == 0, (
  92. 'The exit code should be 0 for ' + box.__class__.__name__
  93. )
  94. assert output.strip() == 'total 0'
  95. assert config.workspace_base == temp_dir
  96. exit_code, output = box.execute('ls -l')
  97. assert exit_code == 0, 'The exit code should be 0.'
  98. assert output.strip() == 'total 0'
  99. exit_code, output = box.execute('mkdir test')
  100. assert exit_code == 0, 'The exit code should be 0.'
  101. assert output.strip() == ''
  102. exit_code, output = box.execute('ls -l')
  103. assert exit_code == 0, 'The exit code should be 0.'
  104. assert (
  105. 'opendevin' in output
  106. ), "The output should contain username 'opendevin'"
  107. assert 'test' in output, 'The output should contain the test directory'
  108. exit_code, output = box.execute('touch test/foo.txt')
  109. assert exit_code == 0, 'The exit code should be 0.'
  110. assert output.strip() == ''
  111. exit_code, output = box.execute('ls -l test')
  112. assert exit_code == 0, 'The exit code should be 0.'
  113. assert 'foo.txt' in output, 'The output should contain the foo.txt file'
  114. box.close()
  115. def test_ssh_box_multi_line_cmd_run_as_devin(temp_dir):
  116. # get a temporary directory
  117. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  118. config, 'workspace_mount_path', new=temp_dir
  119. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  120. config.sandbox, 'box_type', new='ssh'
  121. ):
  122. box = DockerSSHBox()
  123. exit_code, output = box.execute('pwd && ls -l')
  124. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  125. expected_lines = ['/workspace', 'total 0']
  126. line_sep = '\r\n' if isinstance(box, DockerSSHBox) else '\n'
  127. assert output == line_sep.join(expected_lines), (
  128. 'The output should be the same as the input for ' + box.__class__.__name__
  129. )
  130. box.close()
  131. def test_ssh_box_stateful_cmd_run_as_devin(temp_dir):
  132. # get a temporary directory
  133. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  134. config, 'workspace_mount_path', new=temp_dir
  135. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  136. config.sandbox, 'box_type', new='ssh'
  137. ):
  138. box = DockerSSHBox()
  139. exit_code, output = box.execute('mkdir test')
  140. assert exit_code == 0, 'The exit code should be 0.'
  141. assert output.strip() == ''
  142. exit_code, output = box.execute('cd test')
  143. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  144. assert output.strip() == '', (
  145. 'The output should be empty for ' + box.__class__.__name__
  146. )
  147. exit_code, output = box.execute('pwd')
  148. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  149. assert output.strip() == '/workspace/test', (
  150. 'The output should be /workspace for ' + box.__class__.__name__
  151. )
  152. box.close()
  153. def test_ssh_box_failed_cmd_run_as_devin(temp_dir):
  154. # get a temporary directory
  155. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  156. config, 'workspace_mount_path', new=temp_dir
  157. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  158. config.sandbox, 'box_type', new='ssh'
  159. ):
  160. box = DockerSSHBox()
  161. exit_code, output = box.execute('non_existing_command')
  162. assert exit_code != 0, (
  163. 'The exit code should not be 0 for a failed command for '
  164. + box.__class__.__name__
  165. )
  166. box.close()
  167. def test_single_multiline_command(temp_dir):
  168. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  169. config, 'workspace_mount_path', new=temp_dir
  170. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  171. config.sandbox, 'box_type', new='ssh'
  172. ):
  173. box = DockerSSHBox()
  174. exit_code, output = box.execute('echo \\\n -e "foo"')
  175. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  176. # FIXME: why is there a `>` in the output? Probably PS2?
  177. assert output == '> foo', (
  178. 'The output should be the same as the input for ' + box.__class__.__name__
  179. )
  180. box.close()
  181. def test_multiline_echo(temp_dir):
  182. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  183. config, 'workspace_mount_path', new=temp_dir
  184. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  185. config.sandbox, 'box_type', new='ssh'
  186. ):
  187. box = DockerSSHBox()
  188. exit_code, output = box.execute('echo -e "hello\nworld"')
  189. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  190. # FIXME: why is there a `>` in the output?
  191. assert output == '> hello\r\nworld', (
  192. 'The output should be the same as the input for ' + box.__class__.__name__
  193. )
  194. box.close()
  195. def test_sandbox_whitespace(temp_dir):
  196. # get a temporary directory
  197. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  198. config, 'workspace_mount_path', new=temp_dir
  199. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  200. config.sandbox, 'box_type', new='ssh'
  201. ):
  202. box = DockerSSHBox()
  203. exit_code, output = box.execute('echo -e "\\n\\n\\n"')
  204. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  205. assert output == '\r\n\r\n\r\n', (
  206. 'The output should be the same as the input for ' + box.__class__.__name__
  207. )
  208. box.close()
  209. def test_sandbox_jupyter_plugin(temp_dir):
  210. # get a temporary directory
  211. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  212. config, 'workspace_mount_path', new=temp_dir
  213. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  214. config.sandbox, 'box_type', new='ssh'
  215. ):
  216. box = DockerSSHBox()
  217. box.init_plugins([JupyterRequirement])
  218. exit_code, output = box.execute('echo "print(1)" | execute_cli')
  219. print(output)
  220. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  221. assert output == '1\r\n', (
  222. 'The output should be the same as the input for ' + box.__class__.__name__
  223. )
  224. box.close()
  225. def _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box):
  226. box.init_plugins([AgentSkillsRequirement, JupyterRequirement])
  227. exit_code, output = box.execute('mkdir test')
  228. print(output)
  229. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  230. exit_code, output = box.execute('echo "create_file(\'hello.py\')" | execute_cli')
  231. print(output)
  232. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  233. assert output.strip().split('\r\n') == (
  234. '[File: /workspace/hello.py (1 lines total)]\r\n'
  235. '(this is the beginning of the file)\r\n'
  236. '1|\r\n'
  237. '(this is the end of the file)\r\n'
  238. '[File hello.py created.]\r\n'
  239. ).strip().split('\r\n')
  240. exit_code, output = box.execute('cd test')
  241. print(output)
  242. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  243. exit_code, output = box.execute('echo "create_file(\'hello.py\')" | execute_cli')
  244. print(output)
  245. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  246. assert output.strip().split('\r\n') == (
  247. '[File: /workspace/test/hello.py (1 lines total)]\r\n'
  248. '(this is the beginning of the file)\r\n'
  249. '1|\r\n'
  250. '(this is the end of the file)\r\n'
  251. '[File hello.py created.]\r\n'
  252. ).strip().split('\r\n')
  253. if config.enable_auto_lint:
  254. # edit file, but make a mistake in indentation
  255. exit_code, output = box.execute(
  256. 'echo "insert_content_at_line(\'hello.py\', 1, \' print(\\"hello world\\")\')" | execute_cli'
  257. )
  258. print(output)
  259. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  260. assert output.strip().split('\r\n') == (
  261. """
  262. [Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]
  263. ERRORS:
  264. hello.py:1:3: E999 IndentationError: unexpected indent
  265. [This is how your edit would have looked if applied]
  266. -------------------------------------------------
  267. (this is the beginning of the file)
  268. 1| print("hello world")
  269. (this is the end of the file)
  270. -------------------------------------------------
  271. [This is the original code before your edit]
  272. -------------------------------------------------
  273. (this is the beginning of the file)
  274. 1|
  275. (this is the end of the file)
  276. -------------------------------------------------
  277. Your changes have NOT been applied. Please fix your edit command and try again.
  278. You either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.
  279. DO NOT re-run the same failed edit command. Running it again will lead to the same error.
  280. """
  281. ).strip().split('\n')
  282. # edit file with correct indentation
  283. exit_code, output = box.execute(
  284. 'echo "insert_content_at_line(\'hello.py\', 1, \'print(\\"hello world\\")\')" | execute_cli'
  285. )
  286. print(output)
  287. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  288. assert output.strip().split('\r\n') == (
  289. """
  290. [File: /workspace/test/hello.py (1 lines total after edit)]
  291. (this is the beginning of the file)
  292. 1|print("hello world")
  293. (this is the end of the file)
  294. [File updated (edited at line 1). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]
  295. """
  296. ).strip().split('\n')
  297. exit_code, output = box.execute('rm -rf /workspace/*')
  298. assert exit_code == 0, 'The exit code should be 0 for ' + box.__class__.__name__
  299. box.close()
  300. def test_sandbox_jupyter_agentskills_fileop_pwd(temp_dir):
  301. # get a temporary directory
  302. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  303. config, 'workspace_mount_path', new=temp_dir
  304. ), patch.object(config, 'run_as_devin', new='true'), patch.object(
  305. config.sandbox, 'box_type', new='ssh'
  306. ), patch.object(config, 'enable_auto_lint', new=True):
  307. assert config.enable_auto_lint
  308. box = DockerSSHBox()
  309. _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box)
  310. @pytest.mark.skipif(
  311. os.getenv('TEST_IN_CI') != 'true',
  312. reason='The unittest need to download image, so only run on CI',
  313. )
  314. def test_agnostic_sandbox_jupyter_agentskills_fileop_pwd(temp_dir):
  315. for base_sandbox_image in ['ubuntu:22.04', 'debian:11']:
  316. # get a temporary directory
  317. with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
  318. config, 'workspace_mount_path', new=temp_dir
  319. ), patch.object(config, 'run_as_devin', new=True), patch.object(
  320. config.sandbox, 'box_type', new='ssh'
  321. ), patch.object(
  322. config.sandbox, 'container_image', new=base_sandbox_image
  323. ), patch.object(config, 'enable_auto_lint', new=False):
  324. assert not config.enable_auto_lint
  325. box = DockerSSHBox()
  326. _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box)