ssh_box.py 27 KB

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