ssh_box.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  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. if not self.run_as_devin:
  190. raise Exception(
  191. 'Persistent sandbox is currently designed for opendevin user only. Please set run_as_devin=True in your config.toml'
  192. )
  193. self.instance_id = 'persisted'
  194. else:
  195. self.instance_id = (sid or '') + str(uuid.uuid4())
  196. self.timeout = timeout
  197. self.container_image = container_image or config.sandbox_container_image
  198. self.container_name = self.container_name_prefix + self.instance_id
  199. # set up random user password
  200. if config.persist_sandbox:
  201. if not config.ssh_password:
  202. raise Exception(
  203. 'Please add ssh_password to your config.toml or add -e SSH_PASSWORD to your docker run command'
  204. )
  205. self._ssh_password = config.ssh_password
  206. self._ssh_port = config.ssh_port
  207. else:
  208. self._ssh_password = str(uuid.uuid4())
  209. self._ssh_port = find_available_tcp_port()
  210. try:
  211. docker.DockerClient().containers.get(self.container_name)
  212. self.is_initial_session = False
  213. except docker.errors.NotFound:
  214. self.is_initial_session = True
  215. logger.info('Creating new Docker container')
  216. if not config.persist_sandbox or self.is_initial_session:
  217. n_tries = 5
  218. while n_tries > 0:
  219. try:
  220. self.restart_docker_container()
  221. break
  222. except Exception as e:
  223. logger.exception(
  224. 'Failed to start Docker container, retrying...', exc_info=False
  225. )
  226. n_tries -= 1
  227. if n_tries == 0:
  228. raise e
  229. time.sleep(5)
  230. self.setup_user()
  231. else:
  232. self.container = self.docker_client.containers.get(self.container_name)
  233. logger.info('Using existing Docker container')
  234. try:
  235. self.start_ssh_session()
  236. except pxssh.ExceptionPxssh as e:
  237. self.close()
  238. raise e
  239. # make sure /tmp always exists
  240. self.execute('mkdir -p /tmp')
  241. # set git config
  242. self.execute('git config --global user.name "OpenDevin"')
  243. self.execute('git config --global user.email "opendevin@opendevin.ai"')
  244. atexit.register(self.close)
  245. super().__init__()
  246. def add_to_env(self, key: str, value: str):
  247. super().add_to_env(key, value)
  248. def setup_user(self):
  249. # Make users sudoers passwordless
  250. # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image
  251. exit_code, logs = self.container.exec_run(
  252. ['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"],
  253. workdir=self.sandbox_workspace_dir,
  254. environment=self._env,
  255. )
  256. if exit_code != 0:
  257. raise Exception(
  258. f'Failed to make all users passwordless sudoers in sandbox: {logs}'
  259. )
  260. # Check if the opendevin user exists
  261. exit_code, logs = self.container.exec_run(
  262. ['/bin/bash', '-c', 'id -u opendevin'],
  263. workdir=self.sandbox_workspace_dir,
  264. environment=self._env,
  265. )
  266. if exit_code == 0:
  267. # User exists, delete it
  268. exit_code, logs = self.container.exec_run(
  269. ['/bin/bash', '-c', 'userdel -r opendevin'],
  270. workdir=self.sandbox_workspace_dir,
  271. environment=self._env,
  272. )
  273. if exit_code != 0:
  274. raise Exception(f'Failed to remove opendevin user in sandbox: {logs}')
  275. if self.run_as_devin:
  276. # Create the opendevin user
  277. exit_code, logs = self.container.exec_run(
  278. [
  279. '/bin/bash',
  280. '-c',
  281. f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.user_id} opendevin',
  282. ],
  283. workdir=self.sandbox_workspace_dir,
  284. environment=self._env,
  285. )
  286. if exit_code != 0:
  287. raise Exception(f'Failed to create opendevin user in sandbox: {logs}')
  288. exit_code, logs = self.container.exec_run(
  289. [
  290. '/bin/bash',
  291. '-c',
  292. f"echo 'opendevin:{self._ssh_password}' | chpasswd",
  293. ],
  294. workdir=self.sandbox_workspace_dir,
  295. environment=self._env,
  296. )
  297. if exit_code != 0:
  298. raise Exception(f'Failed to set password in sandbox: {logs}')
  299. # chown the home directory
  300. exit_code, logs = self.container.exec_run(
  301. ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'],
  302. workdir=self.sandbox_workspace_dir,
  303. environment=self._env,
  304. )
  305. if exit_code != 0:
  306. raise Exception(
  307. f'Failed to chown home directory for opendevin in sandbox: {logs}'
  308. )
  309. exit_code, logs = self.container.exec_run(
  310. [
  311. '/bin/bash',
  312. '-c',
  313. f'chown opendevin:root {self.sandbox_workspace_dir}',
  314. ],
  315. workdir=self.sandbox_workspace_dir,
  316. environment=self._env,
  317. )
  318. if exit_code != 0:
  319. # This is not a fatal error, just a warning
  320. logger.warning(
  321. 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.'
  322. )
  323. else:
  324. exit_code, logs = self.container.exec_run(
  325. # change password for root
  326. ['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"],
  327. workdir=self.sandbox_workspace_dir,
  328. environment=self._env,
  329. )
  330. if exit_code != 0:
  331. raise Exception(f'Failed to set password for root in sandbox: {logs}')
  332. exit_code, logs = self.container.exec_run(
  333. ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
  334. workdir=self.sandbox_workspace_dir,
  335. environment=self._env,
  336. )
  337. # Use the retry decorator, with a maximum of 5 attempts and a fixed wait time of 5 seconds between attempts
  338. @retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
  339. def __ssh_login(self):
  340. try:
  341. self.ssh = pxssh.pxssh(
  342. echo=False,
  343. timeout=self.timeout,
  344. encoding='utf-8',
  345. codec_errors='replace',
  346. )
  347. hostname = self.ssh_hostname
  348. username = 'opendevin' if self.run_as_devin else 'root'
  349. if config.persist_sandbox:
  350. password_msg = 'using your SSH password'
  351. else:
  352. password_msg = f"using the password '{self._ssh_password}'"
  353. logger.info('Connecting to SSH session...')
  354. ssh_cmd = f'`ssh -v -p {self._ssh_port} {username}@{hostname}`'
  355. logger.info(
  356. f'You can debug the SSH connection by running: {ssh_cmd} {password_msg}'
  357. )
  358. self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port)
  359. logger.info('Connected to SSH session')
  360. except pxssh.ExceptionPxssh as e:
  361. logger.exception(
  362. 'Failed to login to SSH session, retrying...', exc_info=False
  363. )
  364. raise e
  365. def start_ssh_session(self):
  366. self.__ssh_login()
  367. # Fix: https://github.com/pexpect/pexpect/issues/669
  368. self.ssh.sendline("bind 'set enable-bracketed-paste off'")
  369. self.ssh.prompt()
  370. # cd to workspace
  371. self.ssh.sendline(f'cd {self.sandbox_workspace_dir}')
  372. self.ssh.prompt()
  373. def get_exec_cmd(self, cmd: str) -> list[str]:
  374. if self.run_as_devin:
  375. return ['su', 'opendevin', '-c', cmd]
  376. else:
  377. return ['/bin/bash', '-c', cmd]
  378. def read_logs(self, id) -> str:
  379. if id not in self.background_commands:
  380. raise SandboxInvalidBackgroundCommandError()
  381. bg_cmd = self.background_commands[id]
  382. return bg_cmd.read_logs()
  383. def _send_interrupt(
  384. self,
  385. cmd: str,
  386. prev_output: str = '',
  387. ignore_last_output: bool = False,
  388. ) -> tuple[int, str]:
  389. logger.exception(
  390. f'Command "{cmd}" timed out, killing process...', exc_info=False
  391. )
  392. # send a SIGINT to the process
  393. self.ssh.sendintr()
  394. self.ssh.prompt()
  395. command_output = prev_output
  396. if not ignore_last_output:
  397. command_output += '\n' + self.ssh.before
  398. return (
  399. -1,
  400. f'Command: "{cmd}" timed out. Sent SIGINT to the process: {command_output}',
  401. )
  402. def execute(
  403. self, cmd: str, stream: bool = False, timeout: int | None = None
  404. ) -> tuple[int, str | CancellableStream]:
  405. timeout = timeout or self.timeout
  406. commands = split_bash_commands(cmd)
  407. if len(commands) > 1:
  408. all_output = ''
  409. for command in commands:
  410. exit_code, output = self.execute(command)
  411. if all_output:
  412. all_output += '\r\n'
  413. all_output += str(output)
  414. if exit_code != 0:
  415. return exit_code, all_output
  416. return 0, all_output
  417. self.ssh.sendline(cmd)
  418. if stream:
  419. return 0, SSHExecCancellableStream(self.ssh, cmd, self.timeout)
  420. success = self.ssh.prompt(timeout=timeout)
  421. if not success:
  422. return self._send_interrupt(cmd)
  423. command_output = self.ssh.before
  424. # once out, make sure that we have *every* output, we while loop until we get an empty output
  425. while True:
  426. logger.debug('WAITING FOR .prompt()')
  427. self.ssh.sendline('\n')
  428. timeout_not_reached = self.ssh.prompt(timeout=1)
  429. if not timeout_not_reached:
  430. logger.debug('TIMEOUT REACHED')
  431. break
  432. logger.debug('WAITING FOR .before')
  433. output = self.ssh.before
  434. logger.debug(
  435. f'WAITING FOR END OF command output ({bool(output)}): {output}'
  436. )
  437. if isinstance(output, str) and output.strip() == '':
  438. break
  439. command_output += output
  440. command_output = command_output.removesuffix('\r\n')
  441. # get the exit code
  442. self.ssh.sendline('echo $?')
  443. self.ssh.prompt()
  444. exit_code_str = self.ssh.before.strip()
  445. _start_time = time.time()
  446. while not exit_code_str:
  447. self.ssh.prompt(timeout=1)
  448. exit_code_str = self.ssh.before.strip()
  449. logger.debug(f'WAITING FOR exit code: {exit_code_str}')
  450. if time.time() - _start_time > timeout:
  451. return self._send_interrupt(
  452. cmd, command_output, ignore_last_output=True
  453. )
  454. cleaned_exit_code_str = exit_code_str.replace('echo $?', '').strip()
  455. try:
  456. exit_code = int(cleaned_exit_code_str)
  457. except ValueError:
  458. logger.error(f'Invalid exit code: {cleaned_exit_code_str}')
  459. # Handle the invalid exit code appropriately (e.g., raise an exception or set a default value)
  460. exit_code = -1 # or some other appropriate default value
  461. return exit_code, command_output
  462. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  463. # mkdir -p sandbox_dest if it doesn't exist
  464. exit_code, logs = self.container.exec_run(
  465. ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'],
  466. workdir=self.sandbox_workspace_dir,
  467. environment=self._env,
  468. )
  469. if exit_code != 0:
  470. raise Exception(
  471. f'Failed to create directory {sandbox_dest} in sandbox: {logs}'
  472. )
  473. # use temp directory to store the tar file to avoid
  474. # conflict of filename when running multi-processes
  475. with tempfile.TemporaryDirectory() as tmp_dir:
  476. if recursive:
  477. assert os.path.isdir(
  478. host_src
  479. ), 'Source must be a directory when recursive is True'
  480. files = glob(host_src + '/**/*', recursive=True)
  481. srcname = os.path.basename(host_src)
  482. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  483. with tarfile.open(tar_filename, mode='w') as tar:
  484. for file in files:
  485. tar.add(
  486. file,
  487. arcname=os.path.relpath(file, os.path.dirname(host_src)),
  488. )
  489. else:
  490. assert os.path.isfile(
  491. host_src
  492. ), 'Source must be a file when recursive is False'
  493. srcname = os.path.basename(host_src)
  494. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  495. with tarfile.open(tar_filename, mode='w') as tar:
  496. tar.add(host_src, arcname=srcname)
  497. with open(tar_filename, 'rb') as f:
  498. data = f.read()
  499. self.container.put_archive(os.path.dirname(sandbox_dest), data)
  500. def execute_in_background(self, cmd: str) -> Process:
  501. result = self.container.exec_run(
  502. self.get_exec_cmd(cmd),
  503. socket=True,
  504. workdir=self.sandbox_workspace_dir,
  505. environment=self._env,
  506. )
  507. result.output._sock.setblocking(0)
  508. pid = self.get_pid(cmd)
  509. bg_cmd = DockerProcess(self.cur_background_id, cmd, result, pid)
  510. self.background_commands[bg_cmd.pid] = bg_cmd
  511. self.cur_background_id += 1
  512. return bg_cmd
  513. def get_pid(self, cmd):
  514. exec_result = self.container.exec_run('ps aux', environment=self._env)
  515. processes = exec_result.output.decode('utf-8').splitlines()
  516. cmd = ' '.join(self.get_exec_cmd(cmd))
  517. for process in processes:
  518. if cmd in process:
  519. pid = process.split()[1] # second column is the pid
  520. return pid
  521. return None
  522. def kill_background(self, id: int) -> Process:
  523. if id not in self.background_commands:
  524. raise SandboxInvalidBackgroundCommandError()
  525. bg_cmd = self.background_commands[id]
  526. if bg_cmd.pid is not None:
  527. self.container.exec_run(
  528. f'kill -9 {bg_cmd.pid}',
  529. workdir=self.sandbox_workspace_dir,
  530. environment=self._env,
  531. )
  532. assert isinstance(bg_cmd, DockerProcess)
  533. bg_cmd.result.output.close()
  534. self.background_commands.pop(id)
  535. return bg_cmd
  536. def stop_docker_container(self):
  537. try:
  538. container = self.docker_client.containers.get(self.container_name)
  539. container.stop()
  540. container.remove()
  541. elapsed = 0
  542. while container.status != 'exited':
  543. time.sleep(1)
  544. elapsed += 1
  545. if elapsed > self.timeout:
  546. break
  547. container = self.docker_client.containers.get(self.container_name)
  548. except docker.errors.NotFound:
  549. pass
  550. def get_working_directory(self):
  551. exit_code, result = self.execute('pwd')
  552. if exit_code != 0:
  553. raise Exception('Failed to get working directory')
  554. return str(result).strip()
  555. @property
  556. def user_id(self):
  557. return config.sandbox_user_id
  558. @property
  559. def sandbox_user_id(self):
  560. return config.sandbox_user_id
  561. @property
  562. def run_as_devin(self):
  563. return config.run_as_devin
  564. @property
  565. def sandbox_workspace_dir(self):
  566. return config.workspace_mount_path_in_sandbox
  567. @property
  568. def ssh_hostname(self):
  569. return config.ssh_hostname
  570. @property
  571. def use_host_network(self):
  572. return config.use_host_network
  573. def is_container_running(self):
  574. try:
  575. container = self.docker_client.containers.get(self.container_name)
  576. if container.status == 'running':
  577. self.container = container
  578. return True
  579. return False
  580. except docker.errors.NotFound:
  581. return False
  582. @property
  583. def volumes(self):
  584. mount_dir = config.workspace_mount_path
  585. logger.info(f'Mounting workspace directory: {mount_dir}')
  586. return {
  587. mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'},
  588. # mount cache directory to /home/opendevin/.cache for pip cache reuse
  589. config.cache_dir: {
  590. 'bind': (
  591. '/home/opendevin/.cache' if self.run_as_devin else '/root/.cache'
  592. ),
  593. 'mode': 'rw',
  594. },
  595. }
  596. def restart_docker_container(self):
  597. try:
  598. self.stop_docker_container()
  599. logger.info('Container stopped')
  600. except docker.errors.DockerException as ex:
  601. logger.exception('Failed to stop container', exc_info=False)
  602. raise ex
  603. try:
  604. network_kwargs: dict[str, str | dict[str, int]] = {}
  605. if self.use_host_network:
  606. network_kwargs['network_mode'] = 'host'
  607. else:
  608. # FIXME: This is a temporary workaround for Mac OS
  609. network_kwargs['ports'] = {f'{self._ssh_port}/tcp': self._ssh_port}
  610. logger.warning(
  611. (
  612. 'Using port forwarding for Mac OS. '
  613. 'Server started by OpenDevin will not be accessible from the host machine at the moment. '
  614. 'See https://github.com/OpenDevin/OpenDevin/issues/897 for more information.'
  615. )
  616. )
  617. # start the container
  618. logger.info(f'Mounting volumes: {self.volumes}')
  619. self.container = self.docker_client.containers.run(
  620. self.container_image,
  621. # allow root login
  622. command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'",
  623. **network_kwargs,
  624. working_dir=self.sandbox_workspace_dir,
  625. name=self.container_name,
  626. detach=True,
  627. volumes=self.volumes,
  628. )
  629. logger.info('Container started')
  630. except Exception as ex:
  631. logger.exception('Failed to start container', exc_info=False)
  632. raise ex
  633. # wait for container to be ready
  634. elapsed = 0
  635. while self.container.status != 'running':
  636. if self.container.status == 'exited':
  637. logger.info('container exited')
  638. logger.info('container logs:')
  639. logger.info(self.container.logs())
  640. break
  641. time.sleep(1)
  642. elapsed += 1
  643. self.container = self.docker_client.containers.get(self.container_name)
  644. logger.info(
  645. f'waiting for container to start: {elapsed}, container status: {self.container.status}'
  646. )
  647. if elapsed > self.timeout:
  648. break
  649. if self.container.status != 'running':
  650. raise Exception('Failed to start container')
  651. # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
  652. def close(self):
  653. containers = self.docker_client.containers.list(all=True)
  654. for container in containers:
  655. try:
  656. if (
  657. container.name.startswith(self.container_name)
  658. and not config.persist_sandbox
  659. ):
  660. # only remove the container we created
  661. # otherwise all other containers with the same prefix will be removed
  662. # which will mess up with parallel evaluation
  663. container.remove(force=True)
  664. except docker.errors.NotFound:
  665. pass
  666. if __name__ == '__main__':
  667. try:
  668. ssh_box = DockerSSHBox()
  669. except Exception as e:
  670. logger.exception('Failed to start Docker container: %s', e)
  671. sys.exit(1)
  672. logger.info(
  673. "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit."
  674. )
  675. # Initialize required plugins
  676. ssh_box.init_plugins([AgentSkillsRequirement(), JupyterRequirement()])
  677. logger.info(
  678. '--- AgentSkills COMMAND DOCUMENTATION ---\n'
  679. f'{AgentSkillsRequirement().documentation}\n'
  680. '---'
  681. )
  682. bg_cmd = ssh_box.execute_in_background(
  683. "while true; do echo -n '.' && sleep 10; done"
  684. )
  685. sys.stdout.flush()
  686. try:
  687. while True:
  688. try:
  689. user_input = input('$ ')
  690. except EOFError:
  691. logger.info('Exiting...')
  692. break
  693. if user_input.lower() == 'exit':
  694. logger.info('Exiting...')
  695. break
  696. if user_input.lower() == 'kill':
  697. ssh_box.kill_background(bg_cmd.pid)
  698. logger.info('Background process killed')
  699. continue
  700. exit_code, output = ssh_box.execute(user_input)
  701. logger.info('exit code: %d', exit_code)
  702. logger.info(output)
  703. if bg_cmd.pid in ssh_box.background_commands:
  704. logs = ssh_box.read_logs(bg_cmd.pid)
  705. logger.info('background logs: %s', logs)
  706. sys.stdout.flush()
  707. except KeyboardInterrupt:
  708. logger.info('Exiting...')
  709. ssh_box.close()