diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 6a67d5c4d4..d2c45e0c0f 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -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]] diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index f94a614da1..d5dd726081 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -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() diff --git a/openhands/app_server/sandbox/process_sandbox_service.py b/openhands/app_server/sandbox/process_sandbox_service.py new file mode 100644 index 0000000000..955f6368bc --- /dev/null +++ b/openhands/app_server/sandbox/process_sandbox_service.py @@ -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, + ) diff --git a/openhands/app_server/sandbox/process_sandbox_spec_service.py b/openhands/app_server/sandbox/process_sandbox_spec_service.py new file mode 100644 index 0000000000..97bb17977a --- /dev/null +++ b/openhands/app_server/sandbox/process_sandbox_spec_service.py @@ -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) diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index cf6537ba2b..8a47115b8f 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -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): diff --git a/poetry.lock b/poetry.lock index 2d3d71cd52..d4d11d8d28 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 48266783d7..1b04f29594 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/unit/app_server/test_process_sandbox_service.py b/tests/unit/app_server/test_process_sandbox_service.py new file mode 100644 index 0000000000..f39384241d --- /dev/null +++ b/tests/unit/app_server/test_process_sandbox_service.py @@ -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'