ssh_box.py 27 KB

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