Spaces:
Build error
Build error
import os | |
from functools import lru_cache | |
from typing import Callable | |
import typing | |
from uuid import UUID | |
import docker | |
import httpx | |
import tenacity | |
from docker.models.containers import Container | |
from openhands.core.config import OpenHandsConfig | |
from openhands.core.exceptions import ( | |
AgentRuntimeDisconnectedError, | |
AgentRuntimeNotFoundError, | |
) | |
from openhands.core.logger import DEBUG, DEBUG_RUNTIME | |
from openhands.core.logger import openhands_logger as logger | |
from openhands.events import EventStream | |
from openhands.runtime.builder import DockerRuntimeBuilder | |
from openhands.runtime.impl.action_execution.action_execution_client import ( | |
ActionExecutionClient, | |
) | |
from openhands.runtime.impl.docker.containers import stop_all_containers | |
from openhands.runtime.plugins import PluginRequirement | |
from openhands.runtime.utils import find_available_tcp_port | |
from openhands.runtime.utils.command import ( | |
DEFAULT_MAIN_MODULE, | |
get_action_execution_server_startup_command, | |
) | |
from openhands.runtime.utils.log_streamer import LogStreamer | |
from openhands.runtime.utils.runtime_build import build_runtime_image | |
from openhands.utils.async_utils import call_sync_from_async | |
from openhands.utils.shutdown_listener import add_shutdown_listener | |
from openhands.utils.tenacity_stop import stop_if_should_exit | |
CONTAINER_NAME_PREFIX = 'openhands-runtime-' | |
EXECUTION_SERVER_PORT_RANGE = (30000, 39999) | |
VSCODE_PORT_RANGE = (40000, 49999) | |
APP_PORT_RANGE_1 = (50000, 54999) | |
APP_PORT_RANGE_2 = (55000, 59999) | |
def _is_retryablewait_until_alive_error(exception: Exception) -> bool: | |
if isinstance(exception, tenacity.RetryError): | |
cause = exception.last_attempt.exception() | |
return _is_retryablewait_until_alive_error(cause) | |
return isinstance( | |
exception, | |
( | |
ConnectionError, | |
httpx.ConnectTimeout, | |
httpx.NetworkError, | |
httpx.RemoteProtocolError, | |
httpx.HTTPStatusError, | |
httpx.ReadTimeout, | |
), | |
) | |
class DockerRuntime(ActionExecutionClient): | |
"""This runtime will subscribe the event stream. | |
When receive an event, it will send the event to runtime-client which run inside the docker 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. | |
""" | |
_shutdown_listener_id: UUID | None = None | |
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, | |
main_module: str = DEFAULT_MAIN_MODULE, | |
): | |
if not DockerRuntime._shutdown_listener_id: | |
DockerRuntime._shutdown_listener_id = add_shutdown_listener( | |
lambda: stop_all_containers(CONTAINER_NAME_PREFIX) | |
) | |
self.config = config | |
self.status_callback = status_callback | |
self._host_port = -1 | |
self._container_port = -1 | |
self._vscode_port = -1 | |
self._app_ports: list[int] = [] | |
if os.environ.get('DOCKER_HOST_ADDR'): | |
logger.info( | |
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url' | |
) | |
self.config.sandbox.local_runtime_url = ( | |
f'http://{os.environ["DOCKER_HOST_ADDR"]}' | |
) | |
self.docker_client: docker.DockerClient = self._init_docker_client() | |
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}' | |
self.base_container_image = self.config.sandbox.base_container_image | |
self.runtime_container_image = self.config.sandbox.runtime_container_image | |
self.container_name = CONTAINER_NAME_PREFIX + sid | |
self.container: Container | None = None | |
self.main_module = main_module | |
self.runtime_builder = DockerRuntimeBuilder(self.docker_client) | |
# Buffer for container logs | |
self.log_streamer: LogStreamer | None = None | |
super().__init__( | |
config, | |
event_stream, | |
sid, | |
plugins, | |
env_vars, | |
status_callback, | |
attach_to_existing, | |
headless_mode, | |
) | |
# Log runtime_extra_deps after base class initialization so self.sid is available | |
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}', | |
) | |
def action_execution_server_url(self) -> str: | |
return self.api_url | |
async def connect(self) -> None: | |
self.send_status_message('STATUS$STARTING_RUNTIME') | |
try: | |
await call_sync_from_async(self._attach_to_container) | |
except docker.errors.NotFound as e: | |
if self.attach_to_existing: | |
self.log( | |
'warning', | |
f'Container {self.container_name} not found.', | |
) | |
raise AgentRuntimeDisconnectedError from e | |
self.maybe_build_runtime_container_image() | |
self.log( | |
'info', f'Starting runtime with image: {self.runtime_container_image}' | |
) | |
await call_sync_from_async(self.init_container) | |
self.log( | |
'info', | |
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}', | |
) | |
if DEBUG_RUNTIME and self.container: | |
self.log_streamer = LogStreamer(self.container, self.log) | |
else: | |
self.log_streamer = None | |
if not self.attach_to_existing: | |
self.log('info', f'Waiting for client to become ready at {self.api_url}...') | |
self.send_status_message('STATUS$WAITING_FOR_CLIENT') | |
await call_sync_from_async(self.wait_until_alive) | |
if not self.attach_to_existing: | |
self.log('info', 'Runtime is ready.') | |
if not self.attach_to_existing: | |
await call_sync_from_async(self.setup_initial_env) | |
self.log( | |
'debug', | |
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}', | |
) | |
if not self.attach_to_existing: | |
self.send_status_message(' ') | |
self._runtime_initialized = True | |
def maybe_build_runtime_container_image(self): | |
if self.runtime_container_image is None: | |
if self.base_container_image is None: | |
raise ValueError( | |
'Neither runtime container image nor base container image is set' | |
) | |
self.send_status_message('STATUS$STARTING_CONTAINER') | |
self.runtime_container_image = build_runtime_image( | |
self.base_container_image, | |
self.runtime_builder, | |
platform=self.config.sandbox.platform, | |
extra_deps=self.config.sandbox.runtime_extra_deps, | |
force_rebuild=self.config.sandbox.force_rebuild_runtime, | |
extra_build_args=self.config.sandbox.runtime_extra_build_args, | |
) | |
def _init_docker_client() -> docker.DockerClient: | |
try: | |
return docker.from_env() | |
except Exception as ex: | |
logger.error( | |
'Launch docker client failed. Please make sure you have installed docker and started docker desktop/daemon.', | |
) | |
raise ex | |
def _process_volumes(self) -> dict[str, dict[str, str]]: | |
"""Process volume mounts based on configuration. | |
Returns: | |
A dictionary mapping host paths to container bind mounts with their modes. | |
""" | |
# Initialize volumes dictionary | |
volumes: dict[str, dict[str, str]] = {} | |
# Process volumes (comma-delimited) | |
if self.config.sandbox.volumes is not None: | |
# Handle multiple mounts with comma delimiter | |
mounts = self.config.sandbox.volumes.split(',') | |
for mount in mounts: | |
parts = mount.split(':') | |
if len(parts) >= 2: | |
host_path = os.path.abspath(parts[0]) | |
container_path = parts[1] | |
# Default mode is 'rw' if not specified | |
mount_mode = parts[2] if len(parts) > 2 else 'rw' | |
volumes[host_path] = { | |
'bind': container_path, | |
'mode': mount_mode, | |
} | |
logger.debug( | |
f'Mount dir (sandbox.volumes): {host_path} to {container_path} with mode: {mount_mode}' | |
) | |
# Legacy mounting with workspace_* parameters | |
elif ( | |
self.config.workspace_mount_path is not None | |
and self.config.workspace_mount_path_in_sandbox is not None | |
): | |
mount_mode = 'rw' # Default mode | |
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}} | |
volumes[self.config.workspace_mount_path] = { | |
'bind': self.config.workspace_mount_path_in_sandbox, | |
'mode': mount_mode, | |
} | |
logger.debug( | |
f'Mount dir (legacy): {self.config.workspace_mount_path} with mode: {mount_mode}' | |
) | |
return volumes | |
def init_container(self) -> None: | |
self.log('debug', 'Preparing to start container...') | |
self.send_status_message('STATUS$PREPARING_CONTAINER') | |
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE) | |
self._container_port = self._host_port | |
# Use the configured vscode_port if provided, otherwise find an available port | |
self._vscode_port = ( | |
self.config.sandbox.vscode_port | |
or self._find_available_port(VSCODE_PORT_RANGE) | |
) | |
self._app_ports = [ | |
self._find_available_port(APP_PORT_RANGE_1), | |
self._find_available_port(APP_PORT_RANGE_2), | |
] | |
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}' | |
use_host_network = self.config.sandbox.use_host_network | |
network_mode: typing.Literal['host'] | None = 'host' if use_host_network else None | |
# Initialize port mappings | |
port_mapping: dict[str, list[dict[str, str]]] | None = None | |
if not use_host_network: | |
port_mapping = { | |
f'{self._container_port}/tcp': [ | |
{ | |
'HostPort': str(self._host_port), | |
'HostIp': self.config.sandbox.runtime_binding_address, | |
} | |
], | |
} | |
if self.vscode_enabled: | |
port_mapping[f'{self._vscode_port}/tcp'] = [ | |
{ | |
'HostPort': str(self._vscode_port), | |
'HostIp': self.config.sandbox.runtime_binding_address, | |
} | |
] | |
for port in self._app_ports: | |
port_mapping[f'{port}/tcp'] = [ | |
{ | |
'HostPort': str(port), | |
'HostIp': self.config.sandbox.runtime_binding_address, | |
} | |
] | |
else: | |
self.log( | |
'warn', | |
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop', | |
) | |
# Combine environment variables | |
environment = dict(**self.initial_env_vars) | |
environment.update( | |
{ | |
'port': str(self._container_port), | |
'PYTHONUNBUFFERED': '1', | |
# Passing in the ports means nested runtimes do not come up with their own ports! | |
'VSCODE_PORT': str(self._vscode_port), | |
'APP_PORT_1': str(self._app_ports[0]), | |
'APP_PORT_2': str(self._app_ports[1]), | |
'PIP_BREAK_SYSTEM_PACKAGES': '1', | |
} | |
) | |
if self.config.debug or DEBUG: | |
environment['DEBUG'] = 'true' | |
# also update with runtime_startup_env_vars | |
environment.update(self.config.sandbox.runtime_startup_env_vars) | |
self.log('debug', f'Workspace Base: {self.config.workspace_base}') | |
# Process volumes for mounting | |
volumes = self._process_volumes() | |
# If no volumes were configured, set to None | |
if not volumes: | |
logger.debug( | |
'Mount dir is not set, will not mount the workspace directory to the container' | |
) | |
volumes = {} # Empty dict instead of None to satisfy mypy | |
self.log( | |
'debug', | |
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}', | |
) | |
command = self.get_action_execution_server_startup_command() | |
try: | |
if self.runtime_container_image is None: | |
raise ValueError("Runtime container image is not set") | |
self.container = self.docker_client.containers.run( | |
self.runtime_container_image, | |
command=command, | |
# Override the default 'bash' entrypoint because the command is a binary. | |
entrypoint=[], | |
network_mode=network_mode, | |
ports=port_mapping, | |
working_dir='/openhands/code/', # do not change this! | |
name=self.container_name, | |
detach=True, | |
environment=environment, | |
volumes=volumes, # type: ignore | |
device_requests=( | |
[docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)] | |
if self.config.sandbox.enable_gpu | |
else None | |
), | |
**(self.config.sandbox.docker_runtime_kwargs or {}), | |
) | |
self.log('debug', f'Container started. Server url: {self.api_url}') | |
self.send_status_message('STATUS$CONTAINER_STARTED') | |
except Exception as e: | |
self.log( | |
'error', | |
f'Error: Instance {self.container_name} FAILED to start container!\n', | |
) | |
self.close() | |
raise e | |
def _attach_to_container(self) -> None: | |
self.container = self.docker_client.containers.get(self.container_name) | |
if self.container.status == 'exited': | |
self.container.start() | |
config = self.container.attrs['Config'] | |
for env_var in config['Env']: | |
if env_var.startswith('port='): | |
self._host_port = int(env_var.split('port=')[1]) | |
self._container_port = self._host_port | |
elif env_var.startswith('VSCODE_PORT='): | |
self._vscode_port = int(env_var.split('VSCODE_PORT=')[1]) | |
self._app_ports = [] | |
exposed_ports = config.get('ExposedPorts') | |
if exposed_ports: | |
for exposed_port in exposed_ports.keys(): | |
exposed_port = int(exposed_port.split('/tcp')[0]) | |
if ( | |
exposed_port != self._host_port | |
and exposed_port != self._vscode_port | |
): | |
self._app_ports.append(exposed_port) | |
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}' | |
self.log( | |
'debug', | |
f'attached to container: {self.container_name} {self._container_port} {self.api_url}', | |
) | |
def wait_until_alive(self) -> None: | |
try: | |
container = self.docker_client.containers.get(self.container_name) | |
if container.status == 'exited': | |
raise AgentRuntimeDisconnectedError( | |
f'Container {self.container_name} has exited.' | |
) | |
except docker.errors.NotFound: | |
raise AgentRuntimeNotFoundError( | |
f'Container {self.container_name} not found.' | |
) | |
self.check_if_alive() | |
def close(self, rm_all_containers: bool | None = None) -> None: | |
"""Closes the DockerRuntime and associated objects | |
Parameters: | |
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix | |
""" | |
super().close() | |
if self.log_streamer: | |
self.log_streamer.close() | |
if rm_all_containers is None: | |
rm_all_containers = self.config.sandbox.rm_all_containers | |
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing: | |
return | |
close_prefix = ( | |
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name | |
) | |
stop_all_containers(close_prefix) | |
def _is_port_in_use_docker(self, port: int) -> bool: | |
containers = self.docker_client.containers.list() | |
for container in containers: | |
container_ports = container.ports | |
if str(port) in str(container_ports): | |
return True | |
return False | |
def _find_available_port( | |
self, port_range: tuple[int, int], max_attempts: int = 5 | |
) -> int: | |
port = port_range[1] | |
for _ in range(max_attempts): | |
port = find_available_tcp_port(port_range[0], port_range[1]) | |
if not self._is_port_in_use_docker(port): | |
return port | |
# If no port is found after max_attempts, return the last tried port | |
return port | |
def vscode_url(self) -> str | None: | |
token = super().get_vscode_token() | |
if not token: | |
return None | |
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}' | |
return vscode_url | |
def web_hosts(self) -> dict[str, int]: | |
hosts: dict[str, int] = {} | |
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost') | |
for port in self._app_ports: | |
hosts[f'http://{host_addr}:{port}'] = port | |
return hosts | |
def pause(self) -> None: | |
"""Pause the runtime by stopping the container. | |
This is different from container.stop() as it ensures environment variables are properly preserved.""" | |
if not self.container: | |
raise RuntimeError('Container not initialized') | |
# First, ensure all environment variables are properly persisted in .bashrc | |
# This is already handled by add_env_vars in base.py | |
# Stop the container | |
self.container.stop() | |
self.log('debug', f'Container {self.container_name} paused') | |
def resume(self) -> None: | |
"""Resume the runtime by starting the container. | |
This is different from container.start() as it ensures environment variables are properly restored.""" | |
if not self.container: | |
raise RuntimeError('Container not initialized') | |
# Start the container | |
self.container.start() | |
self.log('debug', f'Container {self.container_name} resumed') | |
# Wait for the container to be ready | |
self.wait_until_alive() | |
async def delete(cls, conversation_id: str) -> None: | |
docker_client = cls._init_docker_client() | |
try: | |
container_name = CONTAINER_NAME_PREFIX + conversation_id | |
container = docker_client.containers.get(container_name) | |
container.remove(force=True) | |
except docker.errors.APIError: | |
pass | |
except docker.errors.NotFound: | |
pass | |
finally: | |
docker_client.close() | |
def get_action_execution_server_startup_command(self) -> list[str]: | |
return get_action_execution_server_startup_command( | |
server_port=self._container_port, | |
plugins=self.plugins, | |
app_config=self.config, | |
main_module=self.main_module, | |
) | |