Skip to content

Commit

Permalink
Integrate E2B sandbox as an alternative to a Docker container (All-Ha…
Browse files Browse the repository at this point in the history
…nds-AI#727)

* add e2b sandbox [wip]

* Install e2b package

* Add basic E2B sandbox integration

* Update dependencies and fix command execution in E2BSandbox

* Udpate e2b

* Add comment

* Lint

* Remove unnecessary type conversion

* Lint

* Fix linting

* Resolve comments

* Update opendevin/action/fileop.py

* Update opendevin/action/fileop.py

* Fix log

* Update E2B readme

* poetry lock

---------

Co-authored-by: Robert Brennan <[email protected]>
  • Loading branch information
mlejva and rbren authored Apr 19, 2024
1 parent e6d91af commit 76b81ca
Show file tree
Hide file tree
Showing 21 changed files with 513 additions and 203 deletions.
19 changes: 19 additions & 0 deletions containers/e2b-sandbox/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM ubuntu:22.04

# install basic packages
RUN apt-get update && apt-get install -y \
curl \
wget \
git \
vim \
nano \
unzip \
zip \
python3 \
python3-pip \
python3-venv \
python3-dev \
build-essential \
openssh-server \
sudo \
&& rm -rf /var/lib/apt/lists/*
15 changes: 15 additions & 0 deletions containers/e2b-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# How to build custom E2B sandbox for OpenDevin

[E2B](https://e2b.dev) is an [open-source](https://github.com/e2b-dev/e2b) secure cloud environment (sandbox) made for running AI-generated code and agents. E2B offers [Python](https://pypi.org/project/e2b/) and [JS/TS](https://www.npmjs.com/package/e2b) SDK to spawn and control these sandboxes.


1. Install the CLI with NPM.
```sh
npm install -g @e2b/cli@latest
```
Full CLI API is [here](https://e2b.dev/docs/cli/installation).

1. Build the sandbox
```sh
e2b template build --dockerfile ./Dockerfile --name "open-devin"
```
14 changes: 14 additions & 0 deletions containers/e2b-sandbox/e2b.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# This is a config for E2B sandbox template.
# You can use 'template_id' (785n69crgahmz0lkdw9h) or 'template_name (open-devin) from this config to spawn a sandbox:

# Python SDK
# from e2b import Sandbox
# sandbox = Sandbox(template='open-devin')

# JS SDK
# import { Sandbox } from 'e2b'
# const sandbox = await Sandbox.create({ template: 'open-devin' })

dockerfile = "Dockerfile"
template_name = "open-devin"
template_id = "785n69crgahmz0lkdw9h"
4 changes: 2 additions & 2 deletions opendevin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ See the [main README](../README.md) for instructions on how to run OpenDevin fro

## Sandbox Image
```bash
docker build -f opendevin/sandbox/Dockerfile -t opendevin/sandbox:v0.1 .
docker build -f opendevin/sandbox/docker/Dockerfile -t opendevin/sandbox:v0.1 .
```

## Sandbox Runner
Expand All @@ -15,7 +15,7 @@ Run the docker-based interactive sandbox:

```bash
mkdir workspace
python3 opendevin/sandbox/sandbox.py -d workspace
python3 opendevin/sandbox/docker/sandbox.py -d workspace
```

It will map `./workspace` into the docker container with the folder permission correctly adjusted for current user.
Expand Down
98 changes: 61 additions & 37 deletions opendevin/action/fileop.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
)

from opendevin.schema import ActionType
from opendevin.sandbox import E2BBox
from opendevin import config

from .base import ExecutableAction
Expand Down Expand Up @@ -35,27 +36,33 @@ class FileReadAction(ExecutableAction):
thoughts: str = ''
action: str = ActionType.READ

def _read_lines(self, all_lines: list[str]):
if self.end == -1:
if self.start == 0:
return all_lines
else:
return all_lines[self.start:]
else:
num_lines = len(all_lines)
begin = max(0, min(self.start, num_lines - 2))
end = -1 if self.end > num_lines else max(begin + 1, self.end)
return all_lines[begin:end]

async def run(self, controller) -> FileReadObservation:
whole_path = resolve_path(self.path)
self.start = max(self.start, 0)
try:
with open(whole_path, 'r', encoding='utf-8') as file:
if self.end == -1:
if self.start == 0:
code_view = file.read()
else:
all_lines = file.readlines()
code_slice = all_lines[self.start:]
code_view = ''.join(code_slice)
else:
all_lines = file.readlines()
num_lines = len(all_lines)
begin = max(0, min(self.start, num_lines - 2))
end = -1 if self.end > num_lines else max(begin + 1, self.end)
code_slice = all_lines[begin:end]
code_view = ''.join(code_slice)
except FileNotFoundError:
raise FileNotFoundError(f'File not found: {self.path}')
if isinstance(controller.command_manager.sandbox, E2BBox):
content = controller.command_manager.sandbox.filesystem.read(
self.path)
read_lines = self._read_lines(content.split('\n'))
code_view = ''.join(read_lines)
else:
whole_path = resolve_path(self.path)
self.start = max(self.start, 0)
try:
with open(whole_path, 'r', encoding='utf-8') as file:
read_lines = self._read_lines(file.readlines())
code_view = ''.join(read_lines)
except FileNotFoundError:
raise FileNotFoundError(f'File not found: {self.path}')
return FileReadObservation(path=self.path, content=code_view)

@property
Expand All @@ -72,25 +79,42 @@ class FileWriteAction(ExecutableAction):
thoughts: str = ''
action: str = ActionType.WRITE

def _insert_lines(self, to_insert: list[str], original: list[str]):
"""
Insert the new conent to the original content based on self.start and self.end
"""
new_lines = [''] if self.start == 0 else original[:self.start]
new_lines += [i + '\n' for i in to_insert]
new_lines += [''] if self.end == -1 else original[self.end:]
return new_lines

async def run(self, controller) -> FileWriteObservation:
whole_path = resolve_path(self.path)
mode = 'w' if not os.path.exists(whole_path) else 'r+'
insert = self.content.split('\n')
try:
with open(whole_path, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = [''] if self.start == 0 else all_lines[:self.start]
new_file += [i + '\n' for i in insert]
new_file += [''] if self.end == -1 else all_lines[self.end:]
else:
new_file = [i + '\n' for i in insert]

file.seek(0)
file.writelines(new_file)
file.truncate()
except FileNotFoundError:
raise FileNotFoundError(f'File not found: {self.path}')

if isinstance(controller.command_manager.sandbox, E2BBox):
files = controller.command_manager.sandbox.filesystem.list(self.path)
if self.path in files:
all_lines = controller.command_manager.sandbox.filesystem.read(self.path)
new_file = self._insert_lines(self.content.split('\n'), all_lines)
controller.command_manager.sandbox.filesystem.write(self.path, ''.join(new_file))
else:
raise FileNotFoundError(f'File not found: {self.path}')
else:
whole_path = resolve_path(self.path)
mode = 'w' if not os.path.exists(whole_path) else 'r+'
try:
with open(whole_path, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = self._insert_lines(insert, all_lines)
else:
new_file = [i + '\n' for i in insert]

file.seek(0)
file.writelines(new_file)
file.truncate()
except FileNotFoundError:
raise FileNotFoundError(f'File not found: {self.path}')
return FileWriteObservation(content='', path=self.path)

@property
Expand Down
3 changes: 2 additions & 1 deletion opendevin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
# Assuming 5 characters per token, 5 million is a reasonable default limit.
ConfigType.MAX_CHARS: 5_000_000,
ConfigType.AGENT: 'MonologueAgent',
ConfigType.SANDBOX_TYPE: 'ssh',
ConfigType.E2B_API_KEY: '',
ConfigType.SANDBOX_TYPE: 'ssh', # Can be 'ssh', 'exec', or 'e2b'
ConfigType.USE_HOST_NETWORK: 'false',
ConfigType.SSH_HOSTNAME: 'localhost',
ConfigType.DISABLE_COLOR: 'false',
Expand Down
24 changes: 13 additions & 11 deletions opendevin/controller/action_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from opendevin import config
from opendevin.observation import CmdOutputObservation
from opendevin.sandbox import DockerExecBox, DockerSSHBox, Sandbox, LocalBox
from opendevin.sandbox import DockerExecBox, DockerSSHBox, Sandbox, LocalBox, E2BBox
from opendevin.schema import ConfigType
from opendevin.logger import opendevin_logger as logger
from opendevin.action import (
Expand All @@ -18,7 +18,7 @@

class ActionManager:
id: str
shell: Sandbox
sandbox: Sandbox

def __init__(
self,
Expand All @@ -27,15 +27,17 @@ def __init__(
):
sandbox_type = config.get(ConfigType.SANDBOX_TYPE).lower()
if sandbox_type == 'exec':
self.shell = DockerExecBox(
self.sandbox = DockerExecBox(
sid=(sid or 'default'), container_image=container_image
)
elif sandbox_type == 'local':
self.shell = LocalBox()
self.sandbox = LocalBox()
elif sandbox_type == 'ssh':
self.shell = DockerSSHBox(
self.sandbox = DockerSSHBox(
sid=(sid or 'default'), container_image=container_image
)
elif sandbox_type == 'e2b':
self.sandbox = E2BBox()
else:
raise ValueError(f'Invalid sandbox type: {sandbox_type}')

Expand All @@ -58,23 +60,23 @@ def run_command(self, command: str, background=False) -> CmdOutputObservation:
return self._run_immediately(command)

def _run_immediately(self, command: str) -> CmdOutputObservation:
exit_code, output = self.shell.execute(command)
exit_code, output = self.sandbox.execute(command)
return CmdOutputObservation(
command_id=-1, content=output, command=command, exit_code=exit_code
)

def _run_background(self, command: str) -> CmdOutputObservation:
bg_cmd = self.shell.execute_in_background(command)
content = f'Background command started. To stop it, send a `kill` action with id {bg_cmd.id}'
bg_cmd = self.sandbox.execute_in_background(command)
content = f'Background command started. To stop it, send a `kill` action with id {bg_cmd.pid}'
return CmdOutputObservation(
content=content,
command_id=bg_cmd.id,
command_id=bg_cmd.pid,
command=command,
exit_code=0,
)

def kill_command(self, id: int) -> CmdOutputObservation:
cmd = self.shell.kill_background(id)
cmd = self.sandbox.kill_background(id)
return CmdOutputObservation(
content=f'Background command with id {id} has been killed.',
command_id=id,
Expand All @@ -84,7 +86,7 @@ def kill_command(self, id: int) -> CmdOutputObservation:

def get_background_obs(self) -> List[CmdOutputObservation]:
obs = []
for _id, cmd in self.shell.background_commands.items():
for _id, cmd in self.sandbox.background_commands.items():
output = cmd.read_logs()
if output is not None and output != '':
obs.append(
Expand Down
9 changes: 6 additions & 3 deletions opendevin/sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from .sandbox import Sandbox
from .ssh_box import DockerSSHBox
from .exec_box import DockerExecBox
from .local_box import LocalBox
from .docker.ssh_box import DockerSSHBox
from .docker.exec_box import DockerExecBox
from .docker.local_box import LocalBox
from .e2b.sandbox import E2BBox

__all__ = [
'Sandbox',
'DockerSSHBox',
'DockerExecBox',
'E2BBox',
'LocalBox'
]
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

from opendevin import config
from opendevin.logger import opendevin_logger as logger
from opendevin.sandbox.sandbox import Sandbox, BackgroundCommand
from opendevin.sandbox.sandbox import Sandbox
from opendevin.sandbox.process import Process
from opendevin.sandbox.docker.process import DockerProcess
from opendevin.schema import ConfigType
from opendevin.exceptions import SandboxInvalidBackgroundCommandError

Expand Down Expand Up @@ -40,7 +42,7 @@ class DockerExecBox(Sandbox):
docker_client: docker.DockerClient

cur_background_id = 0
background_commands: Dict[int, BackgroundCommand] = {}
background_commands: Dict[int, Process] = {}

def __init__(
self,
Expand Down Expand Up @@ -120,14 +122,14 @@ def run_command(container, command):
return -1, f'Command: "{cmd}" timed out'
return exit_code, logs.decode('utf-8')

def execute_in_background(self, cmd: str) -> BackgroundCommand:
def execute_in_background(self, cmd: str) -> Process:
result = self.container.exec_run(
self.get_exec_cmd(cmd), socket=True, workdir=SANDBOX_WORKSPACE_DIR
)
result.output._sock.setblocking(0)
pid = self.get_pid(cmd)
bg_cmd = BackgroundCommand(self.cur_background_id, cmd, result, pid)
self.background_commands[bg_cmd.id] = bg_cmd
bg_cmd = DockerProcess(self.cur_background_id, cmd, result, pid)
self.background_commands[bg_cmd.pid] = bg_cmd
self.cur_background_id += 1
return bg_cmd

Expand All @@ -142,13 +144,14 @@ def get_pid(self, cmd):
return pid
return None

def kill_background(self, id: int) -> BackgroundCommand:
def kill_background(self, id: int) -> Process:
if id not in self.background_commands:
raise SandboxInvalidBackgroundCommandError()
bg_cmd = self.background_commands[id]
if bg_cmd.pid is not None:
self.container.exec_run(
f'kill -9 {bg_cmd.pid}', workdir=SANDBOX_WORKSPACE_DIR)
assert isinstance(bg_cmd, DockerProcess)
bg_cmd.result.output.close()
self.background_commands.pop(id)
return bg_cmd
Expand Down Expand Up @@ -259,14 +262,14 @@ def close(self):
logger.info('Exiting...')
break
if user_input.lower() == 'kill':
exec_box.kill_background(bg_cmd.id)
exec_box.kill_background(bg_cmd.pid)
logger.info('Background process killed')
continue
exit_code, output = exec_box.execute(user_input)
logger.info('exit code: %d', exit_code)
logger.info(output)
if bg_cmd.id in exec_box.background_commands:
logs = exec_box.read_logs(bg_cmd.id)
if bg_cmd.pid in exec_box.background_commands:
logs = exec_box.read_logs(bg_cmd.pid)
logger.info('background logs: %s', logs)
sys.stdout.flush()
except KeyboardInterrupt:
Expand Down
Loading

0 comments on commit 76b81ca

Please sign in to comment.