diff --git a/.gitignore b/.gitignore index 83bc4a7c3a..a3cbfe7af5 100644 --- a/.gitignore +++ b/.gitignore @@ -211,3 +211,5 @@ cache # configuration config.toml config.toml.bak + +containers/agnostic_sandbox \ No newline at end of file diff --git a/containers/sandbox/Dockerfile b/containers/sandbox/Dockerfile index 7e4c1ee5b0..e822089d8f 100644 --- a/containers/sandbox/Dockerfile +++ b/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 \ No newline at end of file +RUN /opendevin/miniforge3/bin/pip install python-docx PyPDF2 python-pptx pylatexenc openai opencv-python diff --git a/opendevin/runtime/docker/image_agnostic_util.py b/opendevin/runtime/docker/image_agnostic_util.py new file mode 100644 index 0000000000..b33b412f9b --- /dev/null +++ b/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 diff --git a/opendevin/runtime/docker/ssh_box.py b/opendevin/runtime/docker/ssh_box.py index 6359fa6038..04b8c10a86 100644 --- a/opendevin/runtime/docker/ssh_box.py +++ b/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' diff --git a/opendevin/runtime/plugins/agent_skills/setup.sh b/opendevin/runtime/plugins/agent_skills/setup.sh index 659d5db8fd..c7cc5a91ad 100755 --- a/opendevin/runtime/plugins/agent_skills/setup.sh +++ b/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 diff --git a/opendevin/runtime/plugins/jupyter/setup.sh b/opendevin/runtime/plugins/jupyter/setup.sh index 4bfd7e3fde..ef6bbdcefb 100755 --- a/opendevin/runtime/plugins/jupyter/setup.sh +++ b/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" diff --git a/opendevin/runtime/plugins/mixin.py b/opendevin/runtime/plugins/mixin.py index 2b5491ae03..d2dc5882bd 100644 --- a/opendevin/runtime/plugins/mixin.py +++ b/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 diff --git a/tests/unit/test_image_agnostic_util.py b/tests/unit/test_image_agnostic_util.py new file mode 100644 index 0000000000..8daa229747 --- /dev/null +++ b/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) diff --git a/tests/unit/test_ipython.py b/tests/unit/test_ipython.py index 012e2ab324..1b14984af7 100644 --- a/tests/unit/test_ipython.py +++ b/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), ] diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py index 58b44052b7..0bbb6a0680 100644 --- a/tests/unit/test_sandbox.py +++ b/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__ - ) + _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) \ No newline at end of file