Просмотр исходного кода

feat(sandbox): Support sshd-based stateful docker session (#847)

* support sshd-based stateful docker session

* use .getLogger to avoid same logging message to get printed twice

* update poetry lock for dependency

* fix ruff

* bump docker image version with sshd

* set-up random user password and only allow localhost connection for sandbox

* fix poetry

* move apt install up
Xingyao Wang 1 год назад
Родитель
Сommit
55760ec4dd
5 измененных файлов с 96 добавлено и 30 удалено
  1. 4 0
      opendevin/sandbox/Dockerfile
  2. 1 1
      opendevin/sandbox/Makefile
  3. 58 28
      opendevin/sandbox/sandbox.py
  4. 32 1
      poetry.lock
  5. 1 0
      pyproject.toml

+ 4 - 0
opendevin/sandbox/Dockerfile

@@ -14,4 +14,8 @@ RUN apt-get update && apt-get install -y \
     python3-venv \
     python3-dev \
     build-essential \
+    openssh-server \
+    sudo \
     && rm -rf /var/lib/apt/lists/*
+
+RUN service ssh start

+ 1 - 1
opendevin/sandbox/Makefile

@@ -1,7 +1,7 @@
 DOCKER_BUILD_REGISTRY=ghcr.io
 DOCKER_BUILD_ORG=opendevin
 DOCKER_BUILD_REPO=sandbox
-DOCKER_BUILD_TAG=v0.1.0
+DOCKER_BUILD_TAG=v0.1.1
 FULL_IMAGE=$(DOCKER_BUILD_REGISTRY)/$(DOCKER_BUILD_ORG)/$(DOCKER_BUILD_REPO):$(DOCKER_BUILD_TAG)
 
 LATEST_FULL_IMAGE=$(DOCKER_BUILD_REGISTRY)/$(DOCKER_BUILD_ORG)/$(DOCKER_BUILD_REPO):latest

+ 58 - 28
opendevin/sandbox/sandbox.py

@@ -4,11 +4,11 @@ import select
 import sys
 import time
 import uuid
+from pexpect import pxssh
 from collections import namedtuple
 from typing import Dict, List, Tuple
 
 import docker
-import concurrent.futures
 
 from opendevin import config
 from opendevin.logging import opendevin_logger as logger
@@ -132,23 +132,50 @@ class DockerInteractive(CommandExecutor):
 
         if not self.is_container_running():
             self.restart_docker_container()
+        # set up random user password
+        self._ssh_password = str(uuid.uuid4())
         if RUN_AS_DEVIN:
             self.setup_devin_user()
+            self.start_ssh_session()
+        else:
+            # TODO: implement ssh into root
+            raise NotImplementedError(
+                'Running as root is not supported at the moment.')
         atexit.register(self.cleanup)
 
     def setup_devin_user(self):
         exit_code, logs = self.container.exec_run(
-            [
-                '/bin/bash',
-                '-c',
-                f'useradd --shell /bin/bash -u {USER_ID} -o -c "" -m devin',
-            ],
+            ['/bin/bash', '-c',
+                f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {USER_ID} opendevin'],
+            workdir='/workspace',
+        )
+        exit_code, logs = self.container.exec_run(
+            ['/bin/bash', '-c',
+                f"echo 'opendevin:{self._ssh_password}' | chpasswd"],
+            workdir='/workspace',
+        )
+        exit_code, logs = self.container.exec_run(
+            ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"],
             workdir='/workspace',
         )
 
+    def start_ssh_session(self):
+        # start ssh session at the background
+        self.ssh = pxssh.pxssh()
+        hostname = 'localhost'
+        username = 'opendevin'
+        self.ssh.login(hostname, username, self._ssh_password, port=2222)
+
+        # Fix: https://github.com/pexpect/pexpect/issues/669
+        self.ssh.sendline("bind 'set enable-bracketed-paste off'")
+        self.ssh.prompt()
+        # cd to workspace
+        self.ssh.sendline('cd /workspace')
+        self.ssh.prompt()
+
     def get_exec_cmd(self, cmd: str) -> List[str]:
         if RUN_AS_DEVIN:
-            return ['su', 'devin', '-c', cmd]
+            return ['su', 'opendevin', '-c', cmd]
         else:
             return ['/bin/bash', '-c', cmd]
 
@@ -159,26 +186,27 @@ class DockerInteractive(CommandExecutor):
         return bg_cmd.read_logs()
 
     def execute(self, cmd: str) -> Tuple[int, str]:
-        # TODO: each execute is not stateful! We need to keep track of the current working directory
-        def run_command(container, command):
-            return container.exec_run(command, workdir='/workspace')
-
-        # Use ThreadPoolExecutor to control command and set timeout
-        with concurrent.futures.ThreadPoolExecutor() as executor:
-            future = executor.submit(
-                run_command, self.container, self.get_exec_cmd(cmd)
-            )
-            try:
-                exit_code, logs = future.result(timeout=self.timeout)
-            except concurrent.futures.TimeoutError:
-                logger.exception(
-                    'Command timed out, killing process...', exc_info=False)
-                pid = self.get_pid(cmd)
-                if pid is not None:
-                    self.container.exec_run(
-                        f'kill -9 {pid}', workdir='/workspace')
-                return -1, f'Command: "{cmd}" timed out'
-        return exit_code, logs.decode('utf-8')
+        # use self.ssh
+        self.ssh.sendline(cmd)
+        success = self.ssh.prompt(timeout=self.timeout)
+        if not success:
+            logger.exception(
+                'Command timed out, killing process...', exc_info=False)
+            # send a SIGINT to the process
+            self.ssh.sendintr()
+            self.ssh.prompt()
+            command_output = self.ssh.before.decode(
+                'utf-8').lstrip(cmd).strip()
+            return -1, f'Command: "{cmd}" timed out. Sending SIGINT to the process: {command_output}'
+        command_output = self.ssh.before.decode('utf-8').lstrip(cmd).strip()
+
+        # get the exit code
+        self.ssh.sendline('echo $?')
+        self.ssh.prompt()
+        exit_code = self.ssh.before.decode('utf-8')
+        # remove the echo $? itself
+        exit_code = int(exit_code.lstrip('echo $?').strip())
+        return exit_code, command_output
 
     def execute_in_background(self, cmd: str) -> BackgroundCommand:
         result = self.container.exec_run(
@@ -267,10 +295,12 @@ class DockerInteractive(CommandExecutor):
             # start the container
             self.container = docker_client.containers.run(
                 self.container_image,
-                command='tail -f /dev/null',
+                # only allow connections from localhost
+                command="/usr/sbin/sshd -D -p 2222 -o 'ListenAddress=127.0.0.1'",
                 network_mode='host',
                 working_dir='/workspace',
                 name=self.container_name,
+                hostname='opendevin_sandbox',
                 detach=True,
                 volumes={self.workspace_dir: {
                     'bind': '/workspace', 'mode': 'rw'}},

+ 32 - 1
poetry.lock

@@ -3363,6 +3363,20 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d
 test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
 xml = ["lxml (>=4.9.2)"]
 
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+description = "Pexpect allows easy control of interactive console applications."
+optional = false
+python-versions = "*"
+files = [
+    {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
+    {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
+]
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
 [[package]]
 name = "pillow"
 version = "10.3.0"
@@ -3596,6 +3610,17 @@ files = [
     {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"},
 ]
 
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+optional = false
+python-versions = "*"
+files = [
+    {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+    {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+
 [[package]]
 name = "pulsar-client"
 version = "3.4.0"
@@ -3909,26 +3934,31 @@ python-versions = ">=3.8"
 files = [
     {file = "PyMuPDF-1.24.1-cp310-none-macosx_10_9_x86_64.whl", hash = "sha256:6427aee313e24447f57edbfc7a28aa6bbca007fe0ad77603f54a371c6c510eeb"},
     {file = "PyMuPDF-1.24.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:12078c0bee337de969dbd6d89ef446312794d74db365cb9ac14902b863b35414"},
+    {file = "PyMuPDF-1.24.1-cp310-none-manylinux2014_aarch64.whl", hash = "sha256:73f86eefd7f3878f112fa10791aa2e63934cf59a4c024dd54cd6fe94443c352c"},
     {file = "PyMuPDF-1.24.1-cp310-none-manylinux2014_x86_64.whl", hash = "sha256:caf6ceb1dbebe9f70bf7dd683cc91b896604a7c62873e5b50089f9e85e85c517"},
     {file = "PyMuPDF-1.24.1-cp310-none-win32.whl", hash = "sha256:468a8bb2b95828e0f6739fbfe509700cc0dac600f756d8cb6316316e1eba9689"},
     {file = "PyMuPDF-1.24.1-cp310-none-win_amd64.whl", hash = "sha256:e47504391908e2d721c743aed36196310a5e15355a85459c1c4ddcf8f2002fbe"},
     {file = "PyMuPDF-1.24.1-cp311-none-macosx_10_9_x86_64.whl", hash = "sha256:c54ff927257b432ffd39dc6a0a46bd1120e85d192100efca021f27d4b881cfd6"},
     {file = "PyMuPDF-1.24.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:6d412da9f9a73f66973eea4284776f292135906700a06c39122e862a1e3ccf58"},
+    {file = "PyMuPDF-1.24.1-cp311-none-manylinux2014_aarch64.whl", hash = "sha256:95a54611abb7322f5b10b44cbf19b605ed172df2c4c7995ad78854bc8423dd9c"},
     {file = "PyMuPDF-1.24.1-cp311-none-manylinux2014_x86_64.whl", hash = "sha256:9a3b21c8fc274ff42855ca2da65961e2319b05b75ef9e2caf25c04f9083ec79c"},
     {file = "PyMuPDF-1.24.1-cp311-none-win32.whl", hash = "sha256:8a81106a8bc229823736487d2492fd3af724a94521a1cd9b67849dd04b9c31ed"},
     {file = "PyMuPDF-1.24.1-cp311-none-win_amd64.whl", hash = "sha256:de5b6c4db4a2a9f28937e79135f732827c424f7444c12767cc1081c8006f0430"},
     {file = "PyMuPDF-1.24.1-cp312-none-macosx_10_9_x86_64.whl", hash = "sha256:02a6586979df2ad958b524ba42955beaa67fd21661616a0ed04ac07db009474c"},
     {file = "PyMuPDF-1.24.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:8eb292d16671166acdaa280e98cac4368298f32556f2de2ee690782a635df8ee"},
+    {file = "PyMuPDF-1.24.1-cp312-none-manylinux2014_aarch64.whl", hash = "sha256:f7b7f2011fa522a57fb3d6a7a58bcdcf01ee59bdad536ef9eb5c3fdf1e04e6c3"},
     {file = "PyMuPDF-1.24.1-cp312-none-manylinux2014_x86_64.whl", hash = "sha256:6832f1d9332810760b587ad375eb84d64ec8d8f29395995b463cb5f30533a413"},
     {file = "PyMuPDF-1.24.1-cp312-none-win32.whl", hash = "sha256:f775bb56391629e81b5f870fc3dec0a0fb44cb34a92b4696b9207b31234711df"},
     {file = "PyMuPDF-1.24.1-cp312-none-win_amd64.whl", hash = "sha256:8489df092473d590fb14903433bd99a07dc3d2924f5a5c8ead615795f2d65a65"},
     {file = "PyMuPDF-1.24.1-cp38-none-macosx_10_9_x86_64.whl", hash = "sha256:ee9cfac470aeb6b5b7deb4f6472b7796c3132856849c635c8e56c7a371e40238"},
     {file = "PyMuPDF-1.24.1-cp38-none-macosx_11_0_arm64.whl", hash = "sha256:825c62367b01e61b4bce0cc96d45b0ec336475422cfa36de6f441b4d3389a26e"},
+    {file = "PyMuPDF-1.24.1-cp38-none-manylinux2014_aarch64.whl", hash = "sha256:73d07e127936948a29a7dbd4c831e9eb45a60b495d72e604d454fd040fd08c5f"},
     {file = "PyMuPDF-1.24.1-cp38-none-manylinux2014_x86_64.whl", hash = "sha256:d2b4f8956d0ca7564604491db8b29cd7872a2b4d65f1d7e16a1bccfecf84bb56"},
     {file = "PyMuPDF-1.24.1-cp38-none-win32.whl", hash = "sha256:7df966954ff0edbcd5d743c5f6fb68b3203e67534747e8753691b8ffedeaa518"},
     {file = "PyMuPDF-1.24.1-cp38-none-win_amd64.whl", hash = "sha256:6952d47f0f05cf9338470dda078e4533ddb876368b199ebfa2f9e6076311898b"},
     {file = "PyMuPDF-1.24.1-cp39-none-macosx_10_9_x86_64.whl", hash = "sha256:e3f7a101a14d742c93b660b7586ab4c1491caea9062a5de9c308578a7a4f8b69"},
     {file = "PyMuPDF-1.24.1-cp39-none-macosx_11_0_arm64.whl", hash = "sha256:dbc5d67dfd07123293993eb93bee35d329fce0bc8134b9cd5514ef75c68ffee8"},
+    {file = "PyMuPDF-1.24.1-cp39-none-manylinux2014_aarch64.whl", hash = "sha256:0edda1024ada67603e5888f31656048d3fd53167c8b0d56f435b986eb507df8f"},
     {file = "PyMuPDF-1.24.1-cp39-none-manylinux2014_x86_64.whl", hash = "sha256:38728bb6aab9e3879aa8ac4d337be8fe838d33973f43e3b7805b86265c24f349"},
     {file = "PyMuPDF-1.24.1-cp39-none-win32.whl", hash = "sha256:b8a5247d0cec87765481c38d2b8602f0264bf7ca6b5dc3013caf64ce46ad4d5e"},
     {file = "PyMuPDF-1.24.1-cp39-none-win_amd64.whl", hash = "sha256:d1078ea265635e962693d7298bd39be64af7d1dd2c6dc663a8562e75f547f948"},
@@ -3947,6 +3977,7 @@ python-versions = ">=3.8"
 files = [
     {file = "PyMuPDFb-1.24.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:37179e363bf69ce9be637937c5469957b96968341dabe3ce8f4b690a82e9ad92"},
     {file = "PyMuPDFb-1.24.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:17444ea7d6897c27759880ad76af537d19779f901de82ae9548598a70f614558"},
+    {file = "PyMuPDFb-1.24.1-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:490f7fff4dbe362bc895cefdfc5030d712311d024d357a1388d64816eb215d34"},
     {file = "PyMuPDFb-1.24.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0fbcc0d2a9ce79fa38eb4e8bb5c959b582f7a49938874e9f61d1a6f5eeb1e4b8"},
     {file = "PyMuPDFb-1.24.1-py3-none-win32.whl", hash = "sha256:ae67736058882cdd9459810a4aae9ac2b2e89ac2e916cb5fefb0f651c9739e9e"},
     {file = "PyMuPDFb-1.24.1-py3-none-win_amd64.whl", hash = "sha256:01c8b7f0ce9166310eb28c7aebcb8d5fe12a4bc082f9b00d580095eebeaf0af5"},
@@ -5874,4 +5905,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "eb7d77f58c52f70702e9a8501084b09c307d62caf179428b70b781860508a0fb"
+content-hash = "0168adb891fac11fcad6bcfe2e8d13453040f5d5e6ebd8c6713e36d8e4a318da"

+ 1 - 0
pyproject.toml

@@ -23,6 +23,7 @@ types-toml = "*"
 numpy = "*"
 json-repair = "*"
 playwright = "*"
+pexpect = "*"
 
 [tool.poetry.group.llama-index.dependencies]
 llama-index = "*"