ssh_box.py 27 KB

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