ssh_box.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  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. # Make users sudoers passwordless
  193. # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image
  194. exit_code, logs = self.container.exec_run(
  195. ['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"],
  196. workdir=self.sandbox_workspace_dir,
  197. environment=self._env,
  198. )
  199. if exit_code != 0:
  200. raise Exception(
  201. f'Failed to make all users passwordless sudoers in sandbox: {logs}'
  202. )
  203. # Check if the opendevin user exists
  204. exit_code, logs = self.container.exec_run(
  205. ['/bin/bash', '-c', 'id -u opendevin'],
  206. workdir=self.sandbox_workspace_dir,
  207. environment=self._env,
  208. )
  209. if exit_code == 0:
  210. # User exists, delete it
  211. exit_code, logs = self.container.exec_run(
  212. ['/bin/bash', '-c', 'userdel -r opendevin'],
  213. workdir=self.sandbox_workspace_dir,
  214. environment=self._env,
  215. )
  216. if exit_code != 0:
  217. raise Exception(f'Failed to remove opendevin user in sandbox: {logs}')
  218. if self.run_as_devin:
  219. # Create the opendevin user
  220. exit_code, logs = self.container.exec_run(
  221. [
  222. '/bin/bash',
  223. '-c',
  224. f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.config.user_id} opendevin',
  225. ],
  226. workdir=self.sandbox_workspace_dir,
  227. environment=self._env,
  228. )
  229. if exit_code != 0:
  230. raise Exception(f'Failed to create opendevin user in sandbox: {logs}')
  231. exit_code, logs = self.container.exec_run(
  232. [
  233. '/bin/bash',
  234. '-c',
  235. f"echo 'opendevin:{self._ssh_password}' | chpasswd",
  236. ],
  237. workdir=self.sandbox_workspace_dir,
  238. environment=self._env,
  239. )
  240. if exit_code != 0:
  241. raise Exception(f'Failed to set password in sandbox: {logs}')
  242. # chown the home directory
  243. exit_code, logs = self.container.exec_run(
  244. ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'],
  245. workdir=self.sandbox_workspace_dir,
  246. environment=self._env,
  247. )
  248. if exit_code != 0:
  249. raise Exception(
  250. f'Failed to chown home directory for opendevin in sandbox: {logs}'
  251. )
  252. # check the miniforge3 directory exist
  253. exit_code, logs = self.container.exec_run(
  254. [
  255. '/bin/bash',
  256. '-c',
  257. '[ -d "/opendevin/miniforge3" ] && exit 0 || exit 1',
  258. ],
  259. workdir=self.sandbox_workspace_dir,
  260. environment=self._env,
  261. )
  262. if exit_code != 0:
  263. if exit_code == 1:
  264. raise Exception(
  265. 'OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image: docker pull ghcr.io/opendevin/sandbox:main'
  266. )
  267. else:
  268. raise Exception(
  269. f'An error occurred while checking if miniforge3 directory exists: {logs}'
  270. )
  271. exit_code, logs = self.container.exec_run(
  272. [
  273. '/bin/bash',
  274. '-c',
  275. f'chown opendevin:root {self.sandbox_workspace_dir}',
  276. ],
  277. workdir=self.sandbox_workspace_dir,
  278. environment=self._env,
  279. )
  280. if exit_code != 0:
  281. # This is not a fatal error, just a warning
  282. logger.warning(
  283. 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.'
  284. )
  285. else:
  286. exit_code, logs = self.container.exec_run(
  287. # change password for root
  288. ['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"],
  289. workdir=self.sandbox_workspace_dir,
  290. environment=self._env,
  291. )
  292. if exit_code != 0:
  293. raise Exception(f'Failed to set password for root in sandbox: {logs}')
  294. exit_code, logs = self.container.exec_run(
  295. ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
  296. workdir=self.sandbox_workspace_dir,
  297. environment=self._env,
  298. )
  299. # Use the retry decorator, with a maximum of 5 attempts and a fixed wait time of 5 seconds between attempts
  300. @retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
  301. def __ssh_login(self):
  302. try:
  303. self.ssh = pxssh.pxssh(
  304. echo=False,
  305. timeout=self.config.timeout,
  306. encoding='utf-8',
  307. codec_errors='replace',
  308. )
  309. hostname = self.ssh_hostname
  310. username = 'opendevin' if self.run_as_devin else 'root'
  311. if self.persist_sandbox:
  312. password_msg = 'using your SSH password'
  313. else:
  314. password_msg = f"using the password '{self._ssh_password}'"
  315. logger.info('Connecting to SSH session...')
  316. hostname_to_log = hostname.replace('host.docker.internal', 'localhost')
  317. ssh_cmd = f'`ssh -v -p {self._ssh_port} {username}@{hostname_to_log}`'
  318. logger.info(
  319. f'You can debug the SSH connection by running: {ssh_cmd} {password_msg}'
  320. )
  321. self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port)
  322. logger.info('Connected to SSH session')
  323. except pxssh.ExceptionPxssh as e:
  324. logger.exception(
  325. 'Failed to login to SSH session, retrying...', exc_info=False
  326. )
  327. raise e
  328. def start_ssh_session(self):
  329. time.sleep(1)
  330. self.__ssh_login()
  331. assert self.ssh is not None
  332. # Fix: https://github.com/pexpect/pexpect/issues/669
  333. self.ssh.sendline("bind 'set enable-bracketed-paste off'")
  334. self.ssh.prompt()
  335. time.sleep(1)
  336. # cd to workspace
  337. self.ssh.sendline(f'cd {self.sandbox_workspace_dir}')
  338. self.ssh.prompt()
  339. def get_exec_cmd(self, cmd: str) -> list[str]:
  340. if self.run_as_devin:
  341. return ['su', 'opendevin', '-c', cmd]
  342. else:
  343. return ['/bin/bash', '-c', cmd]
  344. def _send_interrupt(
  345. self,
  346. cmd: str,
  347. prev_output: str = '',
  348. ignore_last_output: bool = False,
  349. ) -> tuple[int, str]:
  350. assert self.ssh is not None
  351. logger.exception(
  352. f'Command "{cmd}" timed out, killing process...', exc_info=False
  353. )
  354. # send a SIGINT to the process
  355. self.ssh.sendintr()
  356. self.ssh.prompt()
  357. command_output = prev_output
  358. if not ignore_last_output:
  359. command_output += '\n' + self.ssh.before
  360. return (
  361. -1,
  362. f'Command: "{cmd}" timed out. Sent SIGINT to the process: {command_output}',
  363. )
  364. def execute(
  365. self, cmd: str, stream: bool = False, timeout: int | None = None
  366. ) -> tuple[int, str | CancellableStream]:
  367. assert self.ssh is not None
  368. timeout = timeout or self.config.timeout
  369. commands = split_bash_commands(cmd)
  370. if len(commands) > 1:
  371. all_output = ''
  372. for command in commands:
  373. exit_code, output = self.execute(command)
  374. if all_output:
  375. all_output += '\r\n'
  376. all_output += str(output)
  377. if exit_code != 0:
  378. return exit_code, all_output
  379. return 0, all_output
  380. self.ssh.sendline(cmd)
  381. if stream:
  382. return 0, SSHExecCancellableStream(self.ssh, cmd, self.config.timeout)
  383. success = self.ssh.prompt(timeout=timeout)
  384. if not success:
  385. return self._send_interrupt(cmd)
  386. command_output = self.ssh.before
  387. # once out, make sure that we have *every* output, we while loop until we get an empty output
  388. while True:
  389. self.ssh.sendline('\n')
  390. timeout_not_reached = self.ssh.prompt(timeout=1)
  391. if not timeout_not_reached:
  392. logger.debug('TIMEOUT REACHED')
  393. break
  394. output = self.ssh.before
  395. if isinstance(output, str) and output.strip() == '':
  396. break
  397. command_output += output
  398. command_output = command_output.removesuffix('\r\n')
  399. # get the exit code
  400. self.ssh.sendline('echo $?')
  401. self.ssh.prompt()
  402. exit_code_str = self.ssh.before.strip()
  403. _start_time = time.time()
  404. while not exit_code_str:
  405. self.ssh.prompt(timeout=1)
  406. exit_code_str = self.ssh.before.strip()
  407. if time.time() - _start_time > timeout:
  408. return self._send_interrupt(
  409. cmd, command_output, ignore_last_output=True
  410. )
  411. cleaned_exit_code_str = exit_code_str.replace('echo $?', '').strip()
  412. try:
  413. exit_code = int(cleaned_exit_code_str)
  414. except ValueError:
  415. logger.error(f'Invalid exit code: {cleaned_exit_code_str}')
  416. # Handle the invalid exit code appropriately (e.g., raise an exception or set a default value)
  417. exit_code = -1 # or some other appropriate default value
  418. return exit_code, command_output
  419. def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
  420. # mkdir -p sandbox_dest if it doesn't exist
  421. exit_code, logs = self.container.exec_run(
  422. ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'],
  423. workdir=self.sandbox_workspace_dir,
  424. environment=self._env,
  425. )
  426. if exit_code != 0:
  427. raise Exception(
  428. f'Failed to create directory {sandbox_dest} in sandbox: {logs}'
  429. )
  430. # use temp directory to store the tar file to avoid
  431. # conflict of filename when running multi-processes
  432. with tempfile.TemporaryDirectory() as tmp_dir:
  433. if recursive:
  434. assert os.path.isdir(
  435. host_src
  436. ), 'Source must be a directory when recursive is True'
  437. files = glob(host_src + '/**/*', recursive=True)
  438. srcname = os.path.basename(host_src)
  439. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  440. with tarfile.open(tar_filename, mode='w') as tar:
  441. for file in files:
  442. tar.add(
  443. file,
  444. arcname=os.path.relpath(file, os.path.dirname(host_src)),
  445. )
  446. else:
  447. assert os.path.isfile(
  448. host_src
  449. ), 'Source must be a file when recursive is False'
  450. srcname = os.path.basename(host_src)
  451. tar_filename = os.path.join(tmp_dir, srcname + '.tar')
  452. with tarfile.open(tar_filename, mode='w') as tar:
  453. tar.add(host_src, arcname=srcname)
  454. with open(tar_filename, 'rb') as f:
  455. data = f.read()
  456. self.container.put_archive(os.path.dirname(sandbox_dest), data)
  457. def start_docker_container(self):
  458. try:
  459. container = self.docker_client.containers.get(self.container_name)
  460. logger.info('Container status: %s', container.status)
  461. if container.status != 'running':
  462. container.start()
  463. logger.info('Container started')
  464. elapsed = 0
  465. while container.status != 'running':
  466. time.sleep(1)
  467. elapsed += 1
  468. if elapsed > self.config.timeout:
  469. break
  470. container = self.docker_client.containers.get(self.container_name)
  471. except Exception:
  472. logger.exception('Failed to start container')
  473. def remove_docker_container(self):
  474. try:
  475. container = self.docker_client.containers.get(self.container_name)
  476. container.stop()
  477. logger.info('Container stopped')
  478. container.remove()
  479. logger.info('Container removed')
  480. elapsed = 0
  481. while container.status != 'exited':
  482. time.sleep(1)
  483. elapsed += 1
  484. if elapsed > self.config.timeout:
  485. break
  486. container = self.docker_client.containers.get(self.container_name)
  487. except docker.errors.NotFound:
  488. pass
  489. def get_working_directory(self):
  490. exit_code, result = self.execute('pwd')
  491. if exit_code != 0:
  492. raise Exception('Failed to get working directory')
  493. return str(result).strip()
  494. def is_container_running(self):
  495. try:
  496. container = self.docker_client.containers.get(self.container_name)
  497. if container.status == 'running':
  498. self.container = container
  499. return True
  500. return False
  501. except docker.errors.NotFound:
  502. return False
  503. @property
  504. def volumes(self):
  505. mount_dir = self.workspace_mount_path
  506. return {
  507. mount_dir: {'bind': self.sandbox_workspace_dir, 'mode': 'rw'},
  508. # mount cache directory to /home/opendevin/.cache for pip cache reuse
  509. self.cache_dir: {
  510. 'bind': (
  511. '/home/opendevin/.cache' if self.run_as_devin else '/root/.cache'
  512. ),
  513. 'mode': 'rw',
  514. },
  515. }
  516. def restart_docker_container(self):
  517. try:
  518. self.remove_docker_container()
  519. except docker.errors.DockerException as ex:
  520. logger.exception('Failed to remove container', exc_info=False)
  521. raise ex
  522. try:
  523. network_kwargs: dict[str, str | dict[str, int]] = {}
  524. if self.use_host_network:
  525. network_kwargs['network_mode'] = 'host'
  526. else:
  527. # FIXME: This is a temporary workaround for Windows where host network mode has bugs.
  528. # FIXME: Docker Desktop for Mac OS has experimental support for host network mode
  529. network_kwargs['ports'] = {f'{self._ssh_port}/tcp': self._ssh_port}
  530. logger.warning(
  531. (
  532. 'Using port forwarding till the enable host network mode of Docker is out of experimental mode.'
  533. 'Check the 897th issue on https://github.com/OpenDevin/OpenDevin/issues/ for more information.'
  534. )
  535. )
  536. # start the container
  537. logger.info(f'Mounting volumes: {self.volumes}')
  538. self.container = self.docker_client.containers.run(
  539. self.container_image,
  540. # allow root login
  541. command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'",
  542. **network_kwargs,
  543. working_dir=self.sandbox_workspace_dir,
  544. name=self.container_name,
  545. detach=True,
  546. volumes=self.volumes,
  547. )
  548. logger.info('Container started')
  549. except Exception as ex:
  550. logger.exception('Failed to start container: ' + str(ex), exc_info=False)
  551. raise ex
  552. # wait for container to be ready
  553. elapsed = 0
  554. while self.container.status != 'running':
  555. if self.container.status == 'exited':
  556. logger.info('container exited')
  557. logger.info('container logs:')
  558. logger.info(self.container.logs())
  559. break
  560. time.sleep(1)
  561. elapsed += 1
  562. self.container = self.docker_client.containers.get(self.container_name)
  563. logger.info(
  564. f'waiting for container to start: {elapsed}, container status: {self.container.status}'
  565. )
  566. if elapsed > self.config.timeout:
  567. break
  568. if self.container.status != 'running':
  569. raise Exception('Failed to start container')
  570. # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down
  571. def close(self):
  572. containers = self.docker_client.containers.list(all=True)
  573. for container in containers:
  574. try:
  575. if container.name.startswith(self.container_name):
  576. if self.persist_sandbox:
  577. container.stop()
  578. else:
  579. # only remove the container we created
  580. # otherwise all other containers with the same prefix will be removed
  581. # which will mess up with parallel evaluation
  582. container.remove(force=True)
  583. except docker.errors.NotFound:
  584. pass
  585. self.docker_client.close()
  586. if __name__ == '__main__':
  587. try:
  588. ssh_box = DockerSSHBox(
  589. config=SandboxConfig(),
  590. run_as_devin=False,
  591. workspace_mount_path='/path/to/workspace',
  592. cache_dir='/path/to/cache',
  593. sandbox_workspace_dir='/sandbox',
  594. persist_sandbox=False,
  595. )
  596. except Exception as e:
  597. logger.exception('Failed to start Docker container: %s', e)
  598. sys.exit(1)
  599. logger.info(
  600. "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit."
  601. )
  602. # Initialize required plugins
  603. plugins: list[PluginRequirement] = [AgentSkillsRequirement(), JupyterRequirement()]
  604. ssh_box.init_plugins(plugins)
  605. logger.info(
  606. '--- AgentSkills COMMAND DOCUMENTATION ---\n'
  607. f'{AgentSkillsRequirement().documentation}\n'
  608. '---'
  609. )
  610. sys.stdout.flush()
  611. try:
  612. while True:
  613. try:
  614. user_input = input('$ ')
  615. except EOFError:
  616. logger.info('Exiting...')
  617. break
  618. if user_input.lower() == 'exit':
  619. logger.info('Exiting...')
  620. break
  621. exit_code, output = ssh_box.execute(user_input)
  622. logger.info('exit code: %d', exit_code)
  623. logger.info(output)
  624. sys.stdout.flush()
  625. except KeyboardInterrupt:
  626. logger.info('Exiting...')
  627. ssh_box.close()