Skip to content

Commit 26fc3c8

Browse files
Shimada666neubigxingyaowwli-boxuanyufansong
authored
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 <[email protected]> * Update opendevin/runtime/docker/ssh_box.py Co-authored-by: Xingyao Wang <[email protected]> * 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 <[email protected]> * fix: opendevin user use correct pip * fix lint issue * fix custom sandbox base image * rename test name * add skipif --------- Co-authored-by: Graham Neubig <[email protected]> Co-authored-by: Xingyao Wang <[email protected]> Co-authored-by: Boxuan Li <[email protected]> Co-authored-by: Yufan Song <[email protected]> Co-authored-by: tobitege <[email protected]>
1 parent b569ba7 commit 26fc3c8

File tree

10 files changed

+281
-65
lines changed

10 files changed

+281
-65
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,5 @@ cache
211211
# configuration
212212
config.toml
213213
config.toml.bak
214+
215+
containers/agnostic_sandbox

containers/sandbox/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> /opendevin/bash.bashrc
4141
# - agentskills dependencies
4242
RUN /opendevin/miniforge3/bin/pip install --upgrade pip
4343
RUN /opendevin/miniforge3/bin/pip install jupyterlab notebook jupyter_kernel_gateway flake8
44-
RUN /opendevin/miniforge3/bin/pip install python-docx PyPDF2 python-pptx pylatexenc openai opencv-python
44+
RUN /opendevin/miniforge3/bin/pip install python-docx PyPDF2 python-pptx pylatexenc openai opencv-python
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import tempfile
2+
3+
import docker
4+
5+
from opendevin.core.logger import opendevin_logger as logger
6+
7+
8+
def generate_dockerfile_content(base_image: str) -> str:
9+
"""
10+
Generate the Dockerfile content for the agnostic sandbox image based on user-provided base image.
11+
12+
NOTE: This is only tested on debian yet.
13+
"""
14+
# FIXME: Remove the requirement of ssh in future version
15+
dockerfile_content = (
16+
f'FROM {base_image}\n'
17+
'RUN apt update && apt install -y openssh-server wget sudo\n'
18+
'RUN mkdir -p -m0755 /var/run/sshd\n'
19+
'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n'
20+
'RUN wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"\n'
21+
'RUN bash Miniforge3-$(uname)-$(uname -m).sh -b -p /opendevin/miniforge3\n'
22+
'RUN bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"\n'
23+
'RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> ~/.bashrc\n'
24+
'RUN echo "export PATH=/opendevin/miniforge3/bin:$PATH" >> /opendevin/bash.bashrc\n'
25+
).strip()
26+
return dockerfile_content
27+
28+
29+
def _build_sandbox_image(
30+
base_image: str, target_image_name: str, docker_client: docker.DockerClient
31+
):
32+
try:
33+
with tempfile.TemporaryDirectory() as temp_dir:
34+
dockerfile_content = generate_dockerfile_content(base_image)
35+
logger.info(f'Building agnostic sandbox image: {target_image_name}')
36+
logger.info(
37+
(
38+
f'===== Dockerfile content =====\n'
39+
f'{dockerfile_content}\n'
40+
f'==============================='
41+
)
42+
)
43+
with open(f'{temp_dir}/Dockerfile', 'w') as file:
44+
file.write(dockerfile_content)
45+
46+
image, logs = docker_client.images.build(
47+
path=temp_dir, tag=target_image_name
48+
)
49+
50+
for log in logs:
51+
if 'stream' in log:
52+
print(log['stream'].strip())
53+
54+
logger.info(f'Image {image} built successfully')
55+
except docker.errors.BuildError as e:
56+
logger.error(f'Sandbox image build failed: {e}')
57+
raise e
58+
except Exception as e:
59+
logger.error(f'An error occurred during sandbox image build: {e}')
60+
raise e
61+
62+
63+
def _get_new_image_name(base_image: str) -> str:
64+
if ":" not in base_image:
65+
base_image = base_image + ":latest"
66+
67+
[repo, tag] = base_image.split(':')
68+
return f'od_sandbox:{repo}__{tag}'
69+
70+
71+
def get_od_sandbox_image(base_image: str, docker_client: docker.DockerClient) -> str:
72+
"""Return the sandbox image name based on user-provided base image.
73+
74+
The returned sandbox image is assumed to contains all the required dependencies for OpenDevin.
75+
If the sandbox image is not found, it will be built.
76+
"""
77+
# OpenDevin's offcial sandbox already contains the required dependencies for OpenDevin.
78+
if 'ghcr.io/opendevin/sandbox' in base_image:
79+
return base_image
80+
81+
new_image_name = _get_new_image_name(base_image)
82+
83+
# Detect if the sandbox image is built
84+
images = docker_client.images.list()
85+
for image in images:
86+
if new_image_name in image.tags:
87+
logger.info('Found existing od_sandbox image, reuse:' + new_image_name)
88+
return new_image_name
89+
90+
# If the sandbox image is not found, build it
91+
logger.info(
92+
f'od_sandbox image is not found for {base_image}, will build: {new_image_name}'
93+
)
94+
_build_sandbox_image(base_image, new_image_name, docker_client)
95+
return new_image_name

opendevin/runtime/docker/ssh_box.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from opendevin.core.exceptions import SandboxInvalidBackgroundCommandError
1818
from opendevin.core.logger import opendevin_logger as logger
1919
from opendevin.core.schema import CancellableStream
20+
from opendevin.runtime.docker.image_agnostic_util import get_od_sandbox_image
2021
from opendevin.runtime.docker.process import DockerProcess, Process
2122
from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
2223
from opendevin.runtime.sandbox import Sandbox
@@ -222,6 +223,9 @@ def __init__(
222223

223224
self.timeout = timeout
224225
self.container_image = container_image or config.sandbox_container_image
226+
self.container_image = get_od_sandbox_image(
227+
self.container_image, self.docker_client
228+
)
225229
self.container_name = self.container_name_prefix + self.instance_id
226230

227231
# set up random user password
@@ -271,7 +275,7 @@ def __init__(
271275
self.execute('mkdir -p /tmp')
272276
# set git config
273277
self.execute('git config --global user.name "OpenDevin"')
274-
self.execute('git config --global user.email "opendevin@opendevin.ai"')
278+
self.execute('git config --global user.email "opendevin@all-hands.dev"')
275279
atexit.register(self.close)
276280
super().__init__()
277281

@@ -342,6 +346,31 @@ def setup_user(self):
342346
raise Exception(
343347
f'Failed to chown home directory for opendevin in sandbox: {logs}'
344348
)
349+
# check the miniforge3 directory exist
350+
exit_code, logs = self.container.exec_run(
351+
['/bin/bash', '-c', '[ -d "/opendevin/miniforge3" ] && exit 0 || exit 1'],
352+
workdir=self.sandbox_workspace_dir,
353+
environment=self._env,
354+
)
355+
if exit_code != 0:
356+
if exit_code == 1:
357+
raise Exception(
358+
f'OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image: docker pull ghcr.io/opendevin/sandbox:main'
359+
)
360+
else:
361+
raise Exception(
362+
f'An error occurred while checking if miniforge3 directory exists: {logs}'
363+
)
364+
# chown the miniforge3
365+
exit_code, logs = self.container.exec_run(
366+
['/bin/bash', '-c', 'chown -R opendevin:root /opendevin/miniforge3'],
367+
workdir=self.sandbox_workspace_dir,
368+
environment=self._env,
369+
)
370+
if exit_code != 0:
371+
raise Exception(
372+
f'Failed to chown miniforge3 directory for opendevin in sandbox: {logs}'
373+
)
345374
exit_code, logs = self.container.exec_run(
346375
[
347376
'/bin/bash',
@@ -714,7 +743,7 @@ def restart_docker_container(self):
714743
)
715744
logger.info('Container started')
716745
except Exception as ex:
717-
logger.exception('Failed to start container', exc_info=False)
746+
logger.exception('Failed to start container: ' + str(ex), exc_info=False)
718747
raise ex
719748

720749
# wait for container to be ready
@@ -766,7 +795,8 @@ def close(self):
766795
)
767796

768797
# Initialize required plugins
769-
ssh_box.init_plugins([AgentSkillsRequirement(), JupyterRequirement()])
798+
plugins = [AgentSkillsRequirement(), JupyterRequirement()]
799+
ssh_box.init_plugins(plugins)
770800
logger.info(
771801
'--- AgentSkills COMMAND DOCUMENTATION ---\n'
772802
f'{AgentSkillsRequirement().documentation}\n'

opendevin/runtime/plugins/agent_skills/setup.sh

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
set -e
44

5+
OPENDEVIN_PYTHON_INTERPRETER=/opendevin/miniforge3/bin/python
6+
# check if OPENDEVIN_PYTHON_INTERPRETER exists and it is usable
7+
if [ -z "$OPENDEVIN_PYTHON_INTERPRETER" ] || [ ! -x "$OPENDEVIN_PYTHON_INTERPRETER" ]; then
8+
echo "OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image!"
9+
exit 1
10+
fi
11+
512
# add agent_skills to PATH
613
echo 'export PATH=/opendevin/plugins/agent_skills:$PATH' >> ~/.bashrc
7-
export PATH=/opendevin/plugins/agent_skills:$PATH
814

915
# add agent_skills to PYTHONPATH
1016
echo 'export PYTHONPATH=/opendevin/plugins/agent_skills:$PYTHONPATH' >> ~/.bashrc
11-
export PYTHONPATH=/opendevin/plugins/agent_skills:$PYTHONPATH
1217

13-
pip install flake8 python-docx PyPDF2 python-pptx pylatexenc openai opencv-python
18+
source ~/.bashrc
19+
20+
$OPENDEVIN_PYTHON_INTERPRETER -m pip install flake8 python-docx PyPDF2 python-pptx pylatexenc openai opencv-python

opendevin/runtime/plugins/jupyter/setup.sh

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@
22

33
set -e
44

5+
# Hardcoded to use the Python interpreter from the OpenDevin runtime client
6+
OPENDEVIN_PYTHON_INTERPRETER=/opendevin/miniforge3/bin/python
7+
# check if OPENDEVIN_PYTHON_INTERPRETER exists and it is usable
8+
if [ -z "$OPENDEVIN_PYTHON_INTERPRETER" ] || [ ! -x "$OPENDEVIN_PYTHON_INTERPRETER" ]; then
9+
echo "OPENDEVIN_PYTHON_INTERPRETER is not usable. Please pull the latest Docker image!"
10+
exit 1
11+
fi
12+
13+
# use mamba to install c library
14+
/opendevin/miniforge3/bin/mamba install -y gcc
15+
16+
# Install dependencies
17+
$OPENDEVIN_PYTHON_INTERPRETER -m pip install jupyterlab notebook jupyter_kernel_gateway
18+
519
source ~/.bashrc
620
# ADD /opendevin/plugins to PATH to make `jupyter_cli` available
721
echo 'export PATH=$PATH:/opendevin/plugins/jupyter' >> ~/.bashrc
822
export PATH=/opendevin/plugins/jupyter:$PATH
923

10-
# get current PythonInterpreter
11-
OPENDEVIN_PYTHON_INTERPRETER=$(which python3)
12-
1324
# if user name is `opendevin`, add '/home/opendevin/.local/bin' to PATH
1425
if [ "$USER" = "opendevin" ]; then
1526
echo 'export PATH=$PATH:/home/opendevin/.local/bin' >> ~/.bashrc
@@ -26,12 +37,6 @@ if [ "$USER" = "root" ]; then
2637

2738
fi
2839

29-
# Install dependencies
30-
pip install jupyterlab notebook jupyter_kernel_gateway
31-
32-
# Create logs directory
33-
sudo mkdir -p /opendevin/logs && sudo chmod 777 /opendevin/logs
34-
3540
# Run background process to start jupyter kernel gateway
3641
# write a bash function that finds a free port
3742
find_free_port() {
@@ -50,7 +55,9 @@ find_free_port() {
5055
}
5156

5257
export JUPYTER_GATEWAY_PORT=$(find_free_port 20000 30000)
53-
jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT > /opendevin/logs/jupyter_kernel_gateway.log 2>&1 &
58+
$OPENDEVIN_PYTHON_INTERPRETER -m \
59+
jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=$JUPYTER_GATEWAY_PORT > /opendevin/logs/jupyter_kernel_gateway.log 2>&1 &
60+
5461
export JUPYTER_GATEWAY_PID=$!
5562
echo "export JUPYTER_GATEWAY_PID=$JUPYTER_GATEWAY_PID" >> ~/.bashrc
5663
export JUPYTER_GATEWAY_KERNEL_ID="default"
@@ -60,7 +67,7 @@ echo "JupyterKernelGateway started with PID: $JUPYTER_GATEWAY_PID"
6067
# Start the jupyter_server
6168
export JUPYTER_EXEC_SERVER_PORT=$(find_free_port 30000 40000)
6269
echo "export JUPYTER_EXEC_SERVER_PORT=$JUPYTER_EXEC_SERVER_PORT" >> ~/.bashrc
63-
/opendevin/plugins/jupyter/execute_server > /opendevin/logs/jupyter_execute_server.log 2>&1 &
70+
$OPENDEVIN_PYTHON_INTERPRETER /opendevin/plugins/jupyter/execute_server > /opendevin/logs/jupyter_execute_server.log 2>&1 &
6471
export JUPYTER_EXEC_SERVER_PID=$!
6572
echo "export JUPYTER_EXEC_SERVER_PID=$JUPYTER_EXEC_SERVER_PID" >> ~/.bashrc
6673
echo "Execution server started with PID: $JUPYTER_EXEC_SERVER_PID"

opendevin/runtime/plugins/mixin.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,21 @@ class SandboxProtocol(Protocol):
1313
def initialize_plugins(self) -> bool: ...
1414

1515
def execute(
16-
self, cmd: str, stream: bool = False
16+
self, cmd: str, stream: bool = False
1717
) -> tuple[int, str | CancellableStream]: ...
1818

1919
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): ...
2020

2121

22+
def _source_bashrc(sandbox: SandboxProtocol):
23+
exit_code, output = sandbox.execute('source /opendevin/bash.bashrc && source ~/.bashrc')
24+
if exit_code != 0:
25+
raise RuntimeError(
26+
f'Failed to source /opendevin/bash.bashrc and ~/.bashrc with exit code {exit_code} and output: {output}'
27+
)
28+
logger.info('Sourced /opendevin/bash.bashrc and ~/.bashrc successfully')
29+
30+
2231
class PluginMixin:
2332
"""Mixin for Sandbox to support plugins."""
2433

@@ -35,6 +44,9 @@ def init_plugins(self: SandboxProtocol, requirements: list[PluginRequirement]):
3544
exit_code, output = self.execute('rm -f ~/.bashrc && touch ~/.bashrc')
3645

3746
for requirement in requirements:
47+
# source bashrc file when plugin loads
48+
_source_bashrc(self)
49+
3850
# copy over the files
3951
self.copy_to(
4052
requirement.host_src, requirement.sandbox_dest, recursive=True
@@ -62,7 +74,7 @@ def init_plugins(self: SandboxProtocol, requirements: list[PluginRequirement]):
6274
output.close()
6375
if _exit_code != 0:
6476
raise RuntimeError(
65-
f'Failed to initialize plugin {requirement.name} with exit code {_exit_code} and output {total_output}'
77+
f'Failed to initialize plugin {requirement.name} with exit code {_exit_code} and output: {total_output}'
6678
)
6779
logger.info(f'Plugin {requirement.name} initialized successfully')
6880
else:
@@ -75,11 +87,6 @@ def init_plugins(self: SandboxProtocol, requirements: list[PluginRequirement]):
7587
logger.info('Skipping plugin initialization in the sandbox')
7688

7789
if len(requirements) > 0:
78-
exit_code, output = self.execute('source ~/.bashrc')
79-
if exit_code != 0:
80-
raise RuntimeError(
81-
f'Failed to source ~/.bashrc with exit code {exit_code} and output: {output}'
82-
)
83-
logger.info('Sourced ~/.bashrc successfully')
90+
_source_bashrc(self)
8491

8592
self.plugin_initialized = True
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from unittest.mock import MagicMock, patch
2+
from opendevin.runtime.docker.image_agnostic_util import (
3+
generate_dockerfile_content,
4+
_get_new_image_name,
5+
get_od_sandbox_image,
6+
)
7+
8+
9+
def test_generate_dockerfile_content():
10+
base_image = "debian:11"
11+
dockerfile_content = generate_dockerfile_content(base_image)
12+
assert base_image in dockerfile_content
13+
assert "RUN apt update && apt install -y openssh-server wget sudo" in dockerfile_content
14+
15+
16+
def test_get_new_image_name():
17+
base_image = "debian:11"
18+
new_image_name = _get_new_image_name(base_image)
19+
assert new_image_name == "od_sandbox:debian__11"
20+
21+
base_image = "ubuntu:22.04"
22+
new_image_name = _get_new_image_name(base_image)
23+
assert new_image_name == "od_sandbox:ubuntu__22.04"
24+
25+
base_image = "ubuntu"
26+
new_image_name = _get_new_image_name(base_image)
27+
assert new_image_name == "od_sandbox:ubuntu__latest"
28+
29+
30+
@patch("opendevin.runtime.docker.image_agnostic_util._build_sandbox_image")
31+
@patch("opendevin.runtime.docker.image_agnostic_util.docker.DockerClient")
32+
def test_get_od_sandbox_image(mock_docker_client, mock_build_sandbox_image):
33+
base_image = "debian:11"
34+
mock_docker_client.images.list.return_value = [MagicMock(tags=["od_sandbox:debian__11"])]
35+
36+
image_name = get_od_sandbox_image(base_image, mock_docker_client)
37+
assert image_name == "od_sandbox:debian__11"
38+
39+
mock_docker_client.images.list.return_value = []
40+
image_name = get_od_sandbox_image(base_image, mock_docker_client)
41+
assert image_name == "od_sandbox:debian__11"
42+
mock_build_sandbox_image.assert_called_once_with(base_image, "od_sandbox:debian__11", mock_docker_client)

tests/unit/test_ipython.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def test_run_python_backticks():
6363
[
6464
call('mkdir -p /tmp'),
6565
call('git config --global user.name "OpenDevin"'),
66-
call('git config --global user.email "opendevin@opendevin.ai"'),
66+
call('git config --global user.email "opendevin@all-hands.dev"'),
6767
call(expected_write_command),
6868
call(expected_execute_command),
6969
]

0 commit comments

Comments
 (0)