ssh_box.py 29 KB

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