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>
This commit is contained in:
Shimada666
2024-06-20 10:58:07 +08:00
committed by GitHub
parent b569ba710d
commit 26fc3c886a
10 changed files with 279 additions and 63 deletions

2
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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),
]

View File

@@ -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__
)
_test_sandbox_jupyter_agentskills_fileop_pwd_impl(box)
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')
@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)