mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add ProcessSandboxService implementation for process-based sandboxes (#11394)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
f4fd8ea907
commit
0522734875
12
enterprise/poetry.lock
generated
12
enterprise/poetry.lock
generated
@ -5536,8 +5536,8 @@ websockets = ">=12"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
subdirectory = "openhands/agent_server"
|
||||
|
||||
[[package]]
|
||||
@ -5582,8 +5582,8 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc", subdirectory = "openhands/agent_server"}
|
||||
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc", subdirectory = "openhands/sdk"}
|
||||
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "08cf609a996523c0199c61c768d74417b7e96109", subdirectory = "openhands/agent_server"}
|
||||
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "08cf609a996523c0199c61c768d74417b7e96109", subdirectory = "openhands/sdk"}
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@ -5662,8 +5662,8 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
subdirectory = "openhands/sdk"
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -130,6 +130,12 @@ def config_from_env() -> AppServerConfig:
|
||||
from openhands.app_server.sandbox.docker_sandbox_spec_service import (
|
||||
DockerSandboxSpecServiceInjector,
|
||||
)
|
||||
from openhands.app_server.sandbox.process_sandbox_service import (
|
||||
ProcessSandboxServiceInjector,
|
||||
)
|
||||
from openhands.app_server.sandbox.process_sandbox_spec_service import (
|
||||
ProcessSandboxSpecServiceInjector,
|
||||
)
|
||||
from openhands.app_server.sandbox.remote_sandbox_service import (
|
||||
RemoteSandboxServiceInjector,
|
||||
)
|
||||
@ -155,12 +161,16 @@ def config_from_env() -> AppServerConfig:
|
||||
api_key=os.environ['SANDBOX_API_KEY'],
|
||||
api_url=os.environ['SANDBOX_REMOTE_RUNTIME_API_URL'],
|
||||
)
|
||||
elif os.getenv('RUNTIME') in ('local', 'process'):
|
||||
config.sandbox = ProcessSandboxServiceInjector()
|
||||
else:
|
||||
config.sandbox = DockerSandboxServiceInjector()
|
||||
|
||||
if config.sandbox_spec is None:
|
||||
if os.getenv('RUNTIME') == 'remote':
|
||||
config.sandbox_spec = RemoteSandboxSpecServiceInjector()
|
||||
elif os.getenv('RUNTIME') in ('local', 'process'):
|
||||
config.sandbox_spec = ProcessSandboxSpecServiceInjector()
|
||||
else:
|
||||
config.sandbox_spec = DockerSandboxSpecServiceInjector()
|
||||
|
||||
|
||||
438
openhands/app_server/sandbox/process_sandbox_service.py
Normal file
438
openhands/app_server/sandbox/process_sandbox_service.py
Normal file
@ -0,0 +1,438 @@
|
||||
"""Process-based sandbox service implementation.
|
||||
|
||||
This service creates sandboxes by spawning separate agent server processes,
|
||||
each running within a dedicated directory.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import base62
|
||||
import httpx
|
||||
import psutil
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.app_server.errors import SandboxError
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
ExposedUrl,
|
||||
SandboxInfo,
|
||||
SandboxPage,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_service import (
|
||||
SandboxService,
|
||||
SandboxServiceInjector,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessInfo(BaseModel):
|
||||
"""Information about a running process."""
|
||||
|
||||
pid: int
|
||||
port: int
|
||||
user_id: str | None
|
||||
working_dir: str
|
||||
session_api_key: str
|
||||
created_at: datetime
|
||||
sandbox_spec_id: str
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
# Global store
|
||||
_processes: dict[str, ProcessInfo] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessSandboxService(SandboxService):
|
||||
"""Sandbox service that spawns separate agent server processes.
|
||||
|
||||
Each sandbox is implemented as a separate Python process running the
|
||||
action execution server, with each process:
|
||||
- Operating in a dedicated directory
|
||||
- Listening on a unique port
|
||||
- Having its own session API key
|
||||
"""
|
||||
|
||||
user_id: str | None
|
||||
sandbox_spec_service: SandboxSpecService
|
||||
base_working_dir: str
|
||||
base_port: int
|
||||
python_executable: str
|
||||
agent_server_module: str
|
||||
health_check_path: str
|
||||
httpx_client: httpx.AsyncClient
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize the service after dataclass creation."""
|
||||
# Ensure base working directory exists
|
||||
os.makedirs(self.base_working_dir, exist_ok=True)
|
||||
|
||||
def _find_unused_port(self) -> int:
|
||||
"""Find an unused port starting from base_port."""
|
||||
port = self.base_port
|
||||
while port < self.base_port + 10000: # Try up to 10000 ports
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', port))
|
||||
return port
|
||||
except OSError:
|
||||
port += 1
|
||||
raise SandboxError('No available ports found')
|
||||
|
||||
def _create_sandbox_directory(self, sandbox_id: str) -> str:
|
||||
"""Create a dedicated directory for the sandbox."""
|
||||
sandbox_dir = os.path.join(self.base_working_dir, sandbox_id)
|
||||
os.makedirs(sandbox_dir, exist_ok=True)
|
||||
return sandbox_dir
|
||||
|
||||
async def _start_agent_process(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
port: int,
|
||||
working_dir: str,
|
||||
session_api_key: str,
|
||||
sandbox_spec: SandboxSpecInfo,
|
||||
) -> subprocess.Popen:
|
||||
"""Start the agent server process."""
|
||||
|
||||
# Prepare environment variables
|
||||
env = os.environ.copy()
|
||||
env.update(sandbox_spec.initial_env)
|
||||
env['SESSION_API_KEY'] = session_api_key
|
||||
|
||||
# Prepare command arguments
|
||||
cmd = [
|
||||
self.python_executable,
|
||||
'-m',
|
||||
self.agent_server_module,
|
||||
'--port',
|
||||
str(port),
|
||||
]
|
||||
|
||||
_logger.info(
|
||||
f'Starting agent process for sandbox {sandbox_id}: {" ".join(cmd)}'
|
||||
)
|
||||
|
||||
try:
|
||||
# Start the process
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
cwd=working_dir,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Wait a moment for the process to start
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if process is still running
|
||||
if process.poll() is not None:
|
||||
stdout, stderr = process.communicate()
|
||||
raise SandboxError(f'Agent process failed to start: {stderr.decode()}')
|
||||
|
||||
return process
|
||||
|
||||
except Exception as e:
|
||||
raise SandboxError(f'Failed to start agent process: {e}')
|
||||
|
||||
async def _wait_for_server_ready(self, port: int, timeout: int = 30) -> bool:
|
||||
"""Wait for the agent server to be ready."""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response = await self.httpx_client.get(
|
||||
f'http://localhost:{port}/alive', timeout=5.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('status') == 'ok':
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
def _get_process_status(self, process_info: ProcessInfo) -> SandboxStatus:
|
||||
"""Get the status of a process."""
|
||||
try:
|
||||
process = psutil.Process(process_info.pid)
|
||||
if process.is_running():
|
||||
status = process.status()
|
||||
if status == psutil.STATUS_RUNNING:
|
||||
return SandboxStatus.RUNNING
|
||||
elif status == psutil.STATUS_STOPPED:
|
||||
return SandboxStatus.PAUSED
|
||||
else:
|
||||
return SandboxStatus.STARTING
|
||||
else:
|
||||
return SandboxStatus.MISSING
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return SandboxStatus.MISSING
|
||||
|
||||
async def _process_to_sandbox_info(
|
||||
self, sandbox_id: str, process_info: ProcessInfo
|
||||
) -> SandboxInfo:
|
||||
"""Convert process info to sandbox info."""
|
||||
status = self._get_process_status(process_info)
|
||||
|
||||
exposed_urls = None
|
||||
session_api_key = None
|
||||
|
||||
if status == SandboxStatus.RUNNING:
|
||||
# Check if server is actually responding
|
||||
try:
|
||||
response = await self.httpx_client.get(
|
||||
f'http://localhost:{process_info.port}{self.health_check_path}',
|
||||
timeout=5.0,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
exposed_urls = [
|
||||
ExposedUrl(
|
||||
name=AGENT_SERVER,
|
||||
url=f'http://localhost:{process_info.port}',
|
||||
),
|
||||
]
|
||||
session_api_key = process_info.session_api_key
|
||||
else:
|
||||
status = SandboxStatus.ERROR
|
||||
except Exception:
|
||||
status = SandboxStatus.ERROR
|
||||
|
||||
return SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id=process_info.user_id,
|
||||
sandbox_spec_id=process_info.sandbox_spec_id,
|
||||
status=status,
|
||||
session_api_key=session_api_key,
|
||||
exposed_urls=exposed_urls,
|
||||
created_at=process_info.created_at,
|
||||
)
|
||||
|
||||
async def search_sandboxes(
|
||||
self,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> SandboxPage:
|
||||
"""Search for sandboxes."""
|
||||
# Get all process infos
|
||||
all_processes = list(_processes.items())
|
||||
|
||||
# Sort by creation time (newest first)
|
||||
all_processes.sort(key=lambda x: x[1].created_at, reverse=True)
|
||||
|
||||
# Apply pagination
|
||||
start_idx = 0
|
||||
if page_id:
|
||||
try:
|
||||
start_idx = int(page_id)
|
||||
except ValueError:
|
||||
start_idx = 0
|
||||
|
||||
end_idx = start_idx + limit
|
||||
paginated_processes = all_processes[start_idx:end_idx]
|
||||
|
||||
# Convert to sandbox infos
|
||||
items = []
|
||||
for sandbox_id, process_info in paginated_processes:
|
||||
sandbox_info = await self._process_to_sandbox_info(sandbox_id, process_info)
|
||||
items.append(sandbox_info)
|
||||
|
||||
# Determine next page ID
|
||||
next_page_id = None
|
||||
if end_idx < len(all_processes):
|
||||
next_page_id = str(end_idx)
|
||||
|
||||
return SandboxPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
|
||||
"""Get a single sandbox."""
|
||||
process_info = _processes.get(sandbox_id)
|
||||
if process_info is None:
|
||||
return None
|
||||
|
||||
return await self._process_to_sandbox_info(sandbox_id, process_info)
|
||||
|
||||
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
|
||||
"""Start a new sandbox."""
|
||||
# Get sandbox spec
|
||||
if sandbox_spec_id is None:
|
||||
sandbox_spec = await self.sandbox_spec_service.get_default_sandbox_spec()
|
||||
else:
|
||||
sandbox_spec_maybe = await self.sandbox_spec_service.get_sandbox_spec(
|
||||
sandbox_spec_id
|
||||
)
|
||||
if sandbox_spec_maybe is None:
|
||||
raise ValueError('Sandbox Spec not found')
|
||||
sandbox_spec = sandbox_spec_maybe
|
||||
|
||||
# Generate unique sandbox ID and session API key
|
||||
sandbox_id = base62.encodebytes(os.urandom(16))
|
||||
session_api_key = base62.encodebytes(os.urandom(32))
|
||||
|
||||
# Find available port
|
||||
port = self._find_unused_port()
|
||||
|
||||
# Create sandbox directory
|
||||
working_dir = self._create_sandbox_directory(sandbox_id)
|
||||
|
||||
# Start the agent process
|
||||
process = await self._start_agent_process(
|
||||
sandbox_id=sandbox_id,
|
||||
port=port,
|
||||
working_dir=working_dir,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_spec=sandbox_spec,
|
||||
)
|
||||
|
||||
# Store process info
|
||||
process_info = ProcessInfo(
|
||||
pid=process.pid,
|
||||
port=port,
|
||||
user_id=self.user_id,
|
||||
working_dir=working_dir,
|
||||
session_api_key=session_api_key,
|
||||
created_at=utc_now(),
|
||||
sandbox_spec_id=sandbox_spec.id,
|
||||
)
|
||||
_processes[sandbox_id] = process_info
|
||||
|
||||
# Wait for server to be ready
|
||||
if not await self._wait_for_server_ready(port):
|
||||
# Clean up if server didn't start properly
|
||||
await self.delete_sandbox(sandbox_id)
|
||||
raise SandboxError('Agent Server Failed to start properly')
|
||||
|
||||
return await self._process_to_sandbox_info(sandbox_id, process_info)
|
||||
|
||||
async def resume_sandbox(self, sandbox_id: str) -> bool:
|
||||
"""Resume a paused sandbox."""
|
||||
process_info = _processes.get(sandbox_id)
|
||||
if process_info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
process = psutil.Process(process_info.pid)
|
||||
if process.status() == psutil.STATUS_STOPPED:
|
||||
process.resume()
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return False
|
||||
|
||||
async def pause_sandbox(self, sandbox_id: str) -> bool:
|
||||
"""Pause a running sandbox."""
|
||||
process_info = _processes.get(sandbox_id)
|
||||
if process_info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
process = psutil.Process(process_info.pid)
|
||||
if process.is_running():
|
||||
process.suspend()
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return False
|
||||
|
||||
async def delete_sandbox(self, sandbox_id: str) -> bool:
|
||||
"""Delete a sandbox."""
|
||||
process_info = _processes.get(sandbox_id)
|
||||
if process_info is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Terminate the process
|
||||
process = psutil.Process(process_info.pid)
|
||||
if process.is_running():
|
||||
# Try graceful termination first
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except psutil.TimeoutExpired:
|
||||
# Force kill if graceful termination fails
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
# Clean up the working directory
|
||||
import shutil
|
||||
|
||||
if os.path.exists(process_info.working_dir):
|
||||
shutil.rmtree(process_info.working_dir, ignore_errors=True)
|
||||
|
||||
# Remove from our tracking
|
||||
del _processes[sandbox_id]
|
||||
|
||||
return True
|
||||
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as e:
|
||||
_logger.warning(f'Error deleting sandbox {sandbox_id}: {e}')
|
||||
# Still remove from tracking even if cleanup failed
|
||||
if sandbox_id in _processes:
|
||||
del _processes[sandbox_id]
|
||||
return True
|
||||
|
||||
|
||||
class ProcessSandboxServiceInjector(SandboxServiceInjector):
|
||||
"""Dependency injector for process sandbox services."""
|
||||
|
||||
base_working_dir: str = Field(
|
||||
default='/tmp/openhands-sandboxes',
|
||||
description='Base directory for sandbox working directories',
|
||||
)
|
||||
base_port: int = Field(
|
||||
default=8000, description='Base port number for agent servers'
|
||||
)
|
||||
python_executable: str = Field(
|
||||
default=sys.executable,
|
||||
description='Python executable to use for agent processes',
|
||||
)
|
||||
agent_server_module: str = Field(
|
||||
default='openhands.agent_server',
|
||||
description='Python module for the agent server',
|
||||
)
|
||||
health_check_path: str = Field(
|
||||
default='/alive', description='Health check endpoint path'
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SandboxService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import (
|
||||
get_httpx_client,
|
||||
get_sandbox_spec_service,
|
||||
get_user_context,
|
||||
)
|
||||
|
||||
async with (
|
||||
get_httpx_client(state, request) as httpx_client,
|
||||
get_sandbox_spec_service(state, request) as sandbox_spec_service,
|
||||
get_user_context(state, request) as user_context,
|
||||
):
|
||||
user_id = await user_context.get_user_id()
|
||||
yield ProcessSandboxService(
|
||||
user_id=user_id,
|
||||
sandbox_spec_service=sandbox_spec_service,
|
||||
base_working_dir=self.base_working_dir,
|
||||
base_port=self.base_port,
|
||||
python_executable=self.python_executable,
|
||||
agent_server_module=self.agent_server_module,
|
||||
health_check_path=self.health_check_path,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
43
openhands/app_server/sandbox/process_sandbox_spec_service.py
Normal file
43
openhands/app_server/sandbox/process_sandbox_spec_service.py
Normal file
@ -0,0 +1,43 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.app_server.sandbox.preset_sandbox_spec_service import (
|
||||
PresetSandboxSpecService,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import (
|
||||
SandboxSpecInfo,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import (
|
||||
AGENT_SERVER_VERSION,
|
||||
SandboxSpecService,
|
||||
SandboxSpecServiceInjector,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
|
||||
|
||||
def get_default_sandbox_specs():
|
||||
return [
|
||||
SandboxSpecInfo(
|
||||
id=AGENT_SERVER_VERSION,
|
||||
command=['python', '-m', 'openhands.agent_server'],
|
||||
initial_env={
|
||||
# VSCode disabled for now
|
||||
'OH_ENABLE_VS_CODE': '0',
|
||||
},
|
||||
working_dir='',
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ProcessSandboxSpecServiceInjector(SandboxSpecServiceInjector):
|
||||
specs: list[SandboxSpecInfo] = Field(
|
||||
default_factory=get_default_sandbox_specs,
|
||||
description='Preset list of sandbox specs',
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SandboxSpecService, None]:
|
||||
yield PresetSandboxSpecService(specs=self.specs)
|
||||
@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_VERSION = 'f8ca02c4a3b847bfc50b3c5e579ce126c511fefc'
|
||||
AGENT_SERVER_VERSION = '08cf609a996523c0199c61c768d74417b7e96109'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
10
poetry.lock
generated
10
poetry.lock
generated
@ -7044,8 +7044,8 @@ websockets = ">=12"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
subdirectory = "openhands/agent_server"
|
||||
|
||||
[[package]]
|
||||
@ -7073,8 +7073,8 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/All-Hands-AI/agent-sdk.git"
|
||||
reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
resolved_reference = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc"
|
||||
reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
resolved_reference = "08cf609a996523c0199c61c768d74417b7e96109"
|
||||
subdirectory = "openhands/sdk"
|
||||
|
||||
[[package]]
|
||||
@ -12583,4 +12583,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "90ae740f15865e77791e259038940ba45652f2639159cad26f2b3292948b32e8"
|
||||
content-hash = "056952164091353e4fd14ac7710b1c74bc2e59511578afecf279fc170be1c68e"
|
||||
|
||||
@ -113,10 +113,10 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/agent_server", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
|
||||
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
|
||||
openhands-agent-server = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/agent_server", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
|
||||
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
|
||||
# This refuses to install
|
||||
# openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "f8ca02c4a3b847bfc50b3c5e579ce126c511fefc" }
|
||||
# openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "08cf609a996523c0199c61c768d74417b7e96109" }
|
||||
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
343
tests/unit/app_server/test_process_sandbox_service.py
Normal file
343
tests/unit/app_server/test_process_sandbox_service.py
Normal file
@ -0,0 +1,343 @@
|
||||
"""Tests for ProcessSandboxService."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
from openhands.app_server.sandbox.process_sandbox_service import (
|
||||
ProcessInfo,
|
||||
ProcessSandboxService,
|
||||
ProcessSandboxServiceInjector,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
|
||||
|
||||
class MockSandboxSpec:
|
||||
"""Mock sandbox specification."""
|
||||
|
||||
def __init__(self):
|
||||
self.id = 'test-spec'
|
||||
self.initial_env = {'TEST_VAR': 'test_value'}
|
||||
self.plugins = []
|
||||
|
||||
|
||||
class MockSandboxSpecService:
|
||||
"""Mock sandbox spec service."""
|
||||
|
||||
async def get_default_sandbox_spec(self):
|
||||
return MockSandboxSpec()
|
||||
|
||||
async def get_sandbox_spec(self, spec_id: str):
|
||||
if spec_id == 'test-spec':
|
||||
return MockSandboxSpec()
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Mock httpx client."""
|
||||
client = AsyncMock(spec=httpx.AsyncClient)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def process_sandbox_service(mock_httpx_client, temp_dir):
|
||||
"""Create a ProcessSandboxService instance for testing."""
|
||||
return ProcessSandboxService(
|
||||
user_id='test-user-id',
|
||||
sandbox_spec_service=MockSandboxSpecService(),
|
||||
base_working_dir=temp_dir,
|
||||
base_port=9000,
|
||||
python_executable='python',
|
||||
agent_server_module='openhands.agent_server',
|
||||
health_check_path='/alive',
|
||||
httpx_client=mock_httpx_client,
|
||||
)
|
||||
|
||||
|
||||
class TestProcessSandboxService:
|
||||
"""Test cases for ProcessSandboxService."""
|
||||
|
||||
def test_find_unused_port(self, process_sandbox_service):
|
||||
"""Test finding an unused port."""
|
||||
port = process_sandbox_service._find_unused_port()
|
||||
assert port >= process_sandbox_service.base_port
|
||||
assert port < process_sandbox_service.base_port + 10000
|
||||
|
||||
@patch('os.makedirs')
|
||||
def test_create_sandbox_directory(self, mock_makedirs, process_sandbox_service):
|
||||
"""Test creating a sandbox directory."""
|
||||
sandbox_dir = process_sandbox_service._create_sandbox_directory('test-id')
|
||||
|
||||
expected_dir = os.path.join(process_sandbox_service.base_working_dir, 'test-id')
|
||||
assert sandbox_dir == expected_dir
|
||||
mock_makedirs.assert_called_once_with(expected_dir, exist_ok=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_server_ready_success(self, process_sandbox_service):
|
||||
"""Test waiting for server to be ready - success case."""
|
||||
# Mock successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'status': 'ok'}
|
||||
process_sandbox_service.httpx_client.get.return_value = mock_response
|
||||
|
||||
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_server_ready_timeout(self, process_sandbox_service):
|
||||
"""Test waiting for server to be ready - timeout case."""
|
||||
# Mock failed response
|
||||
process_sandbox_service.httpx_client.get.side_effect = Exception(
|
||||
'Connection failed'
|
||||
)
|
||||
|
||||
result = await process_sandbox_service._wait_for_server_ready(9000, timeout=1)
|
||||
assert result is False
|
||||
|
||||
@patch('psutil.Process')
|
||||
def test_get_process_status_running(
|
||||
self, mock_process_class, process_sandbox_service
|
||||
):
|
||||
"""Test getting process status for running process."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.is_running.return_value = True
|
||||
mock_process.status.return_value = psutil.STATUS_RUNNING
|
||||
mock_process_class.return_value = mock_process
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
status = process_sandbox_service._get_process_status(process_info)
|
||||
assert status == SandboxStatus.RUNNING
|
||||
|
||||
@patch('psutil.Process')
|
||||
def test_get_process_status_missing(
|
||||
self, mock_process_class, process_sandbox_service
|
||||
):
|
||||
"""Test getting process status for missing process."""
|
||||
import psutil
|
||||
|
||||
mock_process_class.side_effect = psutil.NoSuchProcess(1234)
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
status = process_sandbox_service._get_process_status(process_info)
|
||||
assert status == SandboxStatus.MISSING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sandboxes_empty(self, process_sandbox_service):
|
||||
"""Test searching sandboxes when none exist."""
|
||||
result = await process_sandbox_service.search_sandboxes()
|
||||
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_not_found(self, process_sandbox_service):
|
||||
"""Test getting a sandbox that doesn't exist."""
|
||||
result = await process_sandbox_service.get_sandbox('nonexistent')
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_sandbox_not_found(self, process_sandbox_service):
|
||||
"""Test resuming a sandbox that doesn't exist."""
|
||||
result = await process_sandbox_service.resume_sandbox('nonexistent')
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_sandbox_not_found(self, process_sandbox_service):
|
||||
"""Test pausing a sandbox that doesn't exist."""
|
||||
result = await process_sandbox_service.pause_sandbox('nonexistent')
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_sandbox_not_found(self, process_sandbox_service):
|
||||
"""Test deleting a sandbox that doesn't exist."""
|
||||
result = await process_sandbox_service.delete_sandbox('nonexistent')
|
||||
assert result is False
|
||||
|
||||
@patch('psutil.Process')
|
||||
def test_get_process_status_paused(
|
||||
self, mock_process_class, process_sandbox_service
|
||||
):
|
||||
"""Test getting process status for paused process."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.is_running.return_value = True
|
||||
mock_process.status.return_value = psutil.STATUS_STOPPED
|
||||
mock_process_class.return_value = mock_process
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
status = process_sandbox_service._get_process_status(process_info)
|
||||
assert status == SandboxStatus.PAUSED
|
||||
|
||||
@patch('psutil.Process')
|
||||
def test_get_process_status_starting(
|
||||
self, mock_process_class, process_sandbox_service
|
||||
):
|
||||
"""Test getting process status for starting process."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.is_running.return_value = True
|
||||
mock_process.status.return_value = psutil.STATUS_SLEEPING
|
||||
mock_process_class.return_value = mock_process
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
status = process_sandbox_service._get_process_status(process_info)
|
||||
assert status == SandboxStatus.STARTING
|
||||
|
||||
@patch('psutil.Process')
|
||||
def test_get_process_status_access_denied(
|
||||
self, mock_process_class, process_sandbox_service
|
||||
):
|
||||
"""Test getting process status when access is denied."""
|
||||
mock_process_class.side_effect = psutil.AccessDenied(1234)
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
status = process_sandbox_service._get_process_status(process_info)
|
||||
assert status == SandboxStatus.MISSING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_to_sandbox_info_error_status(self, process_sandbox_service):
|
||||
"""Test converting process info to sandbox info when server is not responding."""
|
||||
# Mock a process that's running but server is not responding
|
||||
with patch.object(
|
||||
process_sandbox_service,
|
||||
'_get_process_status',
|
||||
return_value=SandboxStatus.RUNNING,
|
||||
):
|
||||
# Mock httpx client to return error response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
process_sandbox_service.httpx_client.get.return_value = mock_response
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
|
||||
'test-sandbox', process_info
|
||||
)
|
||||
|
||||
assert sandbox_info.status == SandboxStatus.ERROR
|
||||
assert sandbox_info.session_api_key is None
|
||||
assert sandbox_info.exposed_urls is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_to_sandbox_info_exception(self, process_sandbox_service):
|
||||
"""Test converting process info to sandbox info when httpx raises exception."""
|
||||
# Mock a process that's running but httpx raises exception
|
||||
with patch.object(
|
||||
process_sandbox_service,
|
||||
'_get_process_status',
|
||||
return_value=SandboxStatus.RUNNING,
|
||||
):
|
||||
# Mock httpx client to raise exception
|
||||
process_sandbox_service.httpx_client.get.side_effect = Exception(
|
||||
'Connection failed'
|
||||
)
|
||||
|
||||
process_info = ProcessInfo(
|
||||
pid=1234,
|
||||
port=9000,
|
||||
user_id='test-user-id',
|
||||
working_dir='/tmp/test',
|
||||
session_api_key='test-key',
|
||||
created_at=datetime.now(),
|
||||
sandbox_spec_id='test-spec',
|
||||
)
|
||||
|
||||
sandbox_info = await process_sandbox_service._process_to_sandbox_info(
|
||||
'test-sandbox', process_info
|
||||
)
|
||||
|
||||
assert sandbox_info.status == SandboxStatus.ERROR
|
||||
assert sandbox_info.session_api_key is None
|
||||
assert sandbox_info.exposed_urls is None
|
||||
|
||||
|
||||
class TestProcessSandboxServiceInjector:
|
||||
"""Test cases for ProcessSandboxServiceInjector."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default configuration values."""
|
||||
injector = ProcessSandboxServiceInjector()
|
||||
|
||||
assert injector.base_working_dir == '/tmp/openhands-sandboxes'
|
||||
assert injector.base_port == 8000
|
||||
assert injector.health_check_path == '/alive'
|
||||
assert injector.agent_server_module == 'openhands.agent_server'
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test custom configuration values."""
|
||||
injector = ProcessSandboxServiceInjector(
|
||||
base_working_dir='/custom/path',
|
||||
base_port=9000,
|
||||
health_check_path='/health',
|
||||
agent_server_module='custom.agent.module',
|
||||
)
|
||||
|
||||
assert injector.base_working_dir == '/custom/path'
|
||||
assert injector.base_port == 9000
|
||||
assert injector.health_check_path == '/health'
|
||||
assert injector.agent_server_module == 'custom.agent.module'
|
||||
Loading…
x
Reference in New Issue
Block a user