ssh_box.py 28 KB

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