Browse Source

Make plugins sandbox-agnostic (#2101)

* tmp

* tmp

* merge main

* feat: auto build image cache

* remove plugins

* use config file

* update mamba setup shell

* support agnostic sandbox image autobuild

* remove config

* Update .gitignore

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>

* Update opendevin/runtime/docker/ssh_box.py

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>

* update setup.sh

* readd sudo

* add sudo in dockerfile

* remove export

* move od-runtime dependencies to sandbox dockerfile

* factor out re-build logic into a separate util file

* tweak existing plugin to use OD specific sandbox

* update testcase

* attempt to fix unit test using image built in ghcr

* use cache tag

* try to fix unit tests

* add unittest

* add unittest

* add some unittests

* revert gh workflow changes

* feat: optimize sandbox image naming rule

* add pull latest image hint

* add opendevin python hint and use mamba to install gcc

* update docker image naming rule and fix mamba issue

* Update opendevin/runtime/docker/ssh_box.py

Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>

* fix: opendevin user use correct pip

* fix lint issue

* fix custom sandbox base image

* rename test name

* add skipif

---------

Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Co-authored-by: Boxuan Li <liboxuan@connect.hku.hk>
Co-authored-by: Yufan Song <33971064+yufansong@users.noreply.github.com>
Co-authored-by: tobitege <tobitege@gmx.de>
Shimada666 1 year ago
parent
commit
26fc3c886a

+ 2 - 0
.gitignore

@@ -211,3 +211,5 @@ cache
 # configuration
 config.toml
 config.toml.bak
+
+containers/agnostic_sandbox

+ 1 - 1
containers/sandbox/Dockerfile

@@ -41,4 +41,4 @@ RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> /opendevin/bash.bashrc
 # - agentskills dependencies
 RUN /opendevin/miniforge3/bin/pip install --upgrade pip
 RUN /opendevin/miniforge3/bin/pip install jupyterlab notebook jupyter_kernel_gateway flake8
-RUN /opendevin/miniforge3/bin/pip install python-docx PyPDF2 python-pptx pylatexenc openai opencv-python
+RUN /opendevin/miniforge3/bin/pip install python-docx PyPDF2 python-pptx pylatexenc openai opencv-python

+ 95 - 0
opendevin/runtime/docker/image_agnostic_util.py

@@ -0,0 +1,95 @@
+import tempfile
+
+import docker
+
+from opendevin.core.logger import opendevin_logger as logger
+
+
+def generate_dockerfile_content(base_image: str) -> str:
+    """
+    Generate the Dockerfile content for the agnostic sandbox image based on user-provided base image.
+
+    NOTE: This is only tested on debian yet.
+    """
+    # FIXME: Remove the requirement of ssh in future version
+    dockerfile_content = (
+        f'FROM {base_image}\n'
+        'RUN apt update && apt install -y openssh-server wget sudo\n'
+        'RUN mkdir -p -m0755 /var/run/sshd\n'
+        'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n'
+        'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
+        'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /opendevin/miniforge3\n'
+        'RUN bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
+        'RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> ~/.bashrc\n'
+        'RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> /opendevin/bash.bashrc\n'
+    ).strip()
+    return dockerfile_content
+
+
+def _build_sandbox_image(
+        base_image: str, target_image_name: str, docker_client: docker.DockerClient
+):
+    try:
+        with tempfile.TemporaryDirectory() as temp_dir:
+            dockerfile_content = generate_dockerfile_content(base_image)
+            logger.info(f'Building agnostic sandbox image: {target_image_name}')
+            logger.info(
+                (
+                    f'===== Dockerfile content =====\n'
+                    f'{dockerfile_content}\n'
+                    f'==============================='
+                )
+            )
+            with open(f'{temp_dir}/Dockerfile', 'w') as file:
+                file.write(dockerfile_content)
+
+            image, logs = docker_client.images.build(
+                path=temp_dir, tag=target_image_name
+            )
+
+        for log in logs:
+            if 'stream' in log:
+                print(log['stream'].strip())
+
+        logger.info(f'Image {image} built successfully')
+    except docker.errors.BuildError as e:
+        logger.error(f'Sandbox image build failed: {e}')
+        raise e
+    except Exception as e:
+        logger.error(f'An error occurred during sandbox image build: {e}')
+        raise e
+
+
+def _get_new_image_name(base_image: str) -> str:
+    if ":" not in base_image:
+        base_image = base_image + ":latest"
+
+    [repo, tag] = base_image.split(':')
+    return f'od_sandbox:{repo}__{tag}'
+
+
+def get_od_sandbox_image(base_image: str, docker_client: docker.DockerClient) -> str:
+    """Return the sandbox image name based on user-provided base image.
+
+    The returned sandbox image is assumed to contains all the required dependencies for OpenDevin.
+    If the sandbox image is not found, it will be built.
+    """
+    # OpenDevin's offcial sandbox already contains the required dependencies for OpenDevin.
+    if 'ghcr.io/opendevin/sandbox' in base_image:
+        return base_image
+
+    new_image_name = _get_new_image_name(base_image)
+
+    # Detect if the sandbox image is built
+    images = docker_client.images.list()
+    for image in images:
+        if new_image_name in image.tags:
+            logger.info('Found existing od_sandbox image, reuse:' + new_image_name)
+            return new_image_name
+
+    # If the sandbox image is not found, build it
+    logger.info(
+        f'od_sandbox image is not found for {base_image}, will build: {new_image_name}'
+    )
+    _build_sandbox_image(base_image, new_image_name, docker_client)
+    return new_image_name

+ 33 - 3
opendevin/runtime/docker/ssh_box.py

@@ -17,6 +17,7 @@ from opendevin.core.const.guide_url import TROUBLESHOOTING_URL
 from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError
 from opendevin.core.logger import opendevin_logger as logger
 from opendevin.core.schema import CancellableStream
+from opendevin.runtime.docker.image_agnostic_util import get_od_sandbox_image
 from opendevin.runtime.docker.process import DockerProcess, Process
 from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
 from opendevin.runtime.sandbox import Sandbox
@@ -222,6 +223,9 @@ class DockerSSHBox(Sandbox):
 
         self.timeout = timeout
         self.container_image = container_image or config.sandbox_container_image
+        self.container_image = get_od_sandbox_image(
+            self.container_image, self.docker_client
+        )
         self.container_name = self.container_name_prefix + self.instance_id
 
         # set up random user password
@@ -271,7 +275,7 @@ class DockerSSHBox(Sandbox):
         self.execute('mkdir -p /tmp')
         # set git config
         self.execute('git config --global user.name "OpenDevin"')
-        self.execute('git config --global user.email "opendevin@opendevin.ai"')
+        self.execute('git config --global user.email "opendevin@all-hands.dev"')
         atexit.register(self.close)
         super().__init__()
 
@@ -342,6 +346,31 @@ class DockerSSHBox(Sandbox):
                 raise Exception(
                     f'Failed to chown home directory for opendevin in sandbox: {logs}'
                 )
+            # check the miniforge3 directory exist
+            exit_code, logs = self.container.exec_run(
+                ['/bin/bash', '-c', '[ -d "/opendevin/miniforge3" ] && exit 0 || exit 1'],
+                workdir=self.sandbox_workspace_dir,
+                environment=self._env,
+            )
+            if exit_code != 0:
+                if exit_code == 1:
+                    raise Exception(
+                        f'OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image: docker pull ghcr.io/opendevin/sandbox:main'
+                    )
+                else:
+                    raise Exception(
+                        f'An error occurred while checking if miniforge3 directory exists: {logs}'
+                    )
+            # chown the miniforge3
+            exit_code, logs = self.container.exec_run(
+                ['/bin/bash', '-c', 'chown -R opendevin:root /opendevin/miniforge3'],
+                workdir=self.sandbox_workspace_dir,
+                environment=self._env,
+            )
+            if exit_code != 0:
+                raise Exception(
+                    f'Failed to chown miniforge3 directory for opendevin in sandbox: {logs}'
+                )
             exit_code, logs = self.container.exec_run(
                 [
                     '/bin/bash',
@@ -714,7 +743,7 @@ class DockerSSHBox(Sandbox):
             )
             logger.info('Container started')
         except Exception as ex:
-            logger.exception('Failed to start container', exc_info=False)
+            logger.exception('Failed to start container: ' + str(ex), exc_info=False)
             raise ex
 
         # wait for container to be ready
@@ -766,7 +795,8 @@ if __name__ == '__main__':
     )
 
     # Initialize required plugins
-    ssh_box.init_plugins([AgentSkillsRequirement(), JupyterRequirement()])
+    plugins = [AgentSkillsRequirement(), JupyterRequirement()]
+    ssh_box.init_plugins(plugins)
     logger.info(
         '--- AgentSkills COMMAND DOCUMENTATION ---\n'
         f'{AgentSkillsRequirement().documentation}\n'

+ 10 - 3
opendevin/runtime/plugins/agent_skills/setup.sh

@@ -2,12 +2,19 @@
 
 set -e
 
+OPENDEVIN_PYTHON_INTERPRETER=/opendevin/miniforge3/bin/python
+# check if OPENDEVIN_PYTHON_INTERPRETER exists and it is usable
+if [ -z "$OPENDEVIN_PYTHON_INTERPRETER" ] ||  [ ! -x "$OPENDEVIN_PYTHON_INTERPRETER" ]; then
+    echo "OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image!"
+    exit 1
+fi
+
 # add agent_skills to PATH
 echo 'export PATH=/opendevin/plugins/agent_skills:$PATH' >> ~/.bashrc
-export PATH=/opendevin/plugins/agent_skills:$PATH
 
 # add agent_skills to PYTHONPATH
 echo 'export PYTHONPATH=/opendevin/plugins/agent_skills:$PYTHONPATH' >> ~/.bashrc
-export PYTHONPATH=/opendevin/plugins/agent_skills:$PYTHONPATH
 
-pip install flake8 python-docx PyPDF2 python-pptx pylatexenc openai opencv-python
+source ~/.bashrc
+
+$OPENDEVIN_PYTHON_INTERPRETER -m pip install flake8 python-docx PyPDF2 python-pptx pylatexenc openai opencv-python

+ 18 - 11
opendevin/runtime/plugins/jupyter/setup.sh

@@ -2,14 +2,25 @@
 
 set -e
 
+# Hardcoded to use the Python interpreter from the OpenDevin runtime client
+OPENDEVIN_PYTHON_INTERPRETER=/opendevin/miniforge3/bin/python
+# check if OPENDEVIN_PYTHON_INTERPRETER exists and it is usable
+if [ -z "$OPENDEVIN_PYTHON_INTERPRETER" ] ||  [ ! -x "$OPENDEVIN_PYTHON_INTERPRETER" ]; then
+    echo "OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image!"
+    exit 1
+fi
+
+# use mamba to install c library
+/opendevin/miniforge3/bin/mamba install -y gcc
+
+# Install dependencies
+$OPENDEVIN_PYTHON_INTERPRETER -m pip install jupyterlab notebook jupyter_kernel_gateway
+
 source ~/.bashrc
 # ADD /opendevin/plugins to PATH to make `jupyter_cli` available
 echo 'export PATH=$PATH:/opendevin/plugins/jupyter' >> ~/.bashrc
 export PATH=/opendevin/plugins/jupyter:$PATH
 
-# get current PythonInterpreter
-OPENDEVIN_PYTHON_INTERPRETER=$(which python3)
-
 # if user name is `opendevin`, add '/home/opendevin/.local/bin' to PATH
 if [ "$USER" = "opendevin" ]; then
     echo 'export PATH=$PATH:/home/opendevin/.local/bin' >> ~/.bashrc
@@ -26,12 +37,6 @@ if [ "$USER" = "root" ]; then
 
 fi
 
-# Install dependencies
-pip install jupyterlab notebook jupyter_kernel_gateway
-
-# Create logs directory
-sudo mkdir -p /opendevin/logs && sudo chmod 777 /opendevin/logs
-
 # Run background process to start jupyter kernel gateway
 # write a bash function that finds a free port
 find_free_port() {
@@ -50,7 +55,9 @@ find_free_port() {
 }
 
 export JUPYTER_GATEWAY_PORT=$(find_free_port 20000 30000)
-jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT > /opendevin/logs/jupyter_kernel_gateway.log 2>&1 &
+$OPENDEVIN_PYTHON_INTERPRETER -m \
+  jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT > /opendevin/logs/jupyter_kernel_gateway.log 2>&1 &
+
 export JUPYTER_GATEWAY_PID=$!
 echo "export JUPYTER_GATEWAY_PID=$JUPYTER_GATEWAY_PID" >> ~/.bashrc
 export JUPYTER_GATEWAY_KERNEL_ID="default"
@@ -60,7 +67,7 @@ echo "JupyterKernelGateway started with PID: $JUPYTER_GATEWAY_PID"
 # Start the jupyter_server
 export JUPYTER_EXEC_SERVER_PORT=$(find_free_port 30000 40000)
 echo "export JUPYTER_EXEC_SERVER_PORT=$JUPYTER_EXEC_SERVER_PORT" >> ~/.bashrc
-/opendevin/plugins/jupyter/execute_server > /opendevin/logs/jupyter_execute_server.log 2>&1 &
+$OPENDEVIN_PYTHON_INTERPRETER /opendevin/plugins/jupyter/execute_server > /opendevin/logs/jupyter_execute_server.log 2>&1 &
 export JUPYTER_EXEC_SERVER_PID=$!
 echo "export JUPYTER_EXEC_SERVER_PID=$JUPYTER_EXEC_SERVER_PID" >> ~/.bashrc
 echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID"

+ 15 - 8
opendevin/runtime/plugins/mixin.py

@@ -13,12 +13,21 @@ class SandboxProtocol(Protocol):
     def initialize_plugins(self) -> bool: ...
 
     def execute(
-        self, cmd: str, stream: bool = False
+            self, cmd: str, stream: bool = False
     ) -> tuple[int, str | CancellableStream]: ...
 
     def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): ...
 
 
+def _source_bashrc(sandbox: SandboxProtocol):
+    exit_code, output = sandbox.execute('source /opendevin/bash.bashrc && source ~/.bashrc')
+    if exit_code != 0:
+        raise RuntimeError(
+            f'Failed to source /opendevin/bash.bashrc and ~/.bashrc with exit code {exit_code} and output: {output}'
+        )
+    logger.info('Sourced /opendevin/bash.bashrc and ~/.bashrc successfully')
+
+
 class PluginMixin:
     """Mixin for Sandbox to support plugins."""
 
@@ -35,6 +44,9 @@ class PluginMixin:
             exit_code, output = self.execute('rm -f ~/.bashrc && touch ~/.bashrc')
 
             for requirement in requirements:
+                # source bashrc file when plugin loads
+                _source_bashrc(self)
+
                 # copy over the files
                 self.copy_to(
                     requirement.host_src, requirement.sandbox_dest, recursive=True
@@ -62,7 +74,7 @@ class PluginMixin:
                     output.close()
                     if _exit_code != 0:
                         raise RuntimeError(
-                            f'Failed to initialize plugin {requirement.name} with exit code {_exit_code} and output {total_output}'
+                            f'Failed to initialize plugin {requirement.name} with exit code {_exit_code} and output: {total_output}'
                         )
                     logger.info(f'Plugin {requirement.name} initialized successfully')
                 else:
@@ -75,11 +87,6 @@ class PluginMixin:
             logger.info('Skipping plugin initialization in the sandbox')
 
         if len(requirements) > 0:
-            exit_code, output = self.execute('source ~/.bashrc')
-            if exit_code != 0:
-                raise RuntimeError(
-                    f'Failed to source ~/.bashrc with exit code {exit_code} and output: {output}'
-                )
-            logger.info('Sourced ~/.bashrc successfully')
+            _source_bashrc(self)
 
         self.plugin_initialized = True

+ 42 - 0
tests/unit/test_image_agnostic_util.py

@@ -0,0 +1,42 @@
+from unittest.mock import MagicMock, patch
+from opendevin.runtime.docker.image_agnostic_util import (
+    generate_dockerfile_content,
+    _get_new_image_name,
+    get_od_sandbox_image,
+)
+
+
+def test_generate_dockerfile_content():
+    base_image = "debian:11"
+    dockerfile_content = generate_dockerfile_content(base_image)
+    assert base_image in dockerfile_content
+    assert "RUN apt update && apt install -y openssh-server wget sudo" in dockerfile_content
+
+
+def test_get_new_image_name():
+    base_image = "debian:11"
+    new_image_name = _get_new_image_name(base_image)
+    assert new_image_name == "od_sandbox:debian__11"
+
+    base_image = "ubuntu:22.04"
+    new_image_name = _get_new_image_name(base_image)
+    assert new_image_name == "od_sandbox:ubuntu__22.04"
+
+    base_image = "ubuntu"
+    new_image_name = _get_new_image_name(base_image)
+    assert new_image_name == "od_sandbox:ubuntu__latest"
+
+
+@patch("opendevin.runtime.docker.image_agnostic_util._build_sandbox_image")
+@patch("opendevin.runtime.docker.image_agnostic_util.docker.DockerClient")
+def test_get_od_sandbox_image(mock_docker_client, mock_build_sandbox_image):
+    base_image = "debian:11"
+    mock_docker_client.images.list.return_value = [MagicMock(tags=["od_sandbox:debian__11"])]
+
+    image_name = get_od_sandbox_image(base_image, mock_docker_client)
+    assert image_name == "od_sandbox:debian__11"
+
+    mock_docker_client.images.list.return_value = []
+    image_name = get_od_sandbox_image(base_image, mock_docker_client)
+    assert image_name == "od_sandbox:debian__11"
+    mock_build_sandbox_image.assert_called_once_with(base_image, "od_sandbox:debian__11", mock_docker_client)

+ 1 - 1
tests/unit/test_ipython.py

@@ -63,7 +63,7 @@ async def test_run_python_backticks():
             [
                 call('mkdir -p /tmp'),
                 call('git config --global user.name "OpenDevin"'),
-                call('git config --global user.email "opendevin@opendevin.ai"'),
+                call('git config --global user.email "opendevin@all-hands.dev"'),
                 call(expected_write_command),
                 call(expected_execute_command),
             ]

+ 64 - 38
tests/unit/test_sandbox.py

@@ -294,6 +294,52 @@ def test_sandbox_jupyter_plugin(temp_dir):
             )
             box.close()
 
+def _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box):
+    box.init_plugins([AgentSkillsRequirement, JupyterRequirement])
+    exit_code, output = box.execute('mkdir test')
+    print(output)
+    assert exit_code == 0, (
+            'The exit code should be 0 for ' + box.__class__.__name__
+    )
+
+    exit_code, output = box.execute(
+        'echo "create_file(\'a.txt\')" | execute_cli'
+    )
+    print(output)
+    assert exit_code == 0, (
+            'The exit code should be 0 for ' + box.__class__.__name__
+    )
+    assert output.strip().split('\r\n') == (
+        '[File: /workspace/a.txt (1 lines total)]\r\n'
+        '1|\r\n'
+        '[File a.txt created.]'
+    ).strip().split('\r\n')
+
+    exit_code, output = box.execute('cd test')
+    print(output)
+    assert exit_code == 0, (
+            'The exit code should be 0 for ' + box.__class__.__name__
+    )
+
+    exit_code, output = box.execute(
+        'echo "create_file(\'a.txt\')" | execute_cli'
+    )
+    print(output)
+    assert exit_code == 0, (
+            'The exit code should be 0 for ' + box.__class__.__name__
+    )
+    assert output.strip().split('\r\n') == (
+        '[File: /workspace/test/a.txt (1 lines total)]\r\n'
+        '1|\r\n'
+        '[File a.txt created.]'
+    ).strip().split('\r\n')
+
+    exit_code, output = box.execute('rm -rf /workspace/*')
+    assert exit_code == 0, (
+            'The exit code should be 0 for ' + box.__class__.__name__
+    )
+    box.close()
+
 
 def test_sandbox_jupyter_agentskills_fileop_pwd(temp_dir):
     # get a temporary directory
@@ -303,41 +349,21 @@ def test_sandbox_jupyter_agentskills_fileop_pwd(temp_dir):
         config, 'sandbox_type', new='ssh'
     ):
         for box in [DockerSSHBox()]:
-            box.init_plugins([AgentSkillsRequirement, JupyterRequirement])
-            exit_code, output = box.execute('mkdir test')
-            print(output)
-            assert exit_code == 0, (
-                'The exit code should be 0 for ' + box.__class__.__name__
-            )
-
-            exit_code, output = box.execute(
-                'echo "create_file(\'a.txt\')" | execute_cli'
-            )
-            print(output)
-            assert exit_code == 0, (
-                'The exit code should be 0 for ' + box.__class__.__name__
-            )
-            assert output.strip().split('\r\n') == (
-                '[File: /workspace/a.txt (1 lines total)]\r\n'
-                '1|\r\n'
-                '[File a.txt created.]'
-            ).strip().split('\r\n')
-
-            exit_code, output = box.execute('cd test')
-            print(output)
-            assert exit_code == 0, (
-                'The exit code should be 0 for ' + box.__class__.__name__
-            )
-
-            exit_code, output = box.execute(
-                'echo "create_file(\'a.txt\')" | execute_cli'
-            )
-            print(output)
-            assert exit_code == 0, (
-                'The exit code should be 0 for ' + box.__class__.__name__
-            )
-            assert output.strip().split('\r\n') == (
-                '[File: /workspace/test/a.txt (1 lines total)]\r\n'
-                '1|\r\n'
-                '[File a.txt created.]'
-            ).strip().split('\r\n')
+            _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box)
+
+
+@pytest.mark.skipif(os.getenv('TEST_IN_CI') != 'true',
+    reason='The unittest need to download image, so only run on CI',
+)
+def test_agnostic_sandbox_jupyter_agentskills_fileop_pwd(temp_dir):
+    for base_sandbox_image in ['ubuntu:22.04', 'debian:11']:
+        # get a temporary directory
+        with patch.object(config, 'workspace_base', new=temp_dir), patch.object(
+                config, 'workspace_mount_path', new=temp_dir
+        ), patch.object(config, 'run_as_devin', new='true'), patch.object(
+            config, 'sandbox_type', new='ssh'
+        ), patch.object(
+            config, 'sandbox_container_image', new=base_sandbox_image
+        ):
+            for box in [DockerSSHBox()]:
+                _test_sandbox_jupyter_agentskills_fileop_pwd_impl(box)