ssh_box.py 28 KB

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