ssh_box.py 26 KB

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