diff --git a/.github/workflows/dummy-agent-test.yml b/.github/workflows/dummy-agent-test.yml index cddb4c0276..6cf4d5900c 100644 --- a/.github/workflows/dummy-agent-test.yml +++ b/.github/workflows/dummy-agent-test.yml @@ -11,9 +11,6 @@ on: - main pull_request: -env: - PERSIST_SANDBOX : 'false' - jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index 5710b03774..9b757da9c5 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -25,7 +25,7 @@ on: default: '' jobs: - # Builds the OpenDevin and sandbox Docker images + # Builds the OpenDevin Docker images ghcr_build: runs-on: ubuntu-latest outputs: @@ -35,7 +35,7 @@ jobs: packages: write strategy: matrix: - image: ['sandbox', 'opendevin'] + image: ['opendevin'] platform: ['amd64', 'arm64'] steps: - name: Checkout @@ -142,11 +142,9 @@ jobs: name: Test Runtime runs-on: ubuntu-latest needs: [ghcr_build_runtime, ghcr_build] - env: - PERSIST_SANDBOX: 'false' strategy: matrix: - runtime_type: ['eventstream', 'server'] + runtime_type: ['eventstream'] steps: - uses: actions/checkout@v4 - name: Free Disk Space (Ubuntu) @@ -204,60 +202,11 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Run integration tests with the sandbox Docker images - integration_tests_on_linux: - name: Integration Tests on Linux - runs-on: ubuntu-latest - needs: ghcr_build - env: - PERSIST_SANDBOX: "false" - strategy: - fail-fast: false - matrix: - python-version: ['3.11'] - sandbox: ['ssh', 'local'] - steps: - - uses: actions/checkout@v4 - - name: Install poetry via pipx - run: pipx install poetry - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'poetry' - - name: Install Python dependencies using Poetry - run: make install-python-dependencies - - name: Download sandbox Docker image - uses: actions/download-artifact@v4 - with: - name: sandbox-docker-image-amd64 - path: /tmp/ - - name: Load sandbox image and run integration tests - env: - SANDBOX_BOX_TYPE: ${{ matrix.sandbox }} - run: | - # Load the Docker image and capture the output - output=$(docker load -i /tmp/sandbox_image_amd64.tar) - - # Extract the first image name from the output - image_name=$(echo "$output" | grep -oP 'Loaded image: \K.*' | head -n 1) - - # Print the full name of the image - echo "Loaded Docker image: $image_name" - - SANDBOX_CONTAINER_IMAGE=$image_name TEST_IN_CI=true TEST_ONLY=true TEST_RUNTIME=server ./tests/integration/regenerate.sh - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Run integration tests with the eventstream runtime Docker image runtime_integration_tests_on_linux: name: Runtime Integration Tests on Linux runs-on: ubuntu-latest needs: [ghcr_build_runtime] - env: - PERSIST_SANDBOX: 'false' strategy: fail-fast: false matrix: @@ -305,7 +254,7 @@ jobs: # Push the OpenDevin and sandbox Docker images to the ghcr.io repository ghcr_push: runs-on: ubuntu-latest - needs: [ghcr_build, test_runtime, integration_tests_on_linux] + needs: [ghcr_build] if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') env: tags: ${{ needs.ghcr_build.outputs.tags }} @@ -314,7 +263,7 @@ jobs: packages: write strategy: matrix: - image: ['sandbox', 'opendevin'] + image: ['opendevin'] platform: ['amd64', 'arm64'] steps: - name: Checkout code @@ -347,7 +296,7 @@ jobs: # Push the runtime Docker images to the ghcr.io repository ghcr_push_runtime: runs-on: ubuntu-latest - needs: [ghcr_build_runtime, test_runtime, integration_tests_on_linux] + needs: [ghcr_build_runtime, test_runtime, runtime_integration_tests_on_linux] if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') env: tags: ${{ needs.ghcr_build_runtime.outputs.tags }} @@ -416,7 +365,7 @@ jobs: tags: ${{ needs.ghcr_build.outputs.tags }} strategy: matrix: - image: ['sandbox', 'opendevin'] + image: ['opendevin'] permissions: contents: read packages: write diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6cd3b7b51b..0badedf957 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -16,8 +16,6 @@ on: - 'evaluation/**' pull_request: -env: - PERSIST_SANDBOX : "false" jobs: # Run frontend unit tests diff --git a/Makefile b/Makefile index 8472d234a1..c4f145c15d 100644 --- a/Makefile +++ b/Makefile @@ -23,9 +23,6 @@ RESET=$(shell tput -Txterm sgr0) build: @echo "$(GREEN)Building project...$(RESET)" @$(MAKE) -s check-dependencies -ifeq ($(INSTALL_DOCKER),) - @$(MAKE) -s pull-docker-image -endif @$(MAKE) -s install-python-dependencies @$(MAKE) -s install-frontend-dependencies @$(MAKE) -s install-pre-commit-hooks @@ -124,11 +121,6 @@ check-poetry: exit 1; \ fi -pull-docker-image: - @echo "$(YELLOW)Pulling Docker image...$(RESET)" - @docker pull $(DOCKER_IMAGE) - @echo "$(GREEN)Docker image pulled successfully.$(RESET)" - install-python-dependencies: @echo "$(GREEN)Installing Python dependencies...$(RESET)" @if [ -z "${TZ}" ]; then \ @@ -246,16 +238,6 @@ setup-config-prompts: workspace_dir=$${workspace_dir:-$(DEFAULT_WORKSPACE_DIR)}; \ echo "workspace_base=\"$$workspace_dir\"" >> $(CONFIG_FILE).tmp - @read -p "Do you want to persist the sandbox container? [true/false] [default: false]: " persist_sandbox; \ - persist_sandbox=$${persist_sandbox:-false}; \ - if [ "$$persist_sandbox" = "true" ]; then \ - read -p "Enter a password for the sandbox container: " ssh_password; \ - echo "ssh_password=\"$$ssh_password\"" >> $(CONFIG_FILE).tmp; \ - echo "persist_sandbox=$$persist_sandbox" >> $(CONFIG_FILE).tmp; \ - else \ - echo "persist_sandbox=$$persist_sandbox" >> $(CONFIG_FILE).tmp; \ - fi - @echo "" >> $(CONFIG_FILE).tmp @echo "[llm]" >> $(CONFIG_FILE).tmp @@ -316,4 +298,4 @@ help: @echo " $(GREEN)help$(RESET) - Display this help message, providing information on available targets." # Phony targets -.PHONY: build check-dependencies check-python check-npm check-docker check-poetry pull-docker-image install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint start-backend start-frontend run run-wsl setup-config setup-config-prompts help +.PHONY: build check-dependencies check-python check-npm check-docker check-poetry install-python-dependencies install-frontend-dependencies install-pre-commit-hooks lint start-backend start-frontend run run-wsl setup-config setup-config-prompts help diff --git a/agenthub/dummy_agent/agent.py b/agenthub/dummy_agent/agent.py index 6563aef415..f0fa8de9b4 100644 --- a/agenthub/dummy_agent/agent.py +++ b/agenthub/dummy_agent/agent.py @@ -208,9 +208,3 @@ class DummyAgent(Agent): f' Unable to perform interactive browsing: {action.browser_actions}' ) return MessageAction(content=message) - - async def get_working_directory(self, state: State) -> str: - # Implement this method to return the current working directory - # This might involve accessing state information or making an async call - # For now, we'll return a placeholder value - return './workspace' diff --git a/config.template.toml b/config.template.toml index 1ce48dd517..b842cb4ee7 100644 --- a/config.template.toml +++ b/config.template.toml @@ -55,8 +55,6 @@ workspace_base = "./workspace" # Path to rewrite the workspace mount path to #workspace_mount_rewrite = "" -# Persist the sandbox -persist_sandbox = false # Run as devin #run_as_devin = true diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx index 7a8615fc17..7691dc143e 100644 --- a/frontend/src/components/file-explorer/FileExplorer.tsx +++ b/frontend/src/components/file-explorer/FileExplorer.tsx @@ -108,7 +108,7 @@ function FileExplorer() { } dispatch(setRefreshID(Math.random())); try { - const fileList = await listFiles("/"); + const fileList = await listFiles(); setFiles(fileList); } catch (error) { toast.error("refresh-error", t(I18nKey.EXPLORER$REFRESH_ERROR_MESSAGE)); diff --git a/frontend/src/services/fileService.ts b/frontend/src/services/fileService.ts index bdbae8991e..77751c43f4 100644 --- a/frontend/src/services/fileService.ts +++ b/frontend/src/services/fileService.ts @@ -67,10 +67,14 @@ export async function uploadFiles(files: FileList): Promise { }; } -export async function listFiles(path: string = "/"): Promise { - const data = await request( - `/api/list-files?path=${encodeURIComponent(path)}`, - ); +export async function listFiles( + path: string | undefined = undefined, +): Promise { + let url = "/api/list-files"; + if (path) { + url = `/api/list-files?path=${encodeURIComponent(path)}`; + } + const data = await request(url); if (!Array.isArray(data)) { throw new Error("Invalid response format: data is not an array"); } diff --git a/opendevin/core/config.py b/opendevin/core/config.py index 4deb577fb5..516843c473 100644 --- a/opendevin/core/config.py +++ b/opendevin/core/config.py @@ -166,10 +166,8 @@ class SandboxConfig(metaclass=Singleton): """ box_type: str = 'ssh' - container_image: str = 'ghcr.io/opendevin/sandbox' + ( - f':{os.getenv("OPEN_DEVIN_BUILD_VERSION")}' - if os.getenv('OPEN_DEVIN_BUILD_VERSION') - else ':main' + container_image: str = ( + 'ubuntu:22.04' # default to ubuntu:22.04 for eventstream runtime ) user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000 timeout: int = 120 @@ -241,7 +239,7 @@ class AppConfig(metaclass=Singleton): agents: dict = field(default_factory=dict) default_agent: str = _DEFAULT_AGENT sandbox: SandboxConfig = field(default_factory=SandboxConfig) - runtime: str = 'server' + runtime: str = 'eventstream' file_store: str = 'memory' file_store_path: str = '/tmp/file_store' # TODO: clean up workspace path after the removal of ServerRuntime @@ -259,7 +257,6 @@ class AppConfig(metaclass=Singleton): e2b_api_key: str = '' ssh_hostname: str = 'localhost' disable_color: bool = False - persist_sandbox: bool = False ssh_port: int = 63710 ssh_password: str | None = None jwt_secret: str = uuid.uuid4().hex diff --git a/opendevin/core/main.py b/opendevin/core/main.py index 9a60efac5c..9e89d5385b 100644 --- a/opendevin/core/main.py +++ b/opendevin/core/main.py @@ -1,5 +1,4 @@ import asyncio -import os import sys import uuid from typing import Callable, Type @@ -23,7 +22,6 @@ from opendevin.events.observation import AgentStateChangedObservation from opendevin.llm.llm import LLM from opendevin.runtime import get_runtime_cls from opendevin.runtime.runtime import Runtime -from opendevin.runtime.server.runtime import ServerRuntime from opendevin.storage import get_file_store @@ -68,22 +66,6 @@ async def create_runtime( ) await runtime.ainit() - if isinstance(runtime, ServerRuntime): - runtime.init_runtime_tools( - agent_cls.runtime_tools, - runtime_tools_config=runtime_tools_config, - ) - # browser eval specific - # NOTE: This will be deprecated when we move to the new runtime - if runtime.browser and runtime.browser.eval_dir: - logger.info(f'Evaluation directory: {runtime.browser.eval_dir}') - with open( - os.path.join(runtime.browser.eval_dir, 'goal.txt'), - 'r', - encoding='utf-8', - ) as f: - task_str = f.read() - logger.info(f'Dynamic Eval task: {task_str}') return runtime diff --git a/opendevin/runtime/__init__.py b/opendevin/runtime/__init__.py index fe2fa828ea..26d690826f 100644 --- a/opendevin/runtime/__init__.py +++ b/opendevin/runtime/__init__.py @@ -1,16 +1,10 @@ -from .docker.local_box import LocalBox -from .docker.ssh_box import DockerSSHBox from .e2b.sandbox import E2BBox from .sandbox import Sandbox def get_runtime_cls(name: str): # Local imports to avoid circular imports - if name == 'server': - from .server.runtime import ServerRuntime - - return ServerRuntime - elif name == 'eventstream': + if name == 'eventstream': from .client.runtime import EventStreamRuntime return EventStreamRuntime diff --git a/opendevin/runtime/client/client.py b/opendevin/runtime/client/client.py index 29044f6d61..539858f411 100644 --- a/opendevin/runtime/client/client.py +++ b/opendevin/runtime/client/client.py @@ -17,6 +17,8 @@ from pathlib import Path import pexpect from fastapi import FastAPI, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse +from pathspec import PathSpec +from pathspec.patterns import GitWildMatchPattern from pydantic import BaseModel from uvicorn import run @@ -46,8 +48,8 @@ from opendevin.runtime.plugins import ( JupyterPlugin, Plugin, ) -from opendevin.runtime.server.files import insert_lines, read_lines from opendevin.runtime.utils import split_bash_commands +from opendevin.runtime.utils.files import insert_lines, read_lines class ActionRequest(BaseModel): @@ -76,6 +78,11 @@ class RuntimeClient: self.lock = asyncio.Lock() self.plugins: dict[str, Plugin] = {} self.browser = BrowserEnv(browsergym_eval_env) + self._initial_pwd = work_dir + + @property + def initial_pwd(self): + return self._initial_pwd async def ainit(self): for plugin in self.plugins_to_load: @@ -499,6 +506,130 @@ if __name__ == '__main__': async def alive(): return {'status': 'ok'} + # ================================ + # File-specific operations for UI + # ================================ + + @app.post('/list_files') + async def list_files(request: Request): + """List files in the specified path. + + This function retrieves a list of files from the agent's runtime file store, + excluding certain system and hidden files/directories. + + To list files: + ```sh + curl http://localhost:3000/api/list-files + ``` + + Args: + request (Request): The incoming request object. + path (str, optional): The path to list files from. Defaults to '/'. + + Returns: + list: A list of file names in the specified path. + + Raises: + HTTPException: If there's an error listing the files. + """ + assert client is not None + + # get request as dict + request_dict = await request.json() + path = request_dict.get('path', None) + + # Get the full path of the requested directory + if path is None: + full_path = client.initial_pwd + elif os.path.isabs(path): + full_path = path + else: + full_path = os.path.join(client.initial_pwd, path) + + if not os.path.exists(full_path): + return JSONResponse( + content={'error': f'Directory {full_path} does not exist'}, + status_code=400, + ) + + try: + # Check if the directory exists + if not os.path.exists(full_path) or not os.path.isdir(full_path): + return [] + + # Check if .gitignore exists + gitignore_path = os.path.join(full_path, '.gitignore') + if os.path.exists(gitignore_path): + # Use PathSpec to parse .gitignore + with open(gitignore_path, 'r') as f: + spec = PathSpec.from_lines(GitWildMatchPattern, f.readlines()) + else: + # Fallback to default exclude list if .gitignore doesn't exist + default_exclude = [ + '.git', + '.DS_Store', + '.svn', + '.hg', + '.idea', + '.vscode', + '.settings', + '.pytest_cache', + '__pycache__', + 'node_modules', + 'vendor', + 'build', + 'dist', + 'bin', + 'logs', + 'log', + 'tmp', + 'temp', + 'coverage', + 'venv', + 'env', + ] + spec = PathSpec.from_lines(GitWildMatchPattern, default_exclude) + + entries = os.listdir(full_path) + + # Filter entries using PathSpec + filtered_entries = [ + os.path.join(full_path, entry) + for entry in entries + if not spec.match_file(os.path.relpath(entry, str(full_path))) + ] + + # Separate directories and files + directories = [] + files = [] + for entry in filtered_entries: + # Remove leading slash and any parent directory components + entry_relative = entry.lstrip('/').split('/')[-1] + + # Construct the full path by joining the base path with the relative entry path + full_entry_path = os.path.join(full_path, entry_relative) + if os.path.exists(full_entry_path): + is_dir = os.path.isdir(full_entry_path) + if is_dir: + # add trailing slash to directories + # required by FE to differentiate directories and files + entry = entry.rstrip('/') + '/' + directories.append(entry) + else: + files.append(entry) + + # Sort directories and files separately + directories.sort(key=lambda s: s.lower()) + files.sort(key=lambda s: s.lower()) + + # Combine sorted directories and files + sorted_entries = directories + files + return sorted_entries + + except Exception as e: + logger.error(f'Error listing files: {e}', exc_info=True) + return [] + logger.info(f'Starting action execution API on port {args.port}') print(f'Starting action execution API on port {args.port}') run(app, host='0.0.0.0', port=args.port) diff --git a/opendevin/runtime/client/runtime.py b/opendevin/runtime/client/runtime.py index b43b1cc0d2..d646b76fcc 100644 --- a/opendevin/runtime/client/runtime.py +++ b/opendevin/runtime/client/runtime.py @@ -2,7 +2,7 @@ import asyncio import os import tempfile import uuid -from typing import Any, Optional +from typing import Optional from zipfile import ZipFile import aiohttp @@ -22,6 +22,7 @@ from opendevin.events.action import ( ) from opendevin.events.action.action import Action from opendevin.events.observation import ( + CmdOutputObservation, ErrorObservation, NullObservation, Observation, @@ -30,7 +31,6 @@ from opendevin.events.serialization import event_to_dict, observation_from_dict from opendevin.events.serialization.action import ACTION_TYPE_TO_CLASS from opendevin.runtime.plugins import PluginRequirement from opendevin.runtime.runtime import Runtime -from opendevin.runtime.tools import RuntimeTool from opendevin.runtime.utils import find_available_tcp_port from opendevin.runtime.utils.runtime_build import build_runtime_image @@ -98,6 +98,8 @@ class EventStreamRuntime(Runtime): ) logger.info(f'Container initialized with env vars: {env_vars}') + await self._init_git_config() + @staticmethod def _init_docker_client() -> docker.DockerClient: try: @@ -181,6 +183,16 @@ class EventStreamRuntime(Runtime): await self.close(close_client=False) raise e + async def _init_git_config(self): + action = CmdRunAction( + 'git config --global user.name "opendevin" && ' + 'git config --global user.email "opendevin@all-hands.dev"' + ) + logger.info(f'Setting git config: {action}') + obs: Observation = await self.run_action(action) + assert isinstance(obs, CmdOutputObservation) + assert obs.exit_code == 0, f'Failed to set git config: {obs}' + async def _ensure_session(self): await asyncio.sleep(1) if self.session is None or self.session.closed: @@ -224,56 +236,6 @@ class EventStreamRuntime(Runtime): if close_client: self.docker_client.close() - async def copy_to( - self, host_src: str, sandbox_dest: str, recursive: bool = False - ) -> None: - if not os.path.exists(host_src): - raise FileNotFoundError(f'Source file {host_src} does not exist') - - session = await self._ensure_session() - await self._wait_until_alive() - try: - if recursive: - # For recursive copy, create a zip file - with tempfile.NamedTemporaryFile( - suffix='.zip', delete=False - ) as temp_zip: - temp_zip_path = temp_zip.name - - with ZipFile(temp_zip_path, 'w') as zipf: - for root, _, files in os.walk(host_src): - for file in files: - file_path = os.path.join(root, file) - arcname = os.path.relpath( - file_path, os.path.dirname(host_src) - ) - zipf.write(file_path, arcname) - - upload_data = {'file': open(temp_zip_path, 'rb')} - else: - # For single file copy - upload_data = {'file': open(host_src, 'rb')} - - params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()} - - async with session.post( - f'{self.api_url}/upload_file', data=upload_data, params=params - ) as response: - if response.status == 200: - return - else: - error_message = await response.text() - raise Exception(f'Copy operation failed: {error_message}') - - except asyncio.TimeoutError: - raise TimeoutError('Copy operation timed out') - except Exception as e: - raise RuntimeError(f'Copy operation failed: {str(e)}') - finally: - if recursive: - os.unlink(temp_zip_path) - logger.info(f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}') - async def run_action(self, action: Action) -> Observation: # set timeout to default if not set if action.timeout is None: @@ -340,19 +302,83 @@ class EventStreamRuntime(Runtime): async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation: return await self.run_action(action) - ############################################################################ - # Keep the same with other runtimes - ############################################################################ + # ==================================================================== + # Implement these methods (for file operations) in the subclass + # ==================================================================== - def get_working_directory(self): - raise NotImplementedError( - 'This method is not implemented in the runtime client.' - ) - - def init_runtime_tools( - self, - runtime_tools: list[RuntimeTool], - runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None, + async def copy_to( + self, host_src: str, sandbox_dest: str, recursive: bool = False ) -> None: - # TODO: deprecate this method when we move to the new EventStreamRuntime - logger.warning('init_runtime_tools is not implemented in the runtime client.') + if not os.path.exists(host_src): + raise FileNotFoundError(f'Source file {host_src} does not exist') + + session = await self._ensure_session() + await self._wait_until_alive() + try: + if recursive: + # For recursive copy, create a zip file + with tempfile.NamedTemporaryFile( + suffix='.zip', delete=False + ) as temp_zip: + temp_zip_path = temp_zip.name + + with ZipFile(temp_zip_path, 'w') as zipf: + for root, _, files in os.walk(host_src): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath( + file_path, os.path.dirname(host_src) + ) + zipf.write(file_path, arcname) + + upload_data = {'file': open(temp_zip_path, 'rb')} + else: + # For single file copy + upload_data = {'file': open(host_src, 'rb')} + + params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()} + + async with session.post( + f'{self.api_url}/upload_file', data=upload_data, params=params + ) as response: + if response.status == 200: + return + else: + error_message = await response.text() + raise Exception(f'Copy operation failed: {error_message}') + + except asyncio.TimeoutError: + raise TimeoutError('Copy operation timed out') + except Exception as e: + raise RuntimeError(f'Copy operation failed: {str(e)}') + finally: + if recursive: + os.unlink(temp_zip_path) + logger.info(f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}') + + async def list_files(self, path: str | None = None) -> list[str]: + """List files in the sandbox. + + If path is None, list files in the sandbox's initial working directory (e.g., /workspace). + """ + session = await self._ensure_session() + await self._wait_until_alive() + try: + data = {} + if path is not None: + data['path'] = path + + async with session.post( + f'{self.api_url}/list_files', json=data + ) as response: + if response.status == 200: + response_json = await response.json() + assert isinstance(response_json, list) + return response_json + else: + error_message = await response.text() + raise Exception(f'List files operation failed: {error_message}') + except asyncio.TimeoutError: + raise TimeoutError('List files operation timed out') + except Exception as e: + raise RuntimeError(f'List files operation failed: {str(e)}') diff --git a/opendevin/runtime/docker/__init__.py b/opendevin/runtime/docker/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/opendevin/runtime/docker/local_box.py b/opendevin/runtime/docker/local_box.py deleted file mode 100644 index 5da1707939..0000000000 --- a/opendevin/runtime/docker/local_box.py +++ /dev/null @@ -1,122 +0,0 @@ -import atexit -import os -import subprocess -import sys - -from opendevin.core.config import SandboxConfig -from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import CancellableStream -from opendevin.runtime.sandbox import Sandbox - -# =============================================================================== -# ** WARNING ** -# -# This sandbox should only be used when OpenDevin is running inside a container -# -# Sandboxes are generally isolated so that they cannot affect the host machine. -# This Sandbox implementation does not provide isolation, and can inadvertently -# run dangerous commands on the host machine, potentially rendering the host -# machine unusable. -# -# This sandbox is meant for use with OpenDevin Quickstart -# -# DO NOT USE THIS SANDBOX IN A PRODUCTION ENVIRONMENT -# =============================================================================== - - -class LocalBox(Sandbox): - def __init__( - self, - config: SandboxConfig, - workspace_base: str, - ): - self.config = config - os.makedirs(workspace_base, exist_ok=True) - self.workspace_base = workspace_base - atexit.register(self.cleanup) - super().__init__(config) - - def execute( - self, cmd: str, stream: bool = False, timeout: int | None = None - ) -> tuple[int, str | CancellableStream]: - try: - completed_process = subprocess.run( - cmd, - shell=True, - text=True, - capture_output=True, - timeout=self.config.timeout, - cwd=self.workspace_base, - env=self._env, - ) - return completed_process.returncode, completed_process.stdout.strip() - except subprocess.TimeoutExpired: - return -1, 'Command timed out' - - def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): - # mkdir -p sandbox_dest if it doesn't exist - res = subprocess.run( - f'mkdir -p {sandbox_dest}', - shell=True, - text=True, - cwd=self.workspace_base, - env=self._env, - ) - if res.returncode != 0: - raise RuntimeError(f'Failed to create directory {sandbox_dest} in sandbox') - - if recursive: - res = subprocess.run( - f'cp -r {host_src} {sandbox_dest}', - shell=True, - text=True, - cwd=self.workspace_base, - env=self._env, - ) - if res.returncode != 0: - raise RuntimeError( - f'Failed to copy {host_src} to {sandbox_dest} in sandbox' - ) - else: - res = subprocess.run( - f'cp {host_src} {sandbox_dest}', - shell=True, - text=True, - cwd=self.workspace_base, - env=self._env, - ) - if res.returncode != 0: - raise RuntimeError( - f'Failed to copy {host_src} to {sandbox_dest} in sandbox' - ) - - def close(self): - pass - - def cleanup(self): - self.close() - - def get_working_directory(self): - return self.workspace_base - - -if __name__ == '__main__': - local_box = LocalBox(SandboxConfig(), '/tmp/opendevin') - sys.stdout.flush() - try: - while True: - try: - user_input = input('>>> ') - except EOFError: - logger.info('Exiting...') - break - if user_input.lower() == 'exit': - logger.info('Exiting...') - break - exit_code, output = local_box.execute(user_input) - logger.info('exit code: %d', exit_code) - logger.info(output) - sys.stdout.flush() - except KeyboardInterrupt: - logger.info('Exiting...') - local_box.close() diff --git a/opendevin/runtime/docker/ssh_box.py b/opendevin/runtime/docker/ssh_box.py deleted file mode 100644 index 2de69bc713..0000000000 --- a/opendevin/runtime/docker/ssh_box.py +++ /dev/null @@ -1,693 +0,0 @@ -import atexit -import os -import re -import sys -import tarfile -import tempfile -import time -import uuid -from glob import glob - -import docker -from pexpect import exceptions, pxssh -from tenacity import retry, stop_after_attempt, wait_fixed - -from opendevin.core.config import SandboxConfig -from opendevin.core.const.guide_url import TROUBLESHOOTING_URL -from opendevin.core.logger import opendevin_logger as logger -from opendevin.core.schema import CancellableStream -from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement -from opendevin.runtime.plugins.requirement import PluginRequirement -from opendevin.runtime.sandbox import Sandbox -from opendevin.runtime.utils import find_available_tcp_port, split_bash_commands -from opendevin.runtime.utils.image_agnostic import get_od_sandbox_image - - -class SSHExecCancellableStream(CancellableStream): - def __init__(self, ssh, cmd, timeout): - super().__init__(self.read_output()) - self.ssh = ssh - self.cmd = cmd - self.timeout = timeout - - def close(self): - self.closed = True - - def exit_code(self): - marker = f'EXIT_CODE_MARKER_{uuid.uuid4().hex}' - self.ssh.sendline(f'echo "{marker}$?{marker}"') - - if not self.ssh.prompt(timeout=self.timeout): - return None # Timeout occurred - - output = self.ssh.before - match = re.search(f'{marker}(\\d+){marker}', output) - - if match: - try: - return int(match.group(1)) - except ValueError: - # Log the unexpected format - logger.error(f'Unexpected exit code format: {match.group(1)}') - return None - else: - # If we can't find our marked exit code, log the output and return None - logger.error(f'Could not find exit code in output: {output}') - return None - - def read_output(self): - st = time.time() - buf = '' - crlf = '\r\n' - lf = '\n' - prompt_len = len(self.ssh.PROMPT) - while True: - try: - if self.closed: - break - _output = self.ssh.read_nonblocking(timeout=1) - if not _output: - continue - - buf += _output - - if len(buf) < prompt_len: - continue - - match = re.search(self.ssh.PROMPT, buf) - if match: - idx, _ = match.span() - yield buf[:idx].replace(crlf, lf) - buf = '' - break - - res = buf[:-prompt_len] - if len(res) == 0 or res.find(crlf) == -1: - continue - buf = buf[-prompt_len:] - yield res.replace(crlf, lf) - except exceptions.TIMEOUT: - if time.time() - st < self.timeout: - match = re.search(self.ssh.PROMPT, buf) - if match: - idx, _ = match.span() - yield buf[:idx].replace(crlf, lf) - break - continue - else: - yield buf.replace(crlf, lf) - break - except exceptions.EOF: - break - - -class DockerSSHBox(Sandbox): - instance_id: str - container_image: str - container_name_prefix = 'opendevin-sandbox-' - container_name: str - container: docker.models.containers.Container - docker_client: docker.DockerClient - - _ssh_password: str - _ssh_port: int - ssh: pxssh.pxssh | None = None - - def __init__( - self, - config: SandboxConfig, - persist_sandbox: bool, - workspace_mount_path: str | None, - sandbox_workspace_dir: str, - cache_dir: str, - run_as_devin: bool, - ssh_hostname: str = 'host.docker.internal', - ssh_password: str | None = None, - ssh_port: int = 22, - sid: str | None = None, - ): - self.config = config - self.workspace_mount_path = workspace_mount_path - self.sandbox_workspace_dir = sandbox_workspace_dir - self.cache_dir = cache_dir - self.use_host_network = config.use_host_network - self.run_as_devin = run_as_devin - logger.info( - f'SSHBox is running as {"opendevin" if self.run_as_devin else "root"} user with USER_ID={config.user_id} in the sandbox' - ) - # Initialize docker client. Throws an exception if Docker is not reachable. - try: - self.docker_client = docker.from_env() - except Exception as ex: - logger.exception( - f'Error creating controller. Please check Docker is running and visit `{TROUBLESHOOTING_URL}` for more debugging information.', - exc_info=False, - ) - raise ex - - if persist_sandbox: - if not self.run_as_devin: - raise Exception( - 'Persistent sandbox is currently designed for opendevin user only. Please set run_as_devin=True in your config.toml' - ) - self.instance_id = 'persisted' - else: - self.instance_id = (sid or '') + str(uuid.uuid4()) - - self.container_image = get_od_sandbox_image( - config.container_image, self.docker_client - ) - self.container_name = self.container_name_prefix + self.instance_id - - # set up random user password - self.persist_sandbox = persist_sandbox - self.ssh_hostname = ssh_hostname - if persist_sandbox: - if not ssh_password: - raise ValueError('ssh_password is required for persistent sandbox') - self._ssh_password = ssh_password - self._ssh_port = ssh_port - else: - self._ssh_password = str(uuid.uuid4()) - self._ssh_port = find_available_tcp_port() - try: - docker.DockerClient().containers.get(self.container_name) - self.is_initial_session = False - except docker.errors.NotFound: - self.is_initial_session = True - logger.info('Detected initial session.') - if not persist_sandbox or self.is_initial_session: - logger.info('Creating new Docker container') - n_tries = 5 - while n_tries > 0: - try: - self.restart_docker_container() - break - except Exception as e: - logger.exception( - 'Failed to start Docker container, retrying...', exc_info=False - ) - n_tries -= 1 - if n_tries == 0: - raise e - time.sleep(5) - self.setup_user() - else: - self.container = self.docker_client.containers.get(self.container_name) - logger.info('Using existing Docker container') - self.start_docker_container() - try: - self.start_ssh_session() - except Exception as e: - self.close() - raise e - time.sleep(1) - - # make sure /tmp always exists - self.execute('mkdir -p /tmp') - # set git config - self.execute('git config --global user.name "OpenDevin"') - self.execute('git config --global user.email "opendevin@all-hands.dev"') - atexit.register(self.close) - super().__init__(config) - - def setup_user(self): - time.sleep(2) - # Make users sudoers passwordless - # TODO(sandbox): add this line in the Dockerfile for next minor version of docker image - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception( - f'Failed to make all users passwordless sudoers in sandbox: {logs}' - ) - - # Check if the opendevin user exists - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', 'id -u opendevin'], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code == 0: - # User exists, delete it - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', 'userdel -r opendevin'], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception(f'Failed to remove opendevin user in sandbox: {logs}') - - if self.run_as_devin: - # Create the opendevin user - exit_code, logs = self.container.exec_run( - [ - '/bin/bash', - '-c', - f'useradd -rm -d /home/opendevin -s /bin/bash -g root -G sudo -u {self.config.user_id} opendevin', - ], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception(f'Failed to create opendevin user in sandbox: {logs}') - exit_code, logs = self.container.exec_run( - [ - '/bin/bash', - '-c', - f"echo 'opendevin:{self._ssh_password}' | chpasswd", - ], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception(f'Failed to set password in sandbox: {logs}') - - # chown the home directory - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', 'chown opendevin:root /home/opendevin'], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - 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( - '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}' - ) - exit_code, logs = self.container.exec_run( - [ - '/bin/bash', - '-c', - f'chown opendevin:root {self.sandbox_workspace_dir}', - ], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - # This is not a fatal error, just a warning - logger.warning( - f'Failed to chown workspace directory for opendevin in sandbox: {logs}. But this should be fine if the {self.sandbox_workspace_dir=} is mounted by the app docker container.' - ) - else: - exit_code, logs = self.container.exec_run( - # change password for root - ['/bin/bash', '-c', f"echo 'root:{self._ssh_password}' | chpasswd"], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception(f'Failed to set password for root in sandbox: {logs}') - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', "echo 'opendevin-sandbox' > /etc/hostname"], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - - # Use the retry decorator, with a maximum of 5 attempts and a fixed wait time of 5 seconds between attempts - @retry(stop=stop_after_attempt(5), wait=wait_fixed(5)) - def __ssh_login(self): - time.sleep(2) - try: - self.ssh = pxssh.pxssh( - echo=False, - timeout=self.config.timeout, - encoding='utf-8', - codec_errors='replace', - ) - hostname = self.ssh_hostname - username = 'opendevin' if self.run_as_devin else 'root' - if self.persist_sandbox: - password_msg = 'using your SSH password' - else: - password_msg = f"using the password '{self._ssh_password}'" - logger.info('Connecting to SSH session...') - hostname_to_log = hostname.replace('host.docker.internal', 'localhost') - ssh_cmd = f'`ssh -v -p {self._ssh_port} {username}@{hostname_to_log}`' - logger.info( - f'You can debug the SSH connection by running: {ssh_cmd} {password_msg}' - ) - self.ssh.login(hostname, username, self._ssh_password, port=self._ssh_port) - logger.info('Connected to SSH session') - except pxssh.ExceptionPxssh as e: - logger.exception( - 'Failed to login to SSH session, retrying...', exc_info=False - ) - raise e - - def start_ssh_session(self): - time.sleep(3) - self.__ssh_login() - assert self.ssh is not None - - # Fix: https://github.com/pexpect/pexpect/issues/669 - self.ssh.sendline("bind 'set enable-bracketed-paste off'") - self.ssh.prompt() - time.sleep(1) - - # cd to workspace - self.ssh.sendline(f'cd {self.sandbox_workspace_dir}') - self.ssh.prompt() - - def get_exec_cmd(self, cmd: str) -> list[str]: - if self.run_as_devin: - return ['su', 'opendevin', '-c', cmd] - else: - return ['/bin/bash', '-c', cmd] - - def _send_interrupt( - self, - cmd: str, - prev_output: str = '', - ignore_last_output: bool = False, - ) -> tuple[int, str]: - assert self.ssh is not None - logger.exception( - f'Command "{cmd}" timed out, killing process...', exc_info=False - ) - # send a SIGINT to the process - self.ssh.sendintr() - self.ssh.prompt() - command_output = prev_output - if not ignore_last_output: - command_output += '\n' + self.ssh.before - return ( - -1, - f'Command: "{cmd}" timed out. Sent SIGINT to the process: {command_output}', - ) - - def execute( - self, cmd: str, stream: bool = False, timeout: int | None = None - ) -> tuple[int, str | CancellableStream]: - assert self.ssh is not None - timeout = timeout or self.config.timeout - commands = split_bash_commands(cmd) - if len(commands) > 1: - all_output = '' - for command in commands: - exit_code, output = self.execute(command) - if all_output: - all_output += '\r\n' - all_output += str(output) - if exit_code != 0: - return exit_code, all_output - return 0, all_output - - self.ssh.sendline(cmd) - if stream: - return 0, SSHExecCancellableStream(self.ssh, cmd, self.config.timeout) - success = self.ssh.prompt(timeout=timeout) - if not success: - return self._send_interrupt(cmd) - command_output = self.ssh.before - - # once out, make sure that we have *every* output, we while loop until we get an empty output - while True: - self.ssh.sendline('\n') - timeout_not_reached = self.ssh.prompt(timeout=1) - if not timeout_not_reached: - logger.debug('TIMEOUT REACHED') - break - output = self.ssh.before - if isinstance(output, str) and output.strip() == '': - break - command_output += output - command_output = command_output.removesuffix('\r\n') - - # get the exit code - self.ssh.sendline('echo $?') - self.ssh.prompt() - exit_code_str = self.ssh.before.strip() - _start_time = time.time() - while not exit_code_str: - self.ssh.prompt(timeout=1) - exit_code_str = self.ssh.before.strip() - if time.time() - _start_time > timeout: - return self._send_interrupt( - cmd, command_output, ignore_last_output=True - ) - cleaned_exit_code_str = exit_code_str.replace('echo $?', '').strip().split()[0] - - try: - exit_code = int(cleaned_exit_code_str) - except ValueError: - logger.error(f'Invalid exit code: {cleaned_exit_code_str}') - # Handle the invalid exit code appropriately (e.g., raise an exception or set a default value) - exit_code = -1 # or some other appropriate default value - - return exit_code, command_output - - def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): - if not os.path.exists(host_src): - raise FileNotFoundError(f'Source file {host_src} does not exist') - - # mkdir -p sandbox_dest if it doesn't exist - exit_code, logs = self.container.exec_run( - ['/bin/bash', '-c', f'mkdir -p {sandbox_dest}'], - workdir=self.sandbox_workspace_dir, - environment=self._env, - ) - if exit_code != 0: - raise Exception( - f'Failed to create directory {sandbox_dest} in sandbox: {logs}' - ) - - # use temp directory to store the tar file to avoid - # conflict of filename when running multi-processes - with tempfile.TemporaryDirectory() as tmp_dir: - if recursive: - assert os.path.isdir( - host_src - ), 'Source must be a directory when recursive is True' - files = glob(host_src + '/**/*', recursive=True) - srcname = os.path.basename(host_src) - tar_filename = os.path.join(tmp_dir, srcname + '.tar') - with tarfile.open(tar_filename, mode='w') as tar: - for file in files: - tar.add( - file, - arcname=os.path.relpath(file, os.path.dirname(host_src)), - ) - else: - assert os.path.isfile( - host_src - ), 'Source must be a file when recursive is False' - srcname = os.path.basename(host_src) - tar_filename = os.path.join(tmp_dir, srcname + '.tar') - with tarfile.open(tar_filename, mode='w') as tar: - tar.add(host_src, arcname=srcname) - - with open(tar_filename, 'rb') as f: - data = f.read() - - self.container.put_archive(sandbox_dest, data) - - def start_docker_container(self): - try: - container = self.docker_client.containers.get(self.container_name) - logger.info('Container status: %s', container.status) - if container.status != 'running': - container.start() - logger.info('Container started') - elapsed = 0 - while container.status != 'running': - time.sleep(1) - elapsed += 1 - if elapsed > self.config.timeout: - break - container = self.docker_client.containers.get(self.container_name) - except Exception: - logger.exception('Failed to start container') - - def remove_docker_container(self): - try: - container = self.docker_client.containers.get(self.container_name) - container.stop() - logger.info('Container stopped') - container.remove() - logger.info('Container removed') - elapsed = 0 - while container.status != 'exited': - time.sleep(1) - elapsed += 1 - if elapsed > self.config.timeout: - break - container = self.docker_client.containers.get(self.container_name) - except docker.errors.NotFound: - pass - - def get_working_directory(self): - exit_code, result = self.execute('pwd') - if exit_code != 0: - raise Exception('Failed to get working directory') - return str(result).strip() - - def is_container_running(self): - try: - container = self.docker_client.containers.get(self.container_name) - if container.status == 'running': - self.container = container - return True - return False - except docker.errors.NotFound: - return False - - @property - def volumes(self): - mount_volumes = { - # mount cache directory to /home/opendevin/.cache for pip cache reuse - self.cache_dir: { - 'bind': ( - '/home/opendevin/.cache' if self.run_as_devin else '/root/.cache' - ), - 'mode': 'rw', - }, - } - if self.workspace_mount_path is not None: - mount_volumes[self.workspace_mount_path] = { - 'bind': self.sandbox_workspace_dir, - 'mode': 'rw', - } - return mount_volumes - - def restart_docker_container(self): - try: - self.remove_docker_container() - except docker.errors.DockerException as ex: - logger.exception('Failed to remove container', exc_info=False) - raise ex - - try: - network_kwargs: dict[str, str | dict[str, int]] = {} - if self.use_host_network: - network_kwargs['network_mode'] = 'host' - else: - # FIXME: This is a temporary workaround for Windows where host network mode has bugs. - # FIXME: Docker Desktop for Mac OS has experimental support for host network mode - network_kwargs['ports'] = {f'{self._ssh_port}/tcp': self._ssh_port} - logger.warning( - ( - 'Using port forwarding till the enable host network mode of Docker is out of experimental mode.' - 'Check the 897th issue on https://github.com/OpenDevin/OpenDevin/issues/ for more information.' - ) - ) - - # start the container - logger.info(f'Mounting volumes: {self.volumes}') - self.container = self.docker_client.containers.run( - self.container_image, - # allow root login - command=f"/usr/sbin/sshd -D -p {self._ssh_port} -o 'PermitRootLogin=yes'", - **network_kwargs, - working_dir=self.sandbox_workspace_dir, - name=self.container_name, - detach=True, - volumes=self.volumes, - ) - logger.info('Container started') - except Exception as ex: - logger.exception('Failed to start container: ' + str(ex), exc_info=False) - raise ex - - # wait for container to be ready - elapsed = 0 - while self.container.status != 'running': - if self.container.status == 'exited': - logger.info('container exited') - logger.info('container logs:') - logger.info(self.container.logs()) - break - time.sleep(1) - elapsed += 1 - self.container = self.docker_client.containers.get(self.container_name) - logger.info( - f'waiting for container to start: {elapsed}, container status: {self.container.status}' - ) - if elapsed > self.config.timeout: - break - if self.container.status != 'running': - raise Exception('Failed to start container') - - # clean up the container, cannot do it in __del__ because the python interpreter is already shutting down - def close(self): - containers = self.docker_client.containers.list(all=True) - for container in containers: - try: - if container.name.startswith(self.container_name): - if self.persist_sandbox: - container.stop() - else: - # only remove the container we created - # otherwise all other containers with the same prefix will be removed - # which will mess up with parallel evaluation - container.remove(force=True) - except docker.errors.NotFound: - pass - self.docker_client.close() - - -if __name__ == '__main__': - try: - ssh_box = DockerSSHBox( - config=SandboxConfig(), - run_as_devin=False, - workspace_mount_path='/path/to/workspace', - cache_dir='/path/to/cache', - sandbox_workspace_dir='/sandbox', - persist_sandbox=False, - ) - except Exception as e: - logger.exception('Failed to start Docker container: %s', e) - sys.exit(1) - - logger.info( - "Interactive Docker container started. Type 'exit' or use Ctrl+C to exit." - ) - - # Initialize required plugins - plugins: list[PluginRequirement] = [AgentSkillsRequirement(), JupyterRequirement()] - ssh_box.init_plugins(plugins) - logger.info( - '--- AgentSkills COMMAND DOCUMENTATION ---\n' - f'{AgentSkillsRequirement().documentation}\n' - '---' - ) - - sys.stdout.flush() - try: - while True: - try: - user_input = input('$ ') - except EOFError: - logger.info('Exiting...') - break - if user_input.lower() == 'exit': - logger.info('Exiting...') - break - exit_code, output = ssh_box.execute(user_input) - logger.info('exit code: %d', exit_code) - logger.info(output) - sys.stdout.flush() - except KeyboardInterrupt: - logger.info('Exiting...') - ssh_box.close() diff --git a/opendevin/runtime/e2b/runtime.py b/opendevin/runtime/e2b/runtime.py index 733e9f757e..5d90408b55 100644 --- a/opendevin/runtime/e2b/runtime.py +++ b/opendevin/runtime/e2b/runtime.py @@ -12,14 +12,14 @@ from opendevin.events.observation import ( from opendevin.events.stream import EventStream from opendevin.runtime import Sandbox from opendevin.runtime.plugins import PluginRequirement -from opendevin.runtime.server.files import insert_lines, read_lines -from opendevin.runtime.server.runtime import ServerRuntime +from opendevin.runtime.runtime import Runtime +from ..utils.files import insert_lines, read_lines from .filestore import E2BFileStore from .sandbox import E2BSandbox -class E2BRuntime(ServerRuntime): +class E2BRuntime(Runtime): def __init__( self, config: AppConfig, @@ -28,7 +28,9 @@ class E2BRuntime(ServerRuntime): plugins: list[PluginRequirement] | None = None, sandbox: Sandbox | None = None, ): - super().__init__(config, event_stream, sid, plugins, sandbox) + super().__init__(config, event_stream, sid, plugins) + if sandbox is None: + self.sandbox = E2BSandbox() if not isinstance(self.sandbox, E2BSandbox): raise ValueError('E2BRuntime requires an E2BSandbox') self.file_store = E2BFileStore(self.sandbox.filesystem) diff --git a/opendevin/runtime/runtime.py b/opendevin/runtime/runtime.py index 4caa7f889e..688227cb1b 100644 --- a/opendevin/runtime/runtime.py +++ b/opendevin/runtime/runtime.py @@ -4,7 +4,6 @@ import copy import json import os from abc import abstractmethod -from typing import Any, Optional from opendevin.core.config import AppConfig, SandboxConfig from opendevin.core.logger import opendevin_logger as logger @@ -29,7 +28,6 @@ from opendevin.events.observation import ( ) from opendevin.events.serialization.action import ACTION_TYPE_TO_CLASS from opendevin.runtime.plugins import JupyterRequirement, PluginRequirement -from opendevin.runtime.tools import RuntimeTool def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]: @@ -51,6 +49,7 @@ class Runtime: """ sid: str + config: AppConfig DEFAULT_ENV_VARS: dict[str, str] def __init__( @@ -99,18 +98,6 @@ class Runtime: else: loop.run_until_complete(self.close()) - # ==================================================================== - # Methods we plan to deprecate when we move to new EventStreamRuntime - # ==================================================================== - - def init_runtime_tools( - self, - runtime_tools: list[RuntimeTool], - runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None, - ) -> None: - # TODO: deprecate this method when we move to the new EventStreamRuntime - raise NotImplementedError('This method is not implemented in the base class.') - # ==================================================================== async def add_env_vars(self, env_vars: dict[str, str]) -> None: @@ -179,12 +166,8 @@ class Runtime: observation = await getattr(self, action_type)(action) return observation - @abstractmethod - async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): - raise NotImplementedError('This method is not implemented in the base class.') - # ==================================================================== - # Implement these methods in the subclass + # Action execution # ==================================================================== @abstractmethod @@ -210,3 +193,19 @@ class Runtime: @abstractmethod async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation: pass + + # ==================================================================== + # File operations + # ==================================================================== + + @abstractmethod + async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): + raise NotImplementedError('This method is not implemented in the base class.') + + @abstractmethod + async def list_files(self, path: str | None = None) -> list[str]: + """List files in the sandbox. + + If path is None, list files in the sandbox's initial working directory (e.g., /workspace). + """ + raise NotImplementedError('This method is not implemented in the base class.') diff --git a/opendevin/runtime/server/runtime.py b/opendevin/runtime/server/runtime.py deleted file mode 100644 index 673e23b4a8..0000000000 --- a/opendevin/runtime/server/runtime.py +++ /dev/null @@ -1,234 +0,0 @@ -from typing import Any, Optional - -from opendevin.core.config import AppConfig -from opendevin.core.exceptions import BrowserInitException -from opendevin.core.logger import opendevin_logger as logger -from opendevin.events.action import ( - BrowseInteractiveAction, - BrowseURLAction, - CmdRunAction, - FileReadAction, - FileWriteAction, - IPythonRunCellAction, -) -from opendevin.events.observation import ( - CmdOutputObservation, - ErrorObservation, - IPythonRunCellObservation, - Observation, -) -from opendevin.events.stream import EventStream -from opendevin.runtime import ( - DockerSSHBox, - E2BBox, - LocalBox, - Sandbox, -) -from opendevin.runtime.browser.browser_env import BrowserEnv -from opendevin.runtime.plugins import JupyterRequirement, PluginRequirement -from opendevin.runtime.runtime import Runtime -from opendevin.runtime.tools import RuntimeTool - -from ..browser import browse -from .files import read_file, write_file - - -class ServerRuntime(Runtime): - def __init__( - self, - config: AppConfig, - event_stream: EventStream, - sid: str = 'default', - plugins: list[PluginRequirement] | None = None, - sandbox: Sandbox | None = None, - ): - super().__init__(config, event_stream, sid, plugins) - if sandbox is None: - self.sandbox = self.create_sandbox(sid, config.sandbox.box_type) - self._is_external_sandbox = False - else: - self.sandbox = sandbox - self._is_external_sandbox = True - self.browser: BrowserEnv | None = None - logger.debug(f'ServerRuntime `{sid}` config:\n{self.config}') - - def create_sandbox(self, sid: str = 'default', box_type: str = 'ssh') -> Sandbox: - if box_type == 'local': - return LocalBox( - config=self.config.sandbox, workspace_base=self.config.workspace_base - ) - elif box_type == 'ssh': - return DockerSSHBox( - config=self.config.sandbox, - persist_sandbox=self.config.persist_sandbox, - workspace_mount_path=self.config.workspace_mount_path, - sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox, - cache_dir=self.config.cache_dir, - run_as_devin=self.config.run_as_devin, - ssh_hostname=self.config.ssh_hostname, - ssh_password=self.config.ssh_password, - ssh_port=self.config.ssh_port, - sid=sid, - ) - elif box_type == 'e2b': - return E2BBox( - config=self.config.sandbox, - e2b_api_key=self.config.e2b_api_key, - ) - else: - raise ValueError(f'Invalid sandbox type: {box_type}') - - async def ainit(self, env_vars: dict[str, str] | None = None): - # init sandbox plugins - self.sandbox.init_plugins(self.plugins) - - # MUST call super().ainit() to initialize both default env vars - # AND the ones in env vars! - await super().ainit(env_vars) - - if any(isinstance(plugin, JupyterRequirement) for plugin in self.plugins): - obs = await self.run_ipython( - IPythonRunCellAction( - code=f'import os; os.chdir("{self.config.workspace_mount_path_in_sandbox}")' - ) - ) - logger.info( - f'Switch to working directory {self.config.workspace_mount_path_in_sandbox} in IPython. Output: {obs.content}' - ) - - async def close(self): - if hasattr(self, '_is_external_sandbox') and not self._is_external_sandbox: - self.sandbox.close() - if hasattr(self, 'browser') and self.browser is not None: - self.browser.close() - - def init_runtime_tools( - self, - runtime_tools: list[RuntimeTool], - runtime_tools_config: Optional[dict[RuntimeTool, Any]] = None, - ) -> None: - # if browser in runtime_tools, init it - if RuntimeTool.BROWSER in runtime_tools: - if runtime_tools_config is None: - runtime_tools_config = {} - browser_env_config = runtime_tools_config.get(RuntimeTool.BROWSER, {}) - try: - self.browser = BrowserEnv(**browser_env_config) - except BrowserInitException: - logger.warn( - 'Failed to start browser environment, web browsing functionality will not work' - ) - - async def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False): - self.sandbox.copy_to(host_src, sandbox_dest, recursive) - - async def run(self, action: CmdRunAction) -> Observation: - return self._run_command(action.command) - - async def run_ipython(self, action: IPythonRunCellAction) -> Observation: - self._run_command( - f"cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n{action.code}\nEOL" - ) - - # run the code - obs = self._run_command('cat /tmp/opendevin_jupyter_temp.py | execute_cli') - output = obs.content - if 'pip install' in action.code: - print(output) - package_names = action.code.split(' ', 2)[-1] - is_single_package = ' ' not in package_names - - if 'Successfully installed' in output: - restart_kernel = 'import IPython\nIPython.Application.instance().kernel.do_shutdown(True)' - if ( - 'Note: you may need to restart the kernel to use updated packages.' - in output - ): - self._run_command( - ( - "cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n" - f'{restart_kernel}\n' - 'EOL' - ) - ) - obs = self._run_command( - 'cat /tmp/opendevin_jupyter_temp.py | execute_cli' - ) - output = '[Package installed successfully]' - if "{'status': 'ok', 'restart': True}" != obs.content.strip(): - print(obs.content) - output += ( - '\n[But failed to restart the kernel to load the package]' - ) - else: - output += ( - '\n[Kernel restarted successfully to load the package]' - ) - - # re-init the kernel after restart - if action.kernel_init_code: - self._run_command( - ( - f"cat > /tmp/opendevin_jupyter_init.py <<'EOL'\n" - f'{action.kernel_init_code}\n' - 'EOL' - ), - ) - obs = self._run_command( - 'cat /tmp/opendevin_jupyter_init.py | execute_cli', - ) - elif ( - is_single_package - and f'Requirement already satisfied: {package_names}' in output - ): - output = '[Package already installed]' - return IPythonRunCellObservation(content=output, code=action.code) - - async def read(self, action: FileReadAction) -> Observation: - working_dir = self.sandbox.get_working_directory() - return await read_file( - action.path, - working_dir, - self.config.workspace_base, - self.config.workspace_mount_path_in_sandbox, - action.start, - action.end, - ) - - async def write(self, action: FileWriteAction) -> Observation: - working_dir = self.sandbox.get_working_directory() - return await write_file( - action.path, - working_dir, - self.config.workspace_base, - self.config.workspace_mount_path_in_sandbox, - action.content, - action.start, - action.end, - ) - - async def browse(self, action: BrowseURLAction) -> Observation: - return await browse(action, self.browser) - - async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation: - return await browse(action, self.browser) - - def _run_command(self, command: str) -> Observation: - try: - exit_code, output = self.sandbox.execute(command) - if 'pip install' in command: - package_names = command.split(' ', 2)[-1] - is_single_package = ' ' not in package_names - print(output) - if 'Successfully installed' in output: - output = '[Package installed successfully]' - elif ( - is_single_package - and f'Requirement already satisfied: {package_names}' in output - ): - output = '[Package already installed]' - return CmdOutputObservation( - command_id=-1, content=str(output), command=command, exit_code=exit_code - ) - except UnicodeDecodeError: - return ErrorObservation('Command output could not be decoded as utf-8') diff --git a/opendevin/runtime/server/files.py b/opendevin/runtime/utils/files.py similarity index 100% rename from opendevin/runtime/server/files.py rename to opendevin/runtime/utils/files.py diff --git a/opendevin/server/listen.py b/opendevin/server/listen.py index 19fe3072c1..4453047702 100644 --- a/opendevin/server/listen.py +++ b/opendevin/server/listen.py @@ -1,11 +1,10 @@ import os import re +import tempfile import uuid import warnings import requests -from pathspec import PathSpec -from pathspec.patterns import GitWildMatchPattern from opendevin.server.data_models.feedback import FeedbackDataModel, store_feedback from opendevin.storage import get_file_store @@ -33,13 +32,22 @@ from opendevin.controller.agent import Agent from opendevin.core.config import LLMConfig, load_app_config from opendevin.core.logger import opendevin_logger as logger from opendevin.core.schema import AgentState # Add this import -from opendevin.events.action import ChangeAgentStateAction, NullAction +from opendevin.events.action import ( + ChangeAgentStateAction, + FileReadAction, + FileWriteAction, + NullAction, +) from opendevin.events.observation import ( AgentStateChangedObservation, + ErrorObservation, + FileReadObservation, + FileWriteObservation, NullObservation, ) from opendevin.events.serialization import event_to_dict from opendevin.llm import bedrock +from opendevin.runtime.runtime import Runtime from opendevin.server.auth import get_sid_from_token, sign_token from opendevin.server.session import SessionManager @@ -355,7 +363,7 @@ async def get_agents(): @app.get('/api/list-files') -def list_files(request: Request, path: str = '/'): +async def list_files(request: Request, path: str | None = None): """List files in the specified path. This function retrieves a list of files from the agent's runtime file store, @@ -368,7 +376,7 @@ def list_files(request: Request, path: str = '/'): Args: request (Request): The incoming request object. - path (str, optional): The path to list files from. Defaults to '/'. + path (str, optional): The path to list files from. Defaults to None. Returns: list: A list of file names in the specified path. @@ -381,90 +389,13 @@ def list_files(request: Request, path: str = '/'): status_code=status.HTTP_404_NOT_FOUND, content={'error': 'Runtime not yet initialized'}, ) - - try: - # Get the full path of the requested directory - full_path = ( - request.state.session.agent_session.runtime.file_store.get_full_path(path) - ) - - # Check if the directory exists - if not os.path.exists(full_path) or not os.path.isdir(full_path): - return [] - - # Check if .gitignore exists - gitignore_path = os.path.join(full_path, '.gitignore') - if os.path.exists(gitignore_path): - # Use PathSpec to parse .gitignore - with open(gitignore_path, 'r') as f: - spec = PathSpec.from_lines(GitWildMatchPattern, f.readlines()) - else: - # Fallback to default exclude list if .gitignore doesn't exist - default_exclude = [ - '.git', - '.DS_Store', - '.svn', - '.hg', - '.idea', - '.vscode', - '.settings', - '.pytest_cache', - '__pycache__', - 'node_modules', - 'vendor', - 'build', - 'dist', - 'bin', - 'logs', - 'log', - 'tmp', - 'temp', - 'coverage', - 'venv', - 'env', - ] - spec = PathSpec.from_lines(GitWildMatchPattern, default_exclude) - - entries = request.state.session.agent_session.runtime.file_store.list(path) - - # Filter entries using PathSpec - filtered_entries = [ - entry - for entry in entries - if not spec.match_file(os.path.relpath(entry, str(full_path))) - ] - - # Separate directories and files - directories = [] - files = [] - for entry in filtered_entries: - # Remove leading slash and any parent directory components - entry_relative = entry.lstrip('/').split('/')[-1] - - # Construct the full path by joining the base path with the relative entry path - full_entry_path = os.path.join(full_path, entry_relative) - if os.path.exists(full_entry_path): - is_dir = os.path.isdir(full_entry_path) - if is_dir: - directories.append(entry) - else: - files.append(entry) - - # Sort directories and files separately - directories.sort(key=lambda s: s.lower()) - files.sort(key=lambda s: s.lower()) - - # Combine sorted directories and files - sorted_entries = directories + files - return sorted_entries - - except Exception as e: - logger.error(f'Error listing files: {e}', exc_info=True) - return [] + runtime: Runtime = request.state.session.agent_session.runtime + file_list = await runtime.list_files(path) + return file_list @app.get('/api/select-file') -def select_file(file: str, request: Request): +async def select_file(file: str, request: Request): """Retrieve the content of a specified file. To select a file: @@ -474,6 +405,7 @@ def select_file(file: str, request: Request): Args: file (str): The path of the file to be retrieved. + Expect path to be absolute inside the runtime. request (Request): The incoming request object. Returns: @@ -482,16 +414,27 @@ def select_file(file: str, request: Request): Raises: HTTPException: If there's an error opening the file. """ - try: - content = request.state.session.agent_session.runtime.file_store.read(file) - except Exception as e: - logger.error(f'Error opening file {file}: {e}', exc_info=False) - error_msg = f'Error opening file: {e}' + runtime: Runtime = request.state.session.agent_session.runtime + + # convert file to an absolute path inside the runtime + if not os.path.isabs(file): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={'error': 'File path must be absolute'}, + ) + + read_action = FileReadAction(file) + observation = await runtime.run_action(read_action) + + if isinstance(observation, FileReadObservation): + content = observation.content + return {'code': content} + elif isinstance(observation, ErrorObservation): + logger.error(f'Error opening file {file}: {observation}', exc_info=False) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={'error': error_msg}, + content={'error': f'Error opening file: {observation}'}, ) - return {'code': content} def sanitize_filename(filename): @@ -552,9 +495,17 @@ async def upload_file(request: Request, files: list[UploadFile]): ) continue - request.state.session.agent_session.runtime.file_store.write( - safe_filename, file_contents - ) + # copy the file to the runtime + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_file_path = os.path.join(tmp_dir, safe_filename) + with open(tmp_file_path, 'wb') as tmp_file: + tmp_file.write(file_contents) + tmp_file.flush() + + runtime: Runtime = request.state.session.agent_session.runtime + await runtime.copy_to( + tmp_file_path, runtime.config.workspace_mount_path_in_sandbox + ) uploaded_files.append(safe_filename) response_content = { @@ -709,13 +660,32 @@ async def save_file(request: Request): if not file_path or content is None: raise HTTPException(status_code=400, detail='Missing filePath or content') - # Save the file to the agent's runtime file store - request.state.session.agent_session.runtime.file_store.write(file_path, content) + # Make sure file_path is abs + if not os.path.isabs(file_path): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={'error': 'File path must be absolute'}, + ) - # Return a success response - return JSONResponse( - status_code=200, content={'message': 'File saved successfully'} - ) + # Save the file to the agent's runtime file store + runtime: Runtime = request.state.session.agent_session.runtime + write_action = FileWriteAction(file_path, content) + observation = await runtime.run_action(write_action) + + if isinstance(observation, FileWriteObservation): + return JSONResponse( + status_code=200, content={'message': 'File saved successfully'} + ) + elif isinstance(observation, ErrorObservation): + return JSONResponse( + status_code=500, + content={'error': f'Failed to save file: {observation}'}, + ) + else: + return JSONResponse( + status_code=500, + content={'error': f'Unexpected observation: {observation}'}, + ) except Exception as e: # Log the error and return a 500 response logger.error(f'Error saving file: {e}', exc_info=True) diff --git a/opendevin/server/session/agent.py b/opendevin/server/session/agent.py index 91e20b986b..bbe5d5e98a 100644 --- a/opendevin/server/session/agent.py +++ b/opendevin/server/session/agent.py @@ -1,15 +1,13 @@ from typing import Optional -from agenthub.codeact_agent.codeact_agent import CodeActAgent from opendevin.controller import AgentController from opendevin.controller.agent import Agent from opendevin.controller.state.state import State from opendevin.core.config import AppConfig, LLMConfig from opendevin.core.logger import opendevin_logger as logger from opendevin.events.stream import EventStream -from opendevin.runtime import DockerSSHBox, get_runtime_cls +from opendevin.runtime import get_runtime_cls from opendevin.runtime.runtime import Runtime -from opendevin.runtime.server.runtime import ServerRuntime from opendevin.storage.files import FileStore @@ -22,6 +20,7 @@ class AgentSession: sid: str event_stream: EventStream + file_store: FileStore controller: Optional[AgentController] = None runtime: Optional[Runtime] = None _closed: bool = False @@ -101,16 +100,6 @@ class AgentSession: raise Exception('Runtime must be initialized before the agent controller') logger.info(f'Creating agent {agent.name} using LLM {agent.llm.config.model}') - if isinstance(agent, CodeActAgent): - if not self.runtime or not ( - isinstance(self.runtime, ServerRuntime) - and isinstance(self.runtime.sandbox, DockerSSHBox) - ): - logger.warning( - 'CodeActAgent requires DockerSSHBox as sandbox! Using other sandbox that are not stateful' - ' LocalBox will not work properly.' - ) - self.runtime.init_runtime_tools(agent.runtime_tools) self.controller = AgentController( sid=self.sid, diff --git a/tests/integration/README.md b/tests/integration/README.md index 3a97a7e6cd..390192e973 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -48,15 +48,12 @@ where `conftest.py` defines the infrastructure needed to load real-world LLM pro and responses for mocking purpose. Prompts and responses generated during real runs of agents with real LLMs are stored under `mock/AgentName/TestName` folders. -**Note:** Set PERSIST_SANDBOX=false to use a clean sandbox for each test. ## Run Integration Tests Take a look at `ghcr.yml` (in the `.github/workflow` folder) to learn how integration tests are launched in a CI environment. -We currently have two runtime: `ServerRuntime` and `EventStreamRuntime`, each having their own sets of integration test prompts. - You can run: ```bash diff --git a/tests/integration/regenerate.sh b/tests/integration/regenerate.sh index 67b47c3cf1..91e2788838 100755 --- a/tests/integration/regenerate.sh +++ b/tests/integration/regenerate.sh @@ -63,7 +63,6 @@ if [ "$TEST_RUNTIME" == "eventstream" ] && [ -z "$SANDBOX_CONTAINER_IMAGE" ]; th SANDBOX_CONTAINER_IMAGE="ubuntu:22.04" fi -PERSIST_SANDBOX=false MAX_ITERATIONS=15 echo "SANDBOX_BOX_TYPE: $SANDBOX_BOX_TYPE" echo "TEST_RUNTIME: $TEST_RUNTIME" @@ -114,7 +113,6 @@ run_test() { env SCRIPT_DIR="$SCRIPT_DIR" \ PROJECT_ROOT="$PROJECT_ROOT" \ SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \ - PERSIST_SANDBOX=$PERSIST_SANDBOX \ WORKSPACE_BASE=$WORKSPACE_BASE \ WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \ MAX_ITERATIONS=$MAX_ITERATIONS \ @@ -186,7 +184,6 @@ regenerate_without_llm() { env SCRIPT_DIR="$SCRIPT_DIR" \ PROJECT_ROOT="$PROJECT_ROOT" \ SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \ - PERSIST_SANDBOX=$PERSIST_SANDBOX \ WORKSPACE_BASE=$WORKSPACE_BASE \ WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \ MAX_ITERATIONS=$MAX_ITERATIONS \ @@ -217,7 +214,6 @@ regenerate_with_llm() { PROJECT_ROOT="$PROJECT_ROOT" \ DEBUG=true \ SANDBOX_BOX_TYPE="$SANDBOX_BOX_TYPE" \ - PERSIST_SANDBOX=$PERSIST_SANDBOX \ WORKSPACE_BASE=$WORKSPACE_BASE \ WORKSPACE_MOUNT_PATH=$WORKSPACE_MOUNT_PATH \ DEFAULT_AGENT=$agent \ diff --git a/tests/test_fileops.py b/tests/test_fileops.py index 3b296f13de..9fa9ceaa85 100644 --- a/tests/test_fileops.py +++ b/tests/test_fileops.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from opendevin.runtime.server import files +from opendevin.runtime.utils import files SANDBOX_PATH_PREFIX = '/workspace' WORKSPACE_BASE = 'workspace' diff --git a/tests/unit/test_bash_parsing.py b/tests/unit/test_bash_parsing.py index 014a638eb4..3797d426d2 100644 --- a/tests/unit/test_bash_parsing.py +++ b/tests/unit/test_bash_parsing.py @@ -103,30 +103,6 @@ echo "Done" assert split_bash_commands(input_commands) == expected_output -def test_jupyter_heredoc(): - """This tests specifically test the behavior of the bash parser - when the input is a heredoc for a Jupyter cell (used in ServerRuntime). - - It will failed to parse bash commands AND fall back to the original input, - which won't cause issues in actual execution. - - [input]: cat > /tmp/opendevin_jupyter_temp.py <<'EOL' - print('Hello, `World`! - ') - EOL - [warning]: here-document at line 0 delimited by end-of-file (wanted "'EOL'") (position 75) - - TODO: remove this tests after the deprecation of ServerRuntime - """ - - code = "print('Hello, `World`!\n')" - input_commands = f"""cat > /tmp/opendevin_jupyter_temp.py <<'EOL' -{code} -EOL""" - expected_output = [f"cat > /tmp/opendevin_jupyter_temp.py <<'EOL'\n{code}\nEOL"] - assert split_bash_commands(input_commands) == expected_output - - def test_backslash_continuation(): input_commands = """ echo "This is a long \ diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e14e669ad4..d40e6bf163 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -378,7 +378,7 @@ def test_defaults_dict_after_updates(default_config): assert defaults_after_updates['sandbox']['timeout']['default'] == 120 assert ( defaults_after_updates['sandbox']['container_image']['default'] - == 'ghcr.io/opendevin/sandbox:main' + == 'ubuntu:22.04' ) assert defaults_after_updates == initial_defaults diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py index 174c28689c..63fe495977 100644 --- a/tests/unit/test_runtime.py +++ b/tests/unit/test_runtime.py @@ -32,7 +32,6 @@ from opendevin.events.observation import ( from opendevin.runtime.client.runtime import EventStreamRuntime from opendevin.runtime.plugins import AgentSkillsRequirement, JupyterRequirement from opendevin.runtime.runtime import Runtime -from opendevin.runtime.server.runtime import ServerRuntime from opendevin.storage import get_file_store @@ -58,10 +57,8 @@ def get_box_classes(): runtime = TEST_RUNTIME if runtime.lower() == 'eventstream': return [EventStreamRuntime] - elif runtime.lower() == 'server': - return [ServerRuntime] else: - return [EventStreamRuntime, ServerRuntime] + return [EventStreamRuntime] # This assures that all tests run together per runtime, not alternating between them, @@ -146,19 +143,6 @@ async def _load_runtime( ) await runtime.ainit() - elif box_class == ServerRuntime: - runtime = ServerRuntime( - config=config, event_stream=event_stream, sid=sid, plugins=plugins - ) - await runtime.ainit() - from opendevin.runtime.tools import ( - RuntimeTool, # deprecate this after ServerRuntime is deprecated - ) - - runtime.init_runtime_tools( - [RuntimeTool.BROWSER], - runtime_tools_config={}, - ) else: raise ValueError(f'Invalid box class: {box_class}') await asyncio.sleep(1) @@ -335,11 +319,8 @@ async def test_simple_cmd_ipython_and_fileop(temp_dir, box_class, run_as_devin): logger.info(obs, extra={'msg_type': 'OBSERVATION'}) assert obs.content == '' - if box_class == ServerRuntime: - assert obs.path == 'hello.sh' - else: - # event stream runtime will always use absolute path - assert obs.path == '/workspace/hello.sh' + # event stream runtime will always use absolute path + assert obs.path == '/workspace/hello.sh' # Test read file (file should exist) action_read = FileReadAction(path='hello.sh') @@ -351,10 +332,7 @@ async def test_simple_cmd_ipython_and_fileop(temp_dir, box_class, run_as_devin): logger.info(obs, extra={'msg_type': 'OBSERVATION'}) assert obs.content == 'echo "Hello, World!"\n' - if box_class == ServerRuntime: - assert obs.path == 'hello.sh' - else: - assert obs.path == '/workspace/hello.sh' + assert obs.path == '/workspace/hello.sh' # clean up action = CmdRunAction(command='rm -rf hello.sh') @@ -571,14 +549,8 @@ async def test_no_ps2_in_output(temp_dir, box_class, run_as_devin): obs = await runtime.run_action(action) logger.info(obs, extra={'msg_type': 'OBSERVATION'}) - if box_class == ServerRuntime: - # the extra PS2 '>' is NOT handled by the ServerRuntime - assert 'hello\r\nworld' in obs.content - assert '>' in obs.content - assert obs.content.count('>') == 1 - else: - assert 'hello\r\nworld' in obs.content - assert '>' not in obs.content + assert 'hello\r\nworld' in obs.content + assert '>' not in obs.content await runtime.close() await asyncio.sleep(1) @@ -849,7 +821,7 @@ async def test_ipython_simple(temp_dir, box_class): async def _test_ipython_agentskills_fileop_pwd_impl( - runtime: ServerRuntime | EventStreamRuntime, enable_auto_lint: bool + runtime: EventStreamRuntime, enable_auto_lint: bool ): # remove everything in /workspace action = CmdRunAction(command='rm -rf /workspace/*') @@ -1042,35 +1014,6 @@ async def test_ipython_agentskills_fileop_pwd_with_userdir(temp_dir, box_class): await asyncio.sleep(1) -@pytest.mark.skipif( - os.environ.get('TEST_IN_CI', 'false').lower() == 'true', - # FIXME: There's some weird issue with the CI environment. - reason='Skip this if in CI.', -) -@pytest.mark.asyncio -async def test_ipython_agentskills_fileop_pwd_agnostic_sandbox( - temp_dir, box_class, run_as_devin, enable_auto_lint, container_image -): - """Make sure that cd in bash also updates the current working directory in iPython.""" - - # NOTE: we only test for ServerRuntime, since EventStreamRuntime - # is image agnostic by design. - if box_class != 'server': - pytest.skip('Skip this if box_class is not server') - - runtime = await _load_runtime( - temp_dir, - box_class, - run_as_devin, - enable_auto_lint=enable_auto_lint, - container_image=container_image, - ) - await _test_ipython_agentskills_fileop_pwd_impl(runtime, enable_auto_lint) - - await runtime.close() - await asyncio.sleep(1) - - @pytest.mark.asyncio async def test_bash_python_version(temp_dir, box_class): """Make sure Python is available in bash."""