(arch) Switch default runtime to EventStream Runtime (#3271)

* switch default to eventstream runtime

* remove pull docker from makefile

* fix unittest

* fix file store path

* try deprecate server runtime

* remove persist sandbox

* move file utils

* remove server runtime related workflow

* remove unused method

* attempt to remove the reliance on filestore for BE

* fix async for list file

* fix list_files to post

* fix list files

* add suffix to directory

* make sure list file returns abs path;
make sure other backend endpoints accpets abs path

* remove server runtime test workflow

* set git config in runtime
This commit is contained in:
Xingyao Wang 2024-08-08 10:11:49 +08:00 committed by GitHub
parent 71ad979ffd
commit 90d0a62469
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 352 additions and 1477 deletions

View File

@ -11,9 +11,6 @@ on:
- main
pull_request:
env:
PERSIST_SANDBOX : 'false'
jobs:
test:
runs-on: ubuntu-latest

View File

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

View File

@ -16,8 +16,6 @@ on:
- 'evaluation/**'
pull_request:
env:
PERSIST_SANDBOX : "false"
jobs:
# Run frontend unit tests

View File

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

View File

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

View File

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

View File

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

View File

@ -67,10 +67,14 @@ export async function uploadFiles(files: FileList): Promise<UploadResult> {
};
}
export async function listFiles(path: string = "/"): Promise<string[]> {
const data = await request(
`/api/list-files?path=${encodeURIComponent(path)}`,
);
export async function listFiles(
path: string | undefined = undefined,
): Promise<string[]> {
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");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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