ssh_box.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import atexit
  2. import os
  3. import platform
  4. import sys
  5. import time
  6. import uuid
  7. from collections import namedtuple
  8. from typing import Dict, List, Tuple, Union
  9. import docker
  10. from pexpect import pxssh
  11. from opendevin import config
  12. from opendevin.logger import opendevin_logger as logger
  13. from opendevin.sandbox.sandbox import Sandbox, BackgroundCommand
  14. from opendevin.schema import ConfigType
  15. from opendevin.utils import find_available_tcp_port
  16. InputType = namedtuple('InputType', ['content'])
  17. OutputType = namedtuple('OutputType', ['content'])
  18. # helpful for docker-in-docker scenarios
  19. DIRECTORY_REWRITE = config.get(ConfigType.DIRECTORY_REWRITE)
  20. CONTAINER_IMAGE = config.get(ConfigType.SANDBOX_CONTAINER_IMAGE)
  21. # FIXME: On some containers, the devin user doesn't have enough permission, e.g. to install packages
  22. # How do we make this more flexible?
  23. RUN_AS_DEVIN = config.get('RUN_AS_DEVIN').lower() != 'false'
  24. USER_ID = 1000
  25. if SANDBOX_USER_ID := config.get('SANDBOX_USER_ID'):
  26. USER_ID = int(SANDBOX_USER_ID)
  27. elif hasattr(os, 'getuid'):
  28. USER_ID = os.getuid()
  29. class DockerSSHBox(Sandbox):
  30. instance_id: str
  31. container_image: str
  32. container_name_prefix = 'opendevin-sandbox-'
  33. container_name: str
  34. container: docker.models.containers.Container
  35. docker_client: docker.DockerClient
  36. _ssh_password: str
  37. _ssh_port: int
  38. cur_background_id = 0
  39. background_commands: Dict[int, BackgroundCommand] = {}
  40. def __init__(
  41. self,
  42. workspace_dir: str | None = None,
  43. container_image: str | None = None,
  44. timeout: int = 120,
  45. sid: str | None = None,
  46. ):
  47. # Initialize docker client. Throws an exception if Docker is not reachable.
  48. try:
  49. self.docker_client = docker.from_env()
  50. except Exception as ex:
  51. logger.exception(
  52. 'Please check Docker is running using `docker ps`.', exc_info=False)
  53. raise ex
  54. self.instance_id = sid if sid is not None else str(uuid.uuid4())
  55. if workspace_dir is not None:
  56. os.makedirs(workspace_dir, exist_ok=True)
  57. # expand to absolute path
  58. self.workspace_dir = os.path.abspath(workspace_dir)
  59. else:
  60. self.workspace_dir = os.getcwd()
  61. logger.info(
  62. 'workspace unspecified, using current directory: %s', workspace_dir)
  63. if DIRECTORY_REWRITE != '':
  64. parts = DIRECTORY_REWRITE.split(':')
  65. self.workspace_dir = self.workspace_dir.replace(parts[0], parts[1])
  66. logger.info('Rewriting workspace directory to: %s',
  67. self.workspace_dir)
  68. else:
  69. logger.info('Using workspace directory: %s', self.workspace_dir)
  70. # TODO: this timeout is actually essential - need a better way to set it
  71. # if it is too short, the container may still waiting for previous
  72. # command to finish (e.g. apt-get update)
  73. # if it is too long, the user may have to wait for a unnecessary long time
  74. self.timeout = timeout
  75. self.container_image = CONTAINER_IMAGE if container_image is None else container_image
  76. self.container_name = self.container_name_prefix + self.instance_id
  77. # set up random user password
  78. self._ssh_password = str(uuid.uuid4())
  79. self._ssh_port = find_available_tcp_port()
  80. # always restart the container, cuz the initial be regarded as a new session
  81. self.restart_docker_container()
  82. self.setup_user()
  83. self.start_ssh_session()
  84. atexit.register(self.close)
  85. def setup_user(self):
  86. # Make users sudoers passwordless
  87. # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image
  88. exit_code, logs = self.container.exec_run(
  89. ['/bin/bash', '-c',
  90. r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"],
  91. workdir='/workspace',
  92. )
  93. if exit_code != 0:
  94. raise Exception(
  95. f'Failed to make all users passwordless sudoers in sandbox: {logs}')
  96. # Check if the opendevin user exists
  97. exit_code, logs = self.container.exec_run(
  98. ['/bin/bash', '-c', 'id -u opendevin'],
  99. workdir='/workspace',
  100. )
  101. if exit_code == 0:
  102. # User exists, delete it
  103. exit_code, logs = self.container.exec_run(
  104. ['/bin/bash', '-c', 'userdel -r opendevin'],
  105. workdir='/workspace',
  106. )
  107. if exit_code != 0:
  108. raise Exception(
  109. f'Failed to remove opendevin user in sandbox: {logs}')
  110. # Create the opendevin user
  111. exit_code, logs = self.container.exec_run(
  112. ['/bin/bash', '-c',
  113. f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {USER_ID} opendevin'],
  114. workdir='/workspace',
  115. )
  116. if exit_code != 0:
  117. raise Exception(
  118. f'Failed to create opendevin user in sandbox: {logs}')
  119. exit_code, logs = self.container.exec_run(
  120. ['/bin/bash', '-c',
  121. f"echo 'opendevin:{self._ssh_password}' | chpasswd"],
  122. workdir='/workspace',
  123. )
  124. if exit_code != 0:
  125. raise Exception(f'Failed to set password in sandbox: {logs}')
  126. if not RUN_AS_DEVIN:
  127. exit_code, logs = self.container.exec_run(
  128. # change password for root
  129. ['/bin/bash', '-c',
  130. f"echo 'root:{self._ssh_password}' | chpasswd"],
  131. workdir='/workspace',
  132. )
  133. if exit_code != 0:
  134. raise Exception(
  135. f'Failed to set password for root in sandbox: {logs}')
  136. exit_code, logs = self.container.exec_run(
  137. ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
  138. workdir='/workspace',
  139. )
  140. def start_ssh_session(self):
  141. # start ssh session at the background
  142. self.ssh = pxssh.pxssh()
  143. hostname = 'localhost'
  144. if RUN_AS_DEVIN:
  145. username = 'opendevin'
  146. else:
  147. username = 'root'
  148. logger.info(
  149. # FIXME: mypy and autopep8 fight each other on this line
  150. # autopep8: off
  151. f"Connecting to {username}@{hostname} via ssh. If you encounter any issues, you can try `ssh -v -p 2222 {username}@{hostname}` with the password '{self._ssh_password}' and report the issue on GitHub."
  152. )
  153. self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port)
  154. # Fix: https://github.com/pexpect/pexpect/issues/669
  155. self.ssh.sendline("bind 'set enable-bracketed-paste off'")
  156. self.ssh.prompt()
  157. # cd to workspace
  158. self.ssh.sendline('cd /workspace')
  159. self.ssh.prompt()
  160. def get_exec_cmd(self, cmd: str) -> List[str]:
  161. if RUN_AS_DEVIN:
  162. return ['su', 'opendevin', '-c', cmd]
  163. else:
  164. return ['/bin/bash', '-c', cmd]
  165. def read_logs(self, id) -> str:
  166. if id not in self.background_commands:
  167. raise ValueError('Invalid background command id')
  168. bg_cmd = self.background_commands[id]
  169. return bg_cmd.read_logs()
  170. def execute(self, cmd: str) -> Tuple[int, str]:
  171. # use self.ssh
  172. self.ssh.sendline(cmd)
  173. success = self.ssh.prompt(timeout=self.timeout)
  174. if not success:
  175. logger.exception(
  176. 'Command timed out, killing process...', exc_info=False)
  177. # send a SIGINT to the process
  178. self.ssh.sendintr()
  179. self.ssh.prompt()
  180. command_output = self.ssh.before.decode(
  181. 'utf-8').lstrip(cmd).strip()
  182. return -1, f'Command: "{cmd}" timed out. Sending SIGINT to the process: {command_output}'
  183. command_output = self.ssh.before.decode('utf-8').lstrip(cmd).strip()
  184. # get the exit code
  185. self.ssh.sendline('echo $?')
  186. self.ssh.prompt()
  187. exit_code = self.ssh.before.decode('utf-8')
  188. # remove the echo $? itself
  189. exit_code = int(exit_code.lstrip('echo $?').strip())
  190. return exit_code, command_output
  191. def execute_in_background(self, cmd: str) -> BackgroundCommand:
  192. result = self.container.exec_run(
  193. self.get_exec_cmd(cmd), socket=True, workdir='/workspace'
  194. )
  195. result.output._sock.setblocking(0)
  196. pid = self.get_pid(cmd)
  197. bg_cmd = BackgroundCommand(self.cur_background_id, cmd, result, pid)
  198. self.background_commands[bg_cmd.id] = bg_cmd
  199. self.cur_background_id += 1
  200. return bg_cmd
  201. def get_pid(self, cmd):
  202. exec_result = self.container.exec_run('ps aux')
  203. processes = exec_result.output.decode('utf-8').splitlines()
  204. cmd = ' '.join(self.get_exec_cmd(cmd))
  205. for process in processes:
  206. if cmd in process:
  207. pid = process.split()[1] # second column is the pid
  208. return pid
  209. return None
  210. def kill_background(self, id: int) -> BackgroundCommand:
  211. if id not in self.background_commands:
  212. raise ValueError('Invalid background command id')
  213. bg_cmd = self.background_commands[id]
  214. if bg_cmd.pid is not None:
  215. self.container.exec_run(
  216. f'kill -9 {bg_cmd.pid}', workdir='/workspace')
  217. bg_cmd.result.output.close()
  218. self.background_commands.pop(id)
  219. return bg_cmd
  220. def stop_docker_container(self):
  221. try:
  222. container = self.docker_client.containers.get(self.container_name)
  223. container.stop()
  224. container.remove()
  225. elapsed = 0
  226. while container.status != 'exited':
  227. time.sleep(1)
  228. elapsed += 1
  229. if elapsed > self.timeout:
  230. break
  231. container = self.docker_client.containers.get(
  232. self.container_name)
  233. except docker.errors.NotFound:
  234. pass
  235. def is_container_running(self):
  236. try:
  237. container = self.docker_client.containers.get(self.container_name)
  238. if container.status == 'running':
  239. self.container = container
  240. return True
  241. return False
  242. except docker.errors.NotFound:
  243. return False
  244. def restart_docker_container(self):
  245. try:
  246. self.stop_docker_container()
  247. logger.info('Container stopped')
  248. except docker.errors.DockerException as ex:
  249. logger.exception('Failed to stop container', exc_info=False)
  250. raise ex
  251. try:
  252. network_kwargs: Dict[str, Union[str, Dict[str, int]]] = {}
  253. if platform.system() == 'Linux':
  254. network_kwargs['network_mode'] = 'host'
  255. elif platform.system() == 'Darwin':
  256. # FIXME: This is a temporary workaround for Mac OS
  257. network_kwargs['ports'] = {'2222/tcp': self._ssh_port}
  258. logger.warning(
  259. ('Using port forwarding for Mac OS. '
  260. 'Server started by OpenDevin will not be accessible from the host machine at the moment. '
  261. 'See https://github.com/OpenDevin/OpenDevin/issues/897 for more information.'
  262. )
  263. )
  264. # start the container
  265. self.container = self.docker_client.containers.run(
  266. self.container_image,
  267. # allow root login
  268. command="/usr/sbin/sshd -D -p 2222 -o 'PermitRootLogin=yes'",
  269. **network_kwargs,
  270. working_dir='/workspace',
  271. name=self.container_name,
  272. hostname='opendevin_sandbox',
  273. detach=True,
  274. volumes={self.workspace_dir: {
  275. 'bind': '/workspace', 'mode': 'rw'}},
  276. )
  277. logger.info('Container started')
  278. except Exception as ex:
  279. logger.exception('Failed to start container', exc_info=False)
  280. raise ex
  281. # wait for container to be ready
  282. elapsed = 0
  283. while self.container.status != 'running':
  284. if self.container.status == 'exited':
  285. logger.info('container exited')
  286. logger.info('container logs:')
  287. logger.info(self.container.logs())
  288. break
  289. time.sleep(1)
  290. elapsed += 1
  291. self.container = self.docker_client.containers.get(
  292. self.container_name)
  293. logger.info(
  294. f'waiting for container to start: {elapsed}, container status: {self.container.status}')
  295. if elapsed > self.timeout:
  296. break
  297. if self.container.status != 'running':
  298. raise Exception('Failed to start container')
  299. # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
  300. def close(self):
  301. containers = self.docker_client.containers.list(all=True)
  302. for container in containers:
  303. try:
  304. if container.name.startswith(self.container_name_prefix):
  305. container.remove(force=True)
  306. except docker.errors.NotFound:
  307. pass
  308. if __name__ == '__main__':
  309. import argparse
  310. parser = argparse.ArgumentParser(
  311. description='Interactive Docker container')
  312. parser.add_argument(
  313. '-d',
  314. '--directory',
  315. type=str,
  316. default=None,
  317. help='The directory to mount as the workspace in the Docker container.',
  318. )
  319. args = parser.parse_args()
  320. try:
  321. ssh_box = DockerSSHBox(
  322. workspace_dir=args.directory,
  323. )
  324. except Exception as e:
  325. logger.exception('Failed to start Docker container: %s', e)
  326. sys.exit(1)
  327. logger.info(
  328. "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit.")
  329. bg_cmd = ssh_box.execute_in_background(
  330. "while true; do echo 'dot ' && sleep 1; done"
  331. )
  332. sys.stdout.flush()
  333. try:
  334. while True:
  335. try:
  336. user_input = input('>>> ')
  337. except EOFError:
  338. logger.info('Exiting...')
  339. break
  340. if user_input.lower() == 'exit':
  341. logger.info('Exiting...')
  342. break
  343. if user_input.lower() == 'kill':
  344. ssh_box.kill_background(bg_cmd.id)
  345. logger.info('Background process killed')
  346. continue
  347. exit_code, output = ssh_box.execute(user_input)
  348. logger.info('exit code: %d', exit_code)
  349. logger.info(output)
  350. if bg_cmd.id in ssh_box.background_commands:
  351. logs = ssh_box.read_logs(bg_cmd.id)
  352. logger.info('background logs: %s', logs)
  353. sys.stdout.flush()
  354. except KeyboardInterrupt:
  355. logger.info('Exiting...')
  356. ssh_box.close()