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:
Tim O'Farrell 2025-10-15 17:53:50 -06:00 committed by GitHub
parent f4fd8ea907
commit 0522734875
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 849 additions and 15 deletions

12
enterprise/poetry.lock generated
View File

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

View File

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

View 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,
)

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

View File

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

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

View File

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

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