Remove third-party runtimes (daytona, modal, e2b, runloop) from main codebase (#9213)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
This commit is contained in:
Graham Neubig
2025-06-26 07:39:39 -04:00
committed by GitHub
parent 6efb992bae
commit c7dff3e4d2
35 changed files with 251 additions and 147 deletions

View File

@@ -6,22 +6,14 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.cli import CLIRuntime
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
__all__ = [
'ActionExecutionClient',
'CLIRuntime',
'DaytonaRuntime',
'DockerRuntime',
'E2BRuntime',
'LocalRuntime',
'ModalRuntime',
'RemoteRuntime',
'RunloopRuntime',
]

View File

@@ -1,134 +0,0 @@
# Daytona Runtime
[Daytona](https://www.daytona.io/) is a platform that provides a secure and elastic infrastructure for running AI-generated code. It provides all the necessary features for an AI Agent to interact with a codebase. It provides a Daytona SDK with official Python and TypeScript interfaces for interacting with Daytona, enabling you to programmatically manage development environments and execute code.
## Quick Start
### Step 1: Retrieve Your Daytona API Key
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
2. Click **"Create Key"**.
3. Enter a name for your key and confirm the creation.
4. Once the key is generated, copy it.
### Step 2: Set Your API Key as an Environment Variable
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
Mac/Linux:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
Windows PowerShell:
```powershell
$env:DAYTONA_API_KEY="<your-api-key>"
```
This step ensures that OpenHands can authenticate with the Daytona platform when it runs.
### Step 3: Run OpenHands Locally Using Docker
To start the latest version of OpenHands on your machine, execute the following command in your terminal:
Mac/Linux:
```bash
bash -i <(curl -sL https://get.daytona.io/openhands)
```
Windows:
```powershell
powershell -Command "irm https://get.daytona.io/openhands-windows | iex"
```
#### What This Command Does:
- Downloads the latest OpenHands release script.
- Runs the script in an interactive Bash session.
- Automatically pulls and runs the OpenHands container using Docker.
Once executed, OpenHands should be running locally and ready for use.
## Manual Initialization
### Step 1: Set the `OPENHANDS_VERSION` Environment Variable
Run the following command in your terminal, replacing `<openhands-release>` with the latest release's version seen in the [main README.md file](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-quick-start):
#### Mac/Linux:
```bash
export OPENHANDS_VERSION="<openhands-release>" # e.g. 0.27
```
#### Windows PowerShell:
```powershell
$env:OPENHANDS_VERSION="<openhands-release>" # e.g. 0.27
```
### Step 2: Retrieve Your Daytona API Key
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
2. Click **"Create Key"**.
3. Enter a name for your key and confirm the creation.
4. Once the key is generated, copy it.
### Step 3: Set Your API Key as an Environment Variable:
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
#### Mac/Linux:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
#### Windows PowerShell:
```powershell
$env:DAYTONA_API_KEY="<your-api-key>"
```
### Step 4: Run the following `docker` command:
This command pulls and runs the OpenHands container using Docker. Once executed, OpenHands should be running locally and ready for use.
#### Mac/Linux:
```bash
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${OPENHANDS_VERSION}-nikolaik \
-e LOG_ALL_EVENTS=true \
-e RUNTIME=daytona \
-e DAYTONA_API_KEY=${DAYTONA_API_KEY} \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:${OPENHANDS_VERSION}
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
#### Windows:
```powershell
docker run -it --rm --pull=always `
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${env:OPENHANDS_VERSION}-nikolaik `
-e LOG_ALL_EVENTS=true `
-e RUNTIME=daytona `
-e DAYTONA_API_KEY=${env:DAYTONA_API_KEY} `
-v ~/.openhands:/.openhands `
-p 3000:3000 `
--name openhands-app `
docker.all-hands.dev/all-hands-ai/openhands:${env:OPENHANDS_VERSION}
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
> **Tip:** If you don't want your sandboxes to default to the EU region, you can set the `DAYTONA_TARGET` environment variable to `us`
### Running OpenHands Locally Without Docker
Alternatively, if you want to run the OpenHands app on your local machine using `make run` without Docker, make sure to set the following environment variables first:
#### Mac/Linux:
```bash
export RUNTIME="daytona"
export DAYTONA_API_KEY="<your-api-key>"
```
#### Windows PowerShell:
```powershell
$env:RUNTIME="daytona"
$env:DAYTONA_API_KEY="<your-api-key>"
```
## Documentation
Read more by visiting our [documentation](https://www.daytona.io/docs/) page.

View File

@@ -1,275 +0,0 @@
from typing import Callable
import httpx
import tenacity
from daytona import (
CreateSandboxFromSnapshotParams,
Daytona,
DaytonaConfig,
Sandbox,
SessionExecuteRequest,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins.requirement import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.request import RequestHTTPError
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
OPENHANDS_SID_LABEL = 'OpenHands_SID'
class DaytonaRuntime(ActionExecutionClient):
"""The DaytonaRuntime class is a DockerRuntime that utilizes Daytona Sandboxes as runtime environments."""
_sandbox_port: int = 4444
_vscode_port: int = 4445
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
assert config.daytona_api_key, 'Daytona API key is required'
self.config = config
self.sid = sid
self.sandbox: Sandbox | None = None
self._vscode_url: str | None = None
daytona_config = DaytonaConfig(
api_key=config.daytona_api_key.get_secret_value(),
server_url=config.daytona_api_url,
target=config.daytona_target,
)
self.daytona = Daytona(daytona_config)
# workspace_base cannot be used because we can't bind mount into a workspace.
if self.config.workspace_base is not None:
self.log(
'warning',
'Workspace mounting is not supported in the Daytona runtime.',
)
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
def _get_sandbox(self) -> Sandbox | None:
try:
sandboxes = self.daytona.list({OPENHANDS_SID_LABEL: self.sid})
if len(sandboxes) == 0:
return None
assert len(sandboxes) == 1, 'Multiple sandboxes found for SID'
sandbox = sandboxes[0]
self.log('info', f'Attached to existing sandbox with id: {self.sid}')
except Exception:
self.log(
'warning',
f'Failed to attach to existing sandbox with id: {self.sid}',
)
sandbox = None
return sandbox
def _get_creation_env_vars(self) -> dict[str, str]:
env_vars: dict[str, str] = {
'port': str(self._sandbox_port),
'PYTHONUNBUFFERED': '1',
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug:
env_vars['DEBUG'] = 'true'
return env_vars
def _create_sandbox(self) -> Sandbox:
sandbox_params = CreateSandboxFromSnapshotParams(
language='python',
snapshot=self.config.sandbox.runtime_container_image,
public=True,
env_vars=self._get_creation_env_vars(),
labels={OPENHANDS_SID_LABEL: self.sid},
)
return self.daytona.create(sandbox_params)
def _construct_api_url(self, port: int) -> str:
assert self.sandbox is not None, 'Sandbox is not initialized'
assert self.sandbox.runner_domain is not None, 'Runner domain is not available'
return f'https://{port}-{self.sandbox.id}.{self.sandbox.runner_domain}'
@property
def action_execution_server_url(self) -> str:
return self.api_url
def _start_action_execution_server(self) -> None:
assert self.sandbox is not None, 'Sandbox is not initialized'
start_command: list[str] = get_action_execution_server_startup_command(
server_port=self._sandbox_port,
plugins=self.plugins,
app_config=self.config,
override_user_id=1000,
override_username='openhands',
)
start_command_str: str = (
f'mkdir -p {self.config.workspace_mount_path_in_sandbox} && cd /openhands/code && '
+ ' '.join(start_command)
)
self.log(
'debug',
f'Starting action execution server with command: {start_command_str}',
)
exec_session_id = 'action-execution-server'
self.sandbox.process.create_session(exec_session_id)
exec_command = self.sandbox.process.execute_session_command(
exec_session_id,
SessionExecuteRequest(command=start_command_str, var_async=True),
)
self.log('debug', f'exec_command_id: {exec_command.cmd_id}')
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
super().check_if_alive()
async def connect(self):
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
should_start_action_execution_server = False
if self.attach_to_existing:
self.sandbox = await call_sync_from_async(self._get_sandbox)
else:
should_start_action_execution_server = True
if self.sandbox is None:
self.set_runtime_status(RuntimeStatus.BUILDING_RUNTIME)
self.sandbox = await call_sync_from_async(self._create_sandbox)
self.log('info', f'Created a new sandbox with id: {self.sid}')
self.api_url = self._construct_api_url(self._sandbox_port)
state = self.sandbox.state
if state == 'stopping':
self.log('info', 'Waiting for the Daytona sandbox to stop...')
await call_sync_from_async(self.sandbox.wait_for_sandbox_stop)
state = 'stopped'
if state == 'stopped':
self.log('info', 'Starting the Daytona sandbox...')
await call_sync_from_async(self.sandbox.start)
should_start_action_execution_server = True
if should_start_action_execution_server:
await call_sync_from_async(self._start_action_execution_server)
self.log(
'info',
f'Container started. Action execution server url: {self.api_url}',
)
self.log('info', 'Waiting for client to become ready...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
await call_sync_from_async(self._wait_until_alive)
if should_start_action_execution_server:
await call_sync_from_async(self.setup_initial_env)
self.log(
'info',
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
if should_start_action_execution_server:
self.set_runtime_status(RuntimeStatus.READY)
self._runtime_initialized = True
@tenacity.retry(
retry=tenacity.retry_if_exception(
lambda e: (
isinstance(e, httpx.HTTPError) or isinstance(e, RequestHTTPError)
)
and hasattr(e, 'response')
and e.response.status_code == 502
),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=True,
)
def _send_action_server_request(self, method, url, **kwargs):
return super()._send_action_server_request(method, url, **kwargs)
def close(self):
super().close()
if self.attach_to_existing:
return
if self.sandbox:
self.sandbox.delete()
@property
def vscode_url(self) -> str | None:
if self._vscode_url is not None: # cached value
return self._vscode_url
token = super().get_vscode_token()
if not token:
self.log(
'warning', 'Failed to get VSCode token while trying to get VSCode URL'
)
return None
if not self.sandbox:
self.log(
'warning', 'Sandbox is not initialized while trying to get VSCode URL'
)
return None
self._vscode_url = (
self._construct_api_url(self._vscode_port)
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
)
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url
@property
def additional_agent_instructions(self) -> str:
return f'When showing endpoints to access applications for any port, e.g. port 3000, instead of localhost:3000, use this format: {self._construct_api_url(3000)}.'

View File

@@ -1,35 +0,0 @@
# How to use E2B
[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.
## Getting started
1. [Get your API key](https://e2b.dev/docs/getting-started/api-key)
1. Set your E2B API key to the `E2B_API_KEY` env var when starting the Docker container
1. **Optional** - Install the CLI with NPM.
```sh
npm install -g @e2b/cli@latest
```
Full CLI API is [here](https://e2b.dev/docs/cli/installation).
## OpenHands sandbox
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide [here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the [`containers` directory](/containers/e2b-sandbox). and it's called `openhands`.
## Debugging
You can connect to a running E2B sandbox with E2B CLI in your terminal.
- List all running sandboxes (based on your API key)
```sh
e2b sandbox list
```
- Connect to a running sandbox
```sh
e2b sandbox connect <sandbox-id>
```
## Links
- [E2B Docs](https://e2b.dev/docs)
- [E2B GitHub](https://github.com/e2b-dev/e2b)

View File

@@ -1,78 +0,0 @@
from typing import Callable
from openhands.core.config import OpenHandsConfig
from openhands.events.action import (
FileReadAction,
FileWriteAction,
)
from openhands.events.observation import (
ErrorObservation,
FileReadObservation,
FileWriteObservation,
Observation,
)
from openhands.events.stream import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.e2b.filestore import E2BFileStore
from openhands.runtime.impl.e2b.sandbox import E2BSandbox
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.files import insert_lines, read_lines
class E2BRuntime(ActionExecutionClient):
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
sandbox: E2BSandbox | None = None,
):
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
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)
def read(self, action: FileReadAction) -> Observation:
content = self.file_store.read(action.path)
lines = read_lines(content.split('\n'), action.start, action.end)
code_view = ''.join(lines)
return FileReadObservation(code_view, path=action.path)
def write(self, action: FileWriteAction) -> Observation:
if action.start == 0 and action.end == -1:
self.file_store.write(action.path, action.content)
return FileWriteObservation(content='', path=action.path)
files = self.file_store.list(action.path)
if action.path in files:
all_lines = self.file_store.read(action.path).split('\n')
new_file = insert_lines(
action.content.split('\n'), all_lines, action.start, action.end
)
self.file_store.write(action.path, ''.join(new_file))
return FileWriteObservation('', path=action.path)
else:
# FIXME: we should create a new file here
return ErrorObservation(f'File not found: {action.path}')

View File

@@ -1,27 +0,0 @@
from typing import Protocol
from openhands.storage.files import FileStore
class SupportsFilesystemOperations(Protocol):
def write(self, path: str, contents: str | bytes) -> None: ...
def read(self, path: str) -> str: ...
def list(self, path: str) -> list[str]: ...
def delete(self, path: str) -> None: ...
class E2BFileStore(FileStore):
def __init__(self, filesystem: SupportsFilesystemOperations) -> None:
self.filesystem = filesystem
def write(self, path: str, contents: str | bytes) -> None:
self.filesystem.write(path, contents)
def read(self, path: str) -> str:
return self.filesystem.read(path)
def list(self, path: str) -> list[str]:
return self.filesystem.list(path)
def delete(self, path: str) -> None:
self.filesystem.delete(path)

View File

@@ -1,114 +0,0 @@
import copy
import os
import tarfile
from glob import glob
from e2b import Sandbox as E2BSandbox
from e2b.exceptions import TimeoutException
from openhands.core.config import SandboxConfig
from openhands.core.logger import openhands_logger as logger
class E2BBox:
closed = False
_cwd: str = '/home/user'
_env: dict[str, str] = {}
is_initial_session: bool = True
def __init__(
self,
config: SandboxConfig,
e2b_api_key: str,
template: str = 'openhands',
):
self.config = copy.deepcopy(config)
self.initialize_plugins: bool = config.initialize_plugins
self.sandbox = E2BSandbox(
api_key=e2b_api_key,
template=template,
# It's possible to stream stdout and stderr from sandbox and from each process
on_stderr=lambda x: logger.debug(f'E2B sandbox stderr: {x}'),
on_stdout=lambda x: logger.debug(f'E2B sandbox stdout: {x}'),
cwd=self._cwd, # Default workdir inside sandbox
)
logger.debug(f'Started E2B sandbox with ID "{self.sandbox.id}"')
@property
def filesystem(self):
return self.sandbox.filesystem
def _archive(self, host_src: str, recursive: bool = False):
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(os.path.dirname(host_src), 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(os.path.dirname(host_src), srcname + '.tar')
with tarfile.open(tar_filename, mode='w') as tar:
tar.add(host_src, arcname=srcname)
return tar_filename
def execute(self, cmd: str, timeout: int | None = None) -> tuple[int, str]:
timeout = timeout if timeout is not None else self.config.timeout
process = self.sandbox.process.start(cmd, env_vars=self._env)
try:
process_output = process.wait(timeout=timeout)
except TimeoutException:
logger.debug('Command timed out, killing process...')
process.kill()
return -1, f'Command: "{cmd}" timed out'
logs = [m.line for m in process_output.messages]
logs_str = '\n'.join(logs)
if process.exit_code is None:
return -1, logs_str
assert process_output.exit_code is not None
return process_output.exit_code, logs_str
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
"""Copies a local file or directory to the sandbox."""
tar_filename = self._archive(host_src, recursive)
# Prepend the sandbox destination with our sandbox cwd
sandbox_dest = os.path.join(self._cwd, sandbox_dest.removeprefix('/'))
with open(tar_filename, 'rb') as tar_file:
# Upload the archive to /home/user (default destination that always exists)
uploaded_path = self.sandbox.upload_file(tar_file)
# Check if sandbox_dest exists. If not, create it.
process = self.sandbox.process.start_and_wait(f'test -d {sandbox_dest}')
if process.exit_code != 0:
self.sandbox.filesystem.make_dir(sandbox_dest)
# Extract the archive into the destination and delete the archive
process = self.sandbox.process.start_and_wait(
f'sudo tar -xf {uploaded_path} -C {sandbox_dest} && sudo rm {uploaded_path}'
)
if process.exit_code != 0:
raise Exception(
f'Failed to extract {uploaded_path} to {sandbox_dest}: {process.stderr}'
)
# Delete the local archive
os.remove(tar_filename)
def close(self):
self.sandbox.close()
def get_working_directory(self):
return self.sandbox.cwd

View File

@@ -1,288 +0,0 @@
import os
import tempfile
from pathlib import Path
from typing import Callable
import httpx
import modal
import tenacity
from openhands.core.config import OpenHandsConfig
from openhands.events import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.runtime_build import (
BuildFromImageType,
prep_build_folder,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
# FIXME: this will not work in HA mode. We need a better way to track IDs
MODAL_RUNTIME_IDS: dict[str, str] = {}
class ModalRuntime(ActionExecutionClient):
"""This runtime will subscribe the event stream.
When receive an event, it will send the event to runtime-client which run inside the Modal sandbox environment.
Args:
config (OpenHandsConfig): The application configuration.
event_stream (EventStream): The event stream to subscribe to.
sid (str, optional): The session ID. Defaults to 'default'.
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
"""
container_name_prefix = 'openhands-sandbox-'
sandbox: modal.Sandbox | None
sid: str
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
assert config.modal_api_token_id, 'Modal API token id is required'
assert config.modal_api_token_secret, 'Modal API token secret is required'
self.config = config
self.sandbox = None
self.sid = sid
self.modal_client = modal.Client.from_credentials(
config.modal_api_token_id.get_secret_value(),
config.modal_api_token_secret.get_secret_value(),
)
self.app = modal.App.lookup(
'openhands', create_if_missing=True, client=self.modal_client
)
# workspace_base cannot be used because we can't bind mount into a sandbox.
if self.config.workspace_base is not None:
self.log(
'warning',
'Setting workspace_base is not supported in the modal runtime.',
)
# This value is arbitrary as it's private to the container
self.container_port = 3000
self._vscode_port = 4445
self._vscode_url: str | None = None
self.status_callback = status_callback
self.base_container_image_id = self.config.sandbox.base_container_image
self.runtime_container_image_id = self.config.sandbox.runtime_container_image
if self.config.sandbox.runtime_extra_deps:
self.log(
'debug',
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
)
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
async def connect(self):
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
self.log('debug', f'ModalRuntime `{self.sid}`')
self.image = self._get_image_definition(
self.base_container_image_id,
self.runtime_container_image_id,
self.config.sandbox.runtime_extra_deps,
)
if self.attach_to_existing:
if self.sid in MODAL_RUNTIME_IDS:
sandbox_id = MODAL_RUNTIME_IDS[self.sid]
self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
self.sandbox = modal.Sandbox.from_id(
sandbox_id, client=self.modal_client
)
else:
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
await call_sync_from_async(
self._init_sandbox,
sandbox_workspace_dir=self.config.workspace_mount_path_in_sandbox,
plugins=self.plugins,
)
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
if self.sandbox is None:
raise Exception('Sandbox not initialized')
tunnel = self.sandbox.tunnels()[self.container_port]
self.api_url = tunnel.url
self.log('debug', f'Container started. Server url: {self.api_url}')
if not self.attach_to_existing:
self.log('debug', 'Waiting for client to become ready...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
self._wait_until_alive()
self.setup_initial_env()
if not self.attach_to_existing:
self.set_runtime_status(RuntimeStatus.READY)
self._runtime_initialized = True
@property
def action_execution_server_url(self):
return self.api_url
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
retry=tenacity.retry_if_exception_type((ConnectionError, httpx.NetworkError)),
reraise=True,
wait=tenacity.wait_fixed(2),
)
def _wait_until_alive(self):
self.check_if_alive()
def _get_image_definition(
self,
base_container_image_id: str | None,
runtime_container_image_id: str | None,
runtime_extra_deps: str | None,
) -> modal.Image:
if runtime_container_image_id:
base_runtime_image = modal.Image.from_registry(runtime_container_image_id)
elif base_container_image_id:
build_folder = tempfile.mkdtemp()
prep_build_folder(
build_folder=Path(build_folder),
base_image=base_container_image_id,
build_from=BuildFromImageType.SCRATCH,
extra_deps=runtime_extra_deps,
)
base_runtime_image = modal.Image.from_dockerfile(
path=os.path.join(build_folder, 'Dockerfile'),
context_mount=modal.Mount.from_local_dir(
local_path=build_folder,
remote_path='.', # to current WORKDIR
),
)
else:
raise ValueError(
'Neither runtime container image nor base container image is set'
)
return base_runtime_image.run_commands(
"""
# Disable bracketed paste
# https://github.com/pexpect/pexpect/issues/669
echo "set enable-bracketed-paste off" >> /etc/inputrc && \\
echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
""".strip()
)
@tenacity.retry(
stop=tenacity.stop_after_attempt(5),
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
)
def _init_sandbox(
self,
sandbox_workspace_dir: str,
plugins: list[PluginRequirement] | None = None,
):
try:
self.log('debug', 'Preparing to start container...')
# Combine environment variables
environment: dict[str, str | None] = {
'port': str(self.container_port),
'PYTHONUNBUFFERED': '1',
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug:
environment['DEBUG'] = 'true'
env_secret = modal.Secret.from_dict(environment)
self.log('debug', f'Sandbox workspace: {sandbox_workspace_dir}')
sandbox_start_cmd = get_action_execution_server_startup_command(
server_port=self.container_port,
plugins=self.plugins,
app_config=self.config,
)
self.log('debug', f'Starting container with command: {sandbox_start_cmd}')
self.sandbox = modal.Sandbox.create(
*sandbox_start_cmd,
secrets=[env_secret],
workdir='/openhands/code',
encrypted_ports=[self.container_port, self._vscode_port],
image=self.image,
app=self.app,
client=self.modal_client,
timeout=60 * 60,
)
MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
self.log('debug', 'Container started')
except Exception as e:
self.log(
'error', f'Error: Instance {self.sid} FAILED to start container!\n'
)
self.log('error', str(e))
self.close()
raise e
def close(self):
"""Closes the ModalRuntime and associated objects."""
super().close()
if not self.attach_to_existing and self.sandbox:
self.sandbox.terminate()
@property
def vscode_url(self) -> str | None:
if self._vscode_url is not None: # cached value
self.log('debug', f'VSCode URL: {self._vscode_url}')
return self._vscode_url
token = super().get_vscode_token()
if not token:
self.log('error', 'VSCode token not found')
return None
if not self.sandbox:
self.log('error', 'Sandbox not initialized')
return None
tunnel = self.sandbox.tunnels()[self._vscode_port]
tunnel_url = tunnel.url
self._vscode_url = (
tunnel_url
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
)
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url

View File

@@ -1,31 +0,0 @@
# Runloop Runtime
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
for more detail
## Access
Runloop is currently available in a closed beta. For early access, or
just to say hello, sign up at https://www.runloop.ai/hello
## Set up
With your runloop API,
```bash
export RUNLOOP_API_KEY=<your-api-key>
```
Configure the runtime
```bash
export RUNTIME="runloop"
```
## Interact with your devbox
Runloop provides additional tools to interact with your Devbox based
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
to date list of tools.
### Dashboard
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
### CLI
Use the Runloop CLI to view logs, execute commands, and more.
See the setup instructions [here](https://docs.runloop.ai/tools/cli)

View File

@@ -1,198 +0,0 @@
import logging
from typing import Callable
import tenacity
from runloop_api_client import Runloop
from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.utils.tenacity_stop import stop_if_should_exit
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
class RunloopRuntime(ActionExecutionClient):
"""The RunloopRuntime class is an DockerRuntime that utilizes Runloop Devbox as a runtime environment."""
_sandbox_port: int = 4444
_vscode_port: int = 4445
def __init__(
self,
config: OpenHandsConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
):
assert config.runloop_api_key is not None, 'Runloop API key is required'
self.devbox: DevboxView | None = None
self.config = config
self.runloop_api_client = Runloop(
bearer_token=config.runloop_api_key.get_secret_value(),
)
self.container_name = CONTAINER_NAME_PREFIX + sid
super().__init__(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
headless_mode,
user_id,
git_provider_tokens,
)
# Buffer for container logs
self._vscode_url: str | None = None
@property
def action_execution_server_url(self):
return self.api_url
@tenacity.retry(
stop=tenacity.stop_after_attempt(120),
wait=tenacity.wait_fixed(1),
)
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
"""Pull devbox status until it is running"""
if devbox == 'running':
return devbox
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
if devbox.status != 'running':
raise ConnectionRefusedError('Devbox is not running')
# Devbox is connected and running
logging.debug(f'devbox.id={devbox.id} is running')
return devbox
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
start_command = get_action_execution_server_startup_command(
server_port=self._sandbox_port,
plugins=self.plugins,
app_config=self.config,
)
# Add some additional commands based on our image
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
# (ie browser) to be installed as root
# Convert start_command list to a single command string with additional setup
start_command_str = (
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
'cd /openhands/code && '
'/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
+ ' '.join(start_command)
)
entrypoint = f"sudo bash -c '{start_command_str}'"
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port, self._vscode_port],
resource_size_request='LARGE',
launch_commands=[
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
],
),
metadata={'container-name': self.container_name},
)
return self._wait_for_devbox(devbox)
async def connect(self):
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
if self.attach_to_existing:
active_devboxes = self.runloop_api_client.devboxes.list(
status='running'
).devboxes
self.devbox = next(
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
)
if self.devbox is None:
self.devbox = self._create_new_devbox()
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
tunnel = self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._sandbox_port,
)
self.api_url = tunnel.url
logger.info(f'Container started. Server url: {self.api_url}')
# End Runloop connect
# NOTE: Copied from DockerRuntime
logger.info('Waiting for client to become ready...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
self._wait_until_alive()
if not self.attach_to_existing:
self.setup_initial_env()
logger.info(
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
)
self.set_runtime_status(RuntimeStatus.READY)
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
super().check_if_alive()
def close(self, rm_all_containers: bool | None = True):
super().close()
if self.attach_to_existing:
return
if self.devbox:
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
@property
def vscode_url(self) -> str | None:
if self._vscode_url is not None: # cached value
return self._vscode_url
token = super().get_vscode_token()
if not token:
return None
if not self.devbox:
return None
self._vscode_url = (
self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._vscode_port,
).url
+ f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
)
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url