ssh_box.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. import atexit
  2. import os
  3. import re
  4. import sys
  5. import tarfile
  6. import tempfile
  7. import time
  8. import uuid
  9. from glob import glob
  10. import docker
  11. from pexpect import exceptions, pxssh
  12. from tenacity import retry, stop_after_attempt, wait_fixed
  13. from opendevin.core.config import config
  14. from opendevin.core.const.guide_url import TROUBLESHOOTING_URL
  15. from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError
  16. from opendevin.core.logger import opendevin_logger as logger
  17. from opendevin.core.schema import CancellableStream
  18. from opendevin.runtime.docker.image_agnostic_util import get_od_sandbox_image
  19. from opendevin.runtime.docker.process import DockerProcess, Process
  20. from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
  21. from opendevin.runtime.sandbox import Sandbox
  22. from opendevin.runtime.utils import find_available_tcp_port
  23. class SSHExecCancellableStream(CancellableStream):
  24. def __init__(self, ssh, cmd, timeout):
  25. super().__init__(self.read_output())
  26. self.ssh = ssh
  27. self.cmd = cmd
  28. self.timeout = timeout
  29. def close(self):
  30. self.closed = True
  31. def exit_code(self):
  32. marker = f'EXIT_CODE_MARKER_{uuid.uuid4().hex}'
  33. self.ssh.sendline(f'echo "{marker}$?{marker}"')
  34. if not self.ssh.prompt(timeout=self.timeout):
  35. return None # Timeout occurred
  36. output = self.ssh.before
  37. match = re.search(f'{marker}(\\d+){marker}', output)
  38. if match:
  39. try:
  40. return int(match.group(1))
  41. except ValueError:
  42. # Log the unexpected format
  43. logger.error(f'Unexpected exit code format: {match.group(1)}')
  44. return None
  45. else:
  46. # If we can't find our marked exit code, log the output and return None
  47. logger.error(f'Could not find exit code in output: {output}')
  48. return None
  49. def read_output(self):
  50. st = time.time()
  51. buf = ''
  52. crlf = '\r\n'
  53. lf = '\n'
  54. prompt_len = len(self.ssh.PROMPT)
  55. while True:
  56. try:
  57. if self.closed:
  58. break
  59. _output = self.ssh.read_nonblocking(timeout=1)
  60. if not _output:
  61. continue
  62. buf += _output
  63. if len(buf) < prompt_len:
  64. continue
  65. match = re.search(self.ssh.PROMPT, buf)
  66. if match:
  67. idx, _ = match.span()
  68. yield buf[:idx].replace(crlf, lf)
  69. buf = ''
  70. break
  71. res = buf[:-prompt_len]
  72. if len(res) == 0 or res.find(crlf) == -1:
  73. continue
  74. buf = buf[-prompt_len:]
  75. yield res.replace(crlf, lf)
  76. except exceptions.TIMEOUT:
  77. if time.time() - st < self.timeout:
  78. match = re.search(self.ssh.PROMPT, buf)
  79. if match:
  80. idx, _ = match.span()
  81. yield buf[:idx].replace(crlf, lf)
  82. break
  83. continue
  84. else:
  85. yield buf.replace(crlf, lf)
  86. break
  87. except exceptions.EOF:
  88. break
  89. def split_bash_commands(commands):
  90. # States
  91. NORMAL = 0
  92. IN_SINGLE_QUOTE = 1
  93. IN_DOUBLE_QUOTE = 2
  94. IN_HEREDOC = 3
  95. state = NORMAL
  96. heredoc_trigger = None
  97. result = []
  98. current_command: list[str] = []
  99. i = 0
  100. while i < len(commands):
  101. char = commands[i]
  102. if state == NORMAL:
  103. if char == "'":
  104. state = IN_SINGLE_QUOTE
  105. elif char == '"':
  106. state = IN_DOUBLE_QUOTE
  107. elif char == '\\':
  108. # Check if this is escaping a newline
  109. if i + 1 < len(commands) and commands[i + 1] == '\n':
  110. i += 1 # Skip the newline
  111. # Continue with the next line as part of the same command
  112. i += 1 # Move to the first character of the next line
  113. continue
  114. elif char == '\n':
  115. if not heredoc_trigger and current_command:
  116. result.append(''.join(current_command).strip())
  117. current_command = []
  118. elif char == '<' and commands[i : i + 2] == '<<':
  119. # Detect heredoc
  120. state = IN_HEREDOC
  121. i += 2 # Skip '<<'
  122. while commands[i] == ' ':
  123. i += 1
  124. start = i
  125. while commands[i] not in [' ', '\n']:
  126. i += 1
  127. heredoc_trigger = commands[start:i]
  128. current_command.append(commands[start - 2 : i]) # Include '<<'
  129. continue # Skip incrementing i at the end of the loop
  130. current_command.append(char)
  131. elif state == IN_SINGLE_QUOTE:
  132. current_command.append(char)
  133. if char == "'" and commands[i - 1] != '\\':
  134. state = NORMAL
  135. elif state == IN_DOUBLE_QUOTE:
  136. current_command.append(char)
  137. if char == '"' and commands[i - 1] != '\\':
  138. state = NORMAL
  139. elif state == IN_HEREDOC:
  140. current_command.append(char)
  141. if (
  142. char == '\n'
  143. and heredoc_trigger
  144. and commands[i + 1 : i + 1 + len(heredoc_trigger) + 1]
  145. == heredoc_trigger + '\n'
  146. ):
  147. # Check if the next line starts with the heredoc trigger followed by a newline
  148. i += (
  149. len(heredoc_trigger) + 1
  150. ) # Move past the heredoc trigger and newline
  151. current_command.append(
  152. heredoc_trigger + '\n'
  153. ) # Include the heredoc trigger and newline
  154. result.append(''.join(current_command).strip())
  155. current_command = []
  156. heredoc_trigger = None
  157. state = NORMAL
  158. continue
  159. i += 1
  160. # Add the last command if any
  161. if current_command:
  162. result.append(''.join(current_command).strip())
  163. # Remove any empty strings from the result
  164. result = [cmd for cmd in result if cmd]
  165. return result
  166. class DockerSSHBox(Sandbox):
  167. instance_id: str
  168. container_image: str
  169. container_name_prefix = 'opendevin-sandbox-'
  170. container_name: str
  171. container: docker.models.containers.Container
  172. docker_client: docker.DockerClient
  173. _ssh_password: str
  174. _ssh_port: int
  175. ssh: pxssh.pxssh
  176. cur_background_id = 0
  177. background_commands: dict[int, Process] = {}
  178. def __init__(
  179. self,
  180. container_image: str | None = None,
  181. timeout: int = config.sandbox.timeout,
  182. sid: str | None = None,
  183. ):
  184. logger.info(
  185. f'SSHBox is running as {"opendevin" if self.run_as_devin else "root"} user with USER_ID={self.user_id} in the sandbox'
  186. )
  187. # Initialize docker client. Throws an exception if Docker is not reachable.
  188. try:
  189. self.docker_client = docker.from_env()
  190. except Exception as ex:
  191. logger.exception(
  192. f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information.',
  193. exc_info=False,
  194. )
  195. raise ex
  196. if config.persist_sandbox:
  197. if not self.run_as_devin:
  198. raise Exception(
  199. 'Persistent sandbox is currently designed for opendevin user only. Please set run_as_devin=True in your config.toml'
  200. )
  201. self.instance_id = 'persisted'
  202. else:
  203. self.instance_id = (sid or '') + str(uuid.uuid4())
  204. self.timeout = timeout
  205. self.container_image = container_image or config.sandbox.container_image
  206. self.container_image = get_od_sandbox_image(
  207. self.container_image, self.docker_client
  208. )
  209. self.container_name = self.container_name_prefix + self.instance_id
  210. # set up random user password
  211. if config.persist_sandbox:
  212. if not config.ssh_password:
  213. raise Exception(
  214. 'Please add ssh_password to your config.toml or add -e SSH_PASSWORD to your docker run command'
  215. )
  216. self._ssh_password = config.ssh_password
  217. self._ssh_port = config.ssh_port
  218. else:
  219. self._ssh_password = str(uuid.uuid4())
  220. self._ssh_port = find_available_tcp_port()
  221. try:
  222. docker.DockerClient().containers.get(self.container_name)
  223. self.is_initial_session = False
  224. except docker.errors.NotFound:
  225. self.is_initial_session = True
  226. logger.info('Detected initial session.')
  227. if not config.persist_sandbox or self.is_initial_session:
  228. logger.info('Creating new Docker container')
  229. n_tries = 5
  230. while n_tries > 0:
  231. try:
  232. self.restart_docker_container()
  233. break
  234. except Exception as e:
  235. logger.exception(
  236. 'Failed to start Docker container, retrying...', exc_info=False
  237. )
  238. n_tries -= 1
  239. if n_tries == 0:
  240. raise e
  241. time.sleep(5)
  242. self.setup_user()
  243. else:
  244. self.container = self.docker_client.containers.get(self.container_name)
  245. logger.info('Using existing Docker container')
  246. self.start_docker_container()
  247. try:
  248. self.start_ssh_session()
  249. except Exception as e:
  250. self.close()
  251. raise e
  252. # make sure /tmp always exists
  253. self.execute('mkdir -p /tmp')
  254. # set git config
  255. self.execute('git config --global user.name "OpenDevin"')
  256. self.execute('git config --global user.email "opendevin@all-hands.dev"')
  257. atexit.register(self.close)
  258. super().__init__()
  259. def add_to_env(self, key: str, value: str):
  260. super().add_to_env(key, value)
  261. def setup_user(self):
  262. # Make users sudoers passwordless
  263. # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image
  264. exit_code, logs = self.container.exec_run(
  265. ['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"],
  266. workdir=self.sandbox_workspace_dir,
  267. environment=self._env,
  268. )
  269. if exit_code != 0:
  270. raise Exception(
  271. f'Failed to make all users passwordless sudoers in sandbox: {logs}'
  272. )
  273. # Check if the opendevin user exists
  274. exit_code, logs = self.container.exec_run(
  275. ['/bin/bash', '-c', 'id -u opendevin'],
  276. workdir=self.sandbox_workspace_dir,
  277. environment=self._env,
  278. )
  279. if exit_code == 0:
  280. # User exists, delete it
  281. exit_code, logs = self.container.exec_run(
  282. ['/bin/bash', '-c', 'userdel -r opendevin'],
  283. workdir=self.sandbox_workspace_dir,
  284. environment=self._env,
  285. )
  286. if exit_code != 0:
  287. raise Exception(f'Failed to remove opendevin user in sandbox: {logs}')
  288. if self.run_as_devin:
  289. # Create the opendevin user
  290. exit_code, logs = self.container.exec_run(
  291. [
  292. '/bin/bash',
  293. '-c',
  294. f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.user_id} opendevin',
  295. ],
  296. workdir=self.sandbox_workspace_dir,
  297. environment=self._env,
  298. )
  299. if exit_code != 0:
  300. raise Exception(f'Failed to create opendevin user in sandbox: {logs}')
  301. exit_code, logs = self.container.exec_run(
  302. [
  303. '/bin/bash',
  304. '-c',
  305. f"echo 'opendevin:{self._ssh_password}' | chpasswd",
  306. ],
  307. workdir=self.sandbox_workspace_dir,
  308. environment=self._env,
  309. )
  310. if exit_code != 0:
  311. raise Exception(f'Failed to set password in sandbox: {logs}')
  312. # chown the home directory
  313. exit_code, logs = self.container.exec_run(
  314. ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'],
  315. workdir=self.sandbox_workspace_dir,
  316. environment=self._env,
  317. )
  318. if exit_code != 0:
  319. raise Exception(
  320. f'Failed to chown home directory for opendevin in sandbox: {logs}'
  321. )
  322. # check the miniforge3 directory exist
  323. exit_code, logs = self.container.exec_run(
  324. [
  325. '/bin/bash',
  326. '-c',
  327. '[ -d "/opendevin/miniforge3" ] && exit 0 || exit 1',
  328. ],
  329. workdir=self.sandbox_workspace_dir,
  330. environment=self._env,
  331. )
  332. if exit_code != 0:
  333. if exit_code == 1:
  334. raise Exception(
  335. 'OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image: docker pull ghcr.io/opendevin/sandbox:main'
  336. )
  337. else:
  338. raise Exception(
  339. f'An error occurred while checking if miniforge3 directory exists: {logs}'
  340. )
  341. exit_code, logs = self.container.exec_run(
  342. [
  343. '/bin/bash',
  344. '-c',
  345. f'chown opendevin:root {self.sandbox_workspace_dir}',
  346. ],
  347. workdir=self.sandbox_workspace_dir,
  348. environment=self._env,
  349. )
  350. if exit_code != 0:
  351. # This is not a fatal error, just a warning
  352. logger.warning(
  353. f'Failed to chown workspace directory for opendevin in sandbox: {logs}. But this should be fine if the {self.sandbox_workspace_dir=} is mounted by the app docker container.'
  354. )
  355. else:
  356. exit_code, logs = self.container.exec_run(
  357. # change password for root
  358. ['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"],
  359. workdir=self.sandbox_workspace_dir,
  360. environment=self._env,
  361. )
  362. if exit_code != 0:
  363. raise Exception(f'Failed to set password for root in sandbox: {logs}')
  364. exit_code, logs = self.container.exec_run(
  365. ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
  366. workdir=self.sandbox_workspace_dir,
  367. environment=self._env,
  368. )
  369. # Use the retry decorator, with a maximum of 5 attempts and a fixed wait time of 5 seconds between attempts
  370. @retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
  371. def __ssh_login(self):
  372. try:
  373. self.ssh = pxssh.pxssh(
  374. echo=False,
  375. timeout=self.timeout,
  376. encoding='utf-8',
  377. codec_errors='replace',
  378. )
  379. hostname = self.ssh_hostname
  380. username = 'opendevin' if self.run_as_devin else 'root'
  381. if config.persist_sandbox:
  382. password_msg = 'using your SSH password'
  383. else:
  384. password_msg = f"using the password '{self._ssh_password}'"
  385. logger.info('Connecting to SSH session...')
  386. ssh_cmd = f'`ssh -v -p {self._ssh_port} {username}@{hostname}`'
  387. logger.info(
  388. f'You can debug the SSH connection by running: {ssh_cmd} {password_msg}'
  389. )
  390. self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port)
  391. logger.info('Connected to SSH session')
  392. except pxssh.ExceptionPxssh as e:
  393. logger.exception(
  394. 'Failed to login to SSH session, retrying...', exc_info=False
  395. )
  396. raise e
  397. def start_ssh_session(self):
  398. self.__ssh_login()
  399. # Fix: https://github.com/pexpect/pexpect/issues/669
  400. self.ssh.sendline("bind 'set enable-bracketed-paste off'")
  401. self.ssh.prompt()
  402. # cd to workspace
  403. self.ssh.sendline(f'cd {self.sandbox_workspace_dir}')
  404. self.ssh.prompt()
  405. def get_exec_cmd(self, cmd: str) -> list[str]:
  406. if self.run_as_devin:
  407. return ['su', 'opendevin', '-c', cmd]
  408. else:
  409. return ['/bin/bash', '-c', cmd]
  410. def read_logs(self, id) -> str:
  411. if id not in self.background_commands:
  412. raise SandboxInvalidBackgroundCommandError()
  413. bg_cmd = self.background_commands[id]
  414. return bg_cmd.read_logs()
  415. def _send_interrupt(
  416. self,
  417. cmd: str,
  418. prev_output: str = '',
  419. ignore_last_output: bool = False,
  420. ) -> tuple[int, str]:
  421. logger.exception(
  422. f'Command "{cmd}" timed out, killing process...', exc_info=False
  423. )
  424. # send a SIGINT to the process
  425. self.ssh.sendintr()
  426. self.ssh.prompt()
  427. command_output = prev_output
  428. if not ignore_last_output:
  429. command_output += '\n' + self.ssh.before
  430. return (
  431. -1,
  432. f'Command: "{cmd}" timed out. Sent SIGINT to the process: {command_output}',
  433. )
  434. def execute(
  435. self, cmd: str, stream: bool = False, timeout: int | None = None
  436. ) -> tuple[int, str | CancellableStream]:
  437. timeout = timeout or self.timeout
  438. commands = split_bash_commands(cmd)
  439. if len(commands) > 1:
  440. all_output = ''
  441. for command in commands:
  442. exit_code, output = self.execute(command)
  443. if all_output:
  444. all_output += '\r\n'
  445. all_output += str(output)
  446. if exit_code != 0:
  447. return exit_code, all_output
  448. return 0, all_output
  449. self.ssh.sendline(cmd)
  450. if stream:
  451. return 0, SSHExecCancellableStream(self.ssh, cmd, self.timeout)
  452. success = self.ssh.prompt(timeout=timeout)
  453. if not success:
  454. return self._send_interrupt(cmd)
  455. command_output = self.ssh.before
  456. # once out, make sure that we have *every* output, we while loop until we get an empty output
  457. while True:
  458. self.ssh.sendline('\n')
  459. timeout_not_reached = self.ssh.prompt(timeout=1)
  460. if not timeout_not_reached:
  461. logger.debug('TIMEOUT REACHED')
  462. break
  463. output = self.ssh.before
  464. if isinstance(output, str) and output.strip() == '':
  465. break
  466. command_output += output
  467. command_output = command_output.removesuffix('\r\n')
  468. # get the exit code
  469. self.ssh.sendline('echo $?')
  470. self.ssh.prompt()
  471. exit_code_str = self.ssh.before.strip()
  472. _start_time = time.time()
  473. while not exit_code_str:
  474. self.ssh.prompt(timeout=1)
  475. exit_code_str = self.ssh.before.strip()
  476. if time.time() - _start_time > timeout:
  477. return self._send_interrupt(
  478. cmd, command_output, ignore_last_output=True
  479. )
  480. cleaned_exit_code_str = exit_code_str.replace('echo $?', '').strip()
  481. try:
  482. exit_code = int(cleaned_exit_code_str)
  483. except ValueError:
  484. logger.error(f'Invalid exit code: {cleaned_exit_code_str}')
  485. # Handle the invalid exit code appropriately (e.g., raise an exception or set a default value)
  486. exit_code = -1 # or some other appropriate default value
  487. return exit_code, command_output
  488. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  489. # mkdir -p sandbox_dest if it doesn't exist
  490. exit_code, logs = self.container.exec_run(
  491. ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'],
  492. workdir=self.sandbox_workspace_dir,
  493. environment=self._env,
  494. )
  495. if exit_code != 0:
  496. raise Exception(
  497. f'Failed to create directory {sandbox_dest} in sandbox: {logs}'
  498. )
  499. # use temp directory to store the tar file to avoid
  500. # conflict of filename when running multi-processes
  501. with tempfile.TemporaryDirectory() as tmp_dir:
  502. if recursive:
  503. assert os.path.isdir(
  504. host_src
  505. ), 'Source must be a directory when recursive is True'
  506. files = glob(host_src + '/**/*', recursive=True)
  507. srcname = os.path.basename(host_src)
  508. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  509. with tarfile.open(tar_filename, mode='w') as tar:
  510. for file in files:
  511. tar.add(
  512. file,
  513. arcname=os.path.relpath(file, os.path.dirname(host_src)),
  514. )
  515. else:
  516. assert os.path.isfile(
  517. host_src
  518. ), 'Source must be a file when recursive is False'
  519. srcname = os.path.basename(host_src)
  520. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  521. with tarfile.open(tar_filename, mode='w') as tar:
  522. tar.add(host_src, arcname=srcname)
  523. with open(tar_filename, 'rb') as f:
  524. data = f.read()
  525. self.container.put_archive(os.path.dirname(sandbox_dest), data)
  526. def execute_in_background(self, cmd: str) -> Process:
  527. result = self.container.exec_run(
  528. self.get_exec_cmd(cmd),
  529. socket=True,
  530. workdir=self.sandbox_workspace_dir,
  531. environment=self._env,
  532. )
  533. result.output._sock.setblocking(0)
  534. pid = self.get_pid(cmd)
  535. bg_cmd = DockerProcess(self.cur_background_id, cmd, result, pid)
  536. self.background_commands[bg_cmd.pid] = bg_cmd
  537. self.cur_background_id += 1
  538. return bg_cmd
  539. def get_pid(self, cmd):
  540. exec_result = self.container.exec_run('ps aux', environment=self._env)
  541. processes = exec_result.output.decode('utf-8').splitlines()
  542. cmd = ' '.join(self.get_exec_cmd(cmd))
  543. for process in processes:
  544. if cmd in process:
  545. pid = process.split()[1] # second column is the pid
  546. return pid
  547. return None
  548. def kill_background(self, id: int) -> Process:
  549. if id not in self.background_commands:
  550. raise SandboxInvalidBackgroundCommandError()
  551. bg_cmd = self.background_commands[id]
  552. if bg_cmd.pid is not None:
  553. self.container.exec_run(
  554. f'kill -9 {bg_cmd.pid}',
  555. workdir=self.sandbox_workspace_dir,
  556. environment=self._env,
  557. )
  558. assert isinstance(bg_cmd, DockerProcess)
  559. bg_cmd.result.output.close()
  560. self.background_commands.pop(id)
  561. return bg_cmd
  562. def start_docker_container(self):
  563. try:
  564. container = self.docker_client.containers.get(self.container_name)
  565. logger.info('Container status: %s', container.status)
  566. if container.status != 'running':
  567. container.start()
  568. logger.info('Container started')
  569. elapsed = 0
  570. while container.status != 'running':
  571. time.sleep(1)
  572. elapsed += 1
  573. if elapsed > self.timeout:
  574. break
  575. container = self.docker_client.containers.get(self.container_name)
  576. except Exception:
  577. logger.exception('Failed to start container')
  578. def remove_docker_container(self):
  579. try:
  580. container = self.docker_client.containers.get(self.container_name)
  581. container.stop()
  582. logger.info('Container stopped')
  583. container.remove()
  584. logger.info('Container removed')
  585. elapsed = 0
  586. while container.status != 'exited':
  587. time.sleep(1)
  588. elapsed += 1
  589. if elapsed > self.timeout:
  590. break
  591. container = self.docker_client.containers.get(self.container_name)
  592. except docker.errors.NotFound:
  593. pass
  594. def get_working_directory(self):
  595. exit_code, result = self.execute('pwd')
  596. if exit_code != 0:
  597. raise Exception('Failed to get working directory')
  598. return str(result).strip()
  599. @property
  600. def user_id(self):
  601. return config.sandbox.user_id
  602. @property
  603. def run_as_devin(self):
  604. return config.run_as_devin
  605. @property
  606. def sandbox_workspace_dir(self):
  607. return config.workspace_mount_path_in_sandbox
  608. @property
  609. def ssh_hostname(self):
  610. return config.ssh_hostname
  611. @property
  612. def use_host_network(self):
  613. return config.use_host_network
  614. def is_container_running(self):
  615. try:
  616. container = self.docker_client.containers.get(self.container_name)
  617. if container.status == 'running':
  618. self.container = container
  619. return True
  620. return False
  621. except docker.errors.NotFound:
  622. return False
  623. @property
  624. def volumes(self):
  625. mount_dir = config.workspace_mount_path
  626. logger.info(f'Mounting workspace directory: {mount_dir}')
  627. return {
  628. mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'},
  629. # mount cache directory to /home/opendevin/.cache for pip cache reuse
  630. config.cache_dir: {
  631. 'bind': (
  632. '/home/opendevin/.cache' if self.run_as_devin else '/root/.cache'
  633. ),
  634. 'mode': 'rw',
  635. },
  636. }
  637. def restart_docker_container(self):
  638. try:
  639. self.remove_docker_container()
  640. except docker.errors.DockerException as ex:
  641. logger.exception('Failed to remove container', exc_info=False)
  642. raise ex
  643. try:
  644. network_kwargs: dict[str, str | dict[str, int]] = {}
  645. if self.use_host_network:
  646. network_kwargs['network_mode'] = 'host'
  647. else:
  648. # FIXME: This is a temporary workaround for Windows where host network mode has bugs.
  649. # FIXME: Docker Desktop for Mac OS has experimental support for host network mode
  650. network_kwargs['ports'] = {f'{self._ssh_port}/tcp': self._ssh_port}
  651. logger.warning(
  652. (
  653. 'Using port forwarding till the enable host network mode of Docker is out of experimental mode.'
  654. 'Check the 897th issue on https://github.com/OpenDevin/OpenDevin/issues/ for more information.'
  655. )
  656. )
  657. # start the container
  658. logger.info(f'Mounting volumes: {self.volumes}')
  659. self.container = self.docker_client.containers.run(
  660. self.container_image,
  661. # allow root login
  662. command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'",
  663. **network_kwargs,
  664. working_dir=self.sandbox_workspace_dir,
  665. name=self.container_name,
  666. detach=True,
  667. volumes=self.volumes,
  668. )
  669. logger.info('Container started')
  670. except Exception as ex:
  671. logger.exception('Failed to start container: ' + str(ex), exc_info=False)
  672. raise ex
  673. # wait for container to be ready
  674. elapsed = 0
  675. while self.container.status != 'running':
  676. if self.container.status == 'exited':
  677. logger.info('container exited')
  678. logger.info('container logs:')
  679. logger.info(self.container.logs())
  680. break
  681. time.sleep(1)
  682. elapsed += 1
  683. self.container = self.docker_client.containers.get(self.container_name)
  684. logger.info(
  685. f'waiting for container to start: {elapsed}, container status: {self.container.status}'
  686. )
  687. if elapsed > self.timeout:
  688. break
  689. if self.container.status != 'running':
  690. raise Exception('Failed to start container')
  691. # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
  692. def close(self):
  693. containers = self.docker_client.containers.list(all=True)
  694. for container in containers:
  695. try:
  696. if container.name.startswith(self.container_name):
  697. if config.persist_sandbox:
  698. container.stop()
  699. else:
  700. # only remove the container we created
  701. # otherwise all other containers with the same prefix will be removed
  702. # which will mess up with parallel evaluation
  703. container.remove(force=True)
  704. except docker.errors.NotFound:
  705. pass
  706. self.docker_client.close()
  707. if __name__ == '__main__':
  708. try:
  709. ssh_box = DockerSSHBox()
  710. except Exception as e:
  711. logger.exception('Failed to start Docker container: %s', e)
  712. sys.exit(1)
  713. logger.info(
  714. "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit."
  715. )
  716. # Initialize required plugins
  717. plugins = [AgentSkillsRequirement(), JupyterRequirement()]
  718. ssh_box.init_plugins(plugins)
  719. logger.info(
  720. '--- AgentSkills COMMAND DOCUMENTATION ---\n'
  721. f'{AgentSkillsRequirement().documentation}\n'
  722. '---'
  723. )
  724. bg_cmd = ssh_box.execute_in_background(
  725. "while true; do echo -n '.' && sleep 10; done"
  726. )
  727. sys.stdout.flush()
  728. try:
  729. while True:
  730. try:
  731. user_input = input('$ ')
  732. except EOFError:
  733. logger.info('Exiting...')
  734. break
  735. if user_input.lower() == 'exit':
  736. logger.info('Exiting...')
  737. break
  738. if user_input.lower() == 'kill':
  739. ssh_box.kill_background(bg_cmd.pid)
  740. logger.info('Background process killed')
  741. continue
  742. exit_code, output = ssh_box.execute(user_input)
  743. logger.info('exit code: %d', exit_code)
  744. logger.info(output)
  745. if bg_cmd.pid in ssh_box.background_commands:
  746. logs = ssh_box.read_logs(bg_cmd.pid)
  747. logger.info('background logs: %s', logs)
  748. sys.stdout.flush()
  749. except KeyboardInterrupt:
  750. logger.info('Exiting...')
  751. ssh_box.close()