diff --git a/pyproject.toml b/pyproject.toml index eee9fa4..83b8e72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "modal>=1.1.4", "e2b>=2.0.0", "daytona>=0.103.0", - "hopx-ai>=0.1.19", + "hopx-ai>=0.3.0", "httpx>=0.27.0", ] @@ -44,7 +44,7 @@ modal = [ "modal==1.1.4", # Latest stable version ] hopx = [ - "hopx-ai>=0.1.19", # Official Hopx SDK for secure cloud sandboxes + "hopx-ai>=0.3.0", # Official Hopx SDK for secure cloud sandboxes ] # vercel = [ # "vercel-sdk>=0.1.0", # When available @@ -56,7 +56,7 @@ all = [ "daytona==0.103.0", "e2b>=2.0.0", "modal==1.1.4", - "hopx-ai>=0.1.19", + "hopx-ai>=0.3.0", ] dev = [ "pytest>=7.4.0", @@ -154,6 +154,7 @@ markers = [ "e2b: marks tests that require E2B API", "modal: marks tests that require Modal API", "daytona: marks tests that require Daytona API", + "hopx: marks tests that require Hopx API", "cloudflare: marks tests that require Cloudflare API", "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] diff --git a/sandboxes/providers/hopx.py b/sandboxes/providers/hopx.py index b30879e..9bbd60f 100644 --- a/sandboxes/providers/hopx.py +++ b/sandboxes/providers/hopx.py @@ -62,16 +62,33 @@ def name(self) -> str: def _to_sandbox(self, hopx_sandbox, metadata: dict[str, Any]) -> Sandbox: """Convert Hopx SDK sandbox to standard Sandbox.""" + # Map Hopx status to SandboxState + # Hopx API statuses: running, stopped, paused, creating (verified from SDK models.py:221) + status = metadata.get("status", "running").lower() + state_mapping = { + "running": SandboxState.RUNNING, + "stopped": SandboxState.STOPPED, + "paused": SandboxState.STOPPED, # Hopx paused maps to STOPPED + "creating": SandboxState.CREATING, + } + state = state_mapping.get(status, SandboxState.RUNNING) + + public_host = metadata.get("public_host", "") + return Sandbox( id=hopx_sandbox.sandbox_id, provider=self.name, - state=SandboxState.RUNNING, # Hopx sandboxes are running when created + state=state, labels=metadata.get("labels", {}), created_at=metadata.get("created_at", datetime.now()), + connection_info={ + "public_host": public_host, + "agent_url": f"{public_host}/" if public_host else "", + }, metadata={ "template": metadata.get("template", self.default_template), "last_accessed": metadata.get("last_accessed", time.time()), - "public_host": metadata.get("public_host", ""), + "public_host": public_host, }, ) @@ -108,6 +125,7 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: "last_accessed": time.time(), "template": template, "public_host": info.public_host, + "status": info.status, "config": config, } @@ -158,6 +176,7 @@ async def list_sandboxes(self, labels: dict[str, str] | None = None) -> list[San "last_accessed": time.time(), "template": info.template_name or self.default_template, "public_host": info.public_host, + "status": info.status, } # Filter by labels if provided @@ -364,28 +383,26 @@ async def stream_execution( logger.error(f"Failed to stream execution in sandbox {sandbox_id}: {e}") raise SandboxError(f"Failed to stream execution: {e}") from e - async def upload_file( - self, sandbox_id: str, local_path: str, remote_path: str, binary: bool = False - ) -> bool: + async def upload_file(self, sandbox_id: str, local_path: str, sandbox_path: str) -> bool: """ - Upload a file to the sandbox with security validation. + Upload a file to the sandbox (matches SandboxProvider interface). - Supports both text and binary files. + Automatically handles both text and binary files based on file extension. Args: sandbox_id: Sandbox ID local_path: Path to local file - remote_path: Destination path in sandbox - binary: If True, upload as binary file (for images, PDFs, etc.) + sandbox_path: Destination path in sandbox Returns: True if successful + Raises: + SandboxNotFoundError: If sandbox not found + SandboxError: If upload fails + Example: - >>> # Upload text file >>> await provider.upload_file("sb-123", "/path/to/script.py", "/workspace/script.py") - >>> # Upload binary file (image, PDF, etc.) - >>> await provider.upload_file("sb-123", "/path/to/plot.png", "/workspace/plot.png", binary=True) """ if sandbox_id not in self._sandboxes: raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") @@ -397,21 +414,39 @@ async def upload_file( metadata = self._sandboxes[sandbox_id] hopx_sandbox = metadata["hopx_sandbox"] + # Auto-detect binary files by extension + binary_extensions = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".pdf", + ".zip", + ".tar", + ".gz", + ".bz2", + ".exe", + ".bin", + ".so", + ".dll", + ".dylib", + } + is_binary = validated_path.suffix.lower() in binary_extensions + # Read local file content from validated path - if binary: # noqa: SIM108 - # For binary files (images, PDFs, etc.) + if is_binary: content = validated_path.read_bytes() else: - # For text files - content = validated_path.read_text() + try: + content = validated_path.read_text() + except UnicodeDecodeError: + # Fallback to binary if text decoding fails + content = validated_path.read_bytes() # Write to sandbox filesystem using SDK - await hopx_sandbox.files.write(path=remote_path, content=content) + await hopx_sandbox.files.write(path=sandbox_path, content=content) - logger.info( - f"Uploaded {validated_path} to {remote_path} in sandbox {sandbox_id} " - f"(binary={binary})" - ) + logger.info(f"Uploaded {validated_path} to {sandbox_path} in sandbox {sandbox_id}") metadata["last_accessed"] = time.time() return True @@ -419,28 +454,26 @@ async def upload_file( logger.error(f"Failed to upload file to sandbox {sandbox_id}: {e}") raise SandboxError(f"Failed to upload file: {e}") from e - async def download_file( - self, sandbox_id: str, remote_path: str, local_path: str, binary: bool = False - ) -> bool: + async def download_file(self, sandbox_id: str, sandbox_path: str, local_path: str) -> bool: """ - Download a file from the sandbox with security validation. + Download a file from the sandbox (matches SandboxProvider interface). - Supports both text and binary files. + Automatically handles both text and binary files based on content type. Args: sandbox_id: Sandbox ID - remote_path: Path to file in sandbox + sandbox_path: Path to file in sandbox local_path: Destination path on local filesystem - binary: If True, download as binary file (for images, PDFs, etc.) Returns: True if successful + Raises: + SandboxNotFoundError: If sandbox not found + SandboxError: If download fails + Example: - >>> # Download text file >>> await provider.download_file("sb-123", "/workspace/output.txt", "/local/output.txt") - >>> # Download binary file (image, PDF, etc.) - >>> await provider.download_file("sb-123", "/workspace/plot.png", "/local/plot.png", binary=True) """ if sandbox_id not in self._sandboxes: raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") @@ -453,27 +486,16 @@ async def download_file( hopx_sandbox = metadata["hopx_sandbox"] # Read from sandbox filesystem using SDK - content = await hopx_sandbox.files.read(path=remote_path) - - # Write to local file at validated path - if binary: - # For binary files - SDK returns bytes - if isinstance(content, str): - # If SDK returned string, encode it - validated_path.write_bytes(content.encode("latin1")) - else: - validated_path.write_bytes(content) + content = await hopx_sandbox.files.read(path=sandbox_path) + + # Write to local file at validated path, handling both bytes and str + if isinstance(content, bytes): + validated_path.write_bytes(content) else: - # For text files - if isinstance(content, bytes): - validated_path.write_text(content.decode("utf-8")) - else: - validated_path.write_text(content) + # Content is str, write as text + validated_path.write_text(content) - logger.info( - f"Downloaded {remote_path} from sandbox {sandbox_id} to {validated_path} " - f"(binary={binary})" - ) + logger.info(f"Downloaded {sandbox_path} from sandbox {sandbox_id} to {validated_path}") metadata["last_accessed"] = time.time() return True @@ -600,17 +622,9 @@ async def get_desktop_vnc_url(self, sandbox_id: str) -> str | None: metadata = self._sandboxes[sandbox_id] hopx_sandbox = metadata["hopx_sandbox"] - # Check if SDK supports desktop (may not be in all versions) - if hasattr(hopx_sandbox, "desktop"): - # Try to start VNC and get URL - vnc_info = await hopx_sandbox.desktop.start_vnc() - return vnc_info.url if hasattr(vnc_info, "url") else None - else: - logger.warning( - f"Desktop automation not available for sandbox {sandbox_id}. " - "Requires desktop-enabled template and SDK support." - ) - return None + # Call SDK desktop method - it will raise DesktopNotAvailableError if not supported + vnc_info = await hopx_sandbox.desktop.start_vnc() + return vnc_info.url if hasattr(vnc_info, "url") else None except Exception as e: logger.error(f"Failed to get VNC URL for sandbox {sandbox_id}: {e}") @@ -643,12 +657,7 @@ async def screenshot(self, sandbox_id: str, output_path: str | None = None) -> b metadata = self._sandboxes[sandbox_id] hopx_sandbox = metadata["hopx_sandbox"] - # Check if SDK supports desktop - if not hasattr(hopx_sandbox, "desktop"): - logger.warning("Screenshot not available - desktop support not enabled") - return None - - # Capture screenshot + # Capture screenshot - SDK will raise DesktopNotAvailableError if not supported img_bytes = await hopx_sandbox.desktop.screenshot() # Optionally save to file @@ -663,6 +672,69 @@ async def screenshot(self, sandbox_id: str, output_path: str | None = None) -> b logger.error(f"Failed to capture screenshot for sandbox {sandbox_id}: {e}") return None + async def get_preview_url(self, sandbox_id: str, port: int = 7777) -> str: + """ + Get preview URL for accessing services running in the sandbox. + + Hopx exposes all sandbox ports via public URLs. This returns the URL + for accessing a service on the specified port. + + Args: + sandbox_id: Sandbox ID + port: Port number (default: 7777 for sandbox agent) + + Returns: + Public URL string for the service + + Raises: + SandboxNotFoundError: If sandbox doesn't exist + SandboxError: If URL cannot be generated + + Example: + >>> url = await provider.get_preview_url("sb-123", 8080) + >>> print(url) # https://8080-sandbox123.eu-1001.vms.hopx.dev/ + """ + if sandbox_id not in self._sandboxes: + raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") + + try: + metadata = self._sandboxes[sandbox_id] + hopx_sandbox = metadata["hopx_sandbox"] + + # Use SDK's get_preview_url method (requires SDK >= 0.3.0) + url = await hopx_sandbox.get_preview_url(port) + + logger.info(f"Preview URL for sandbox {sandbox_id} port {port}: {url}") + metadata["last_accessed"] = time.time() + return url + + except SandboxNotFoundError: + raise + except Exception as e: + logger.error(f"Failed to get preview URL for sandbox {sandbox_id}: {e}") + raise SandboxError(f"Failed to get preview URL: {e}") from e + + async def get_agent_url(self, sandbox_id: str) -> str: + """ + Get agent URL for the sandbox. + + Returns the public URL for the sandbox agent (port 7777). + + Args: + sandbox_id: Sandbox ID + + Returns: + Agent URL string + + Raises: + SandboxNotFoundError: If sandbox doesn't exist + SandboxError: If URL cannot be generated + + Example: + >>> url = await provider.get_agent_url("sb-123") + """ + return await self.get_preview_url(sandbox_id, port=7777) + def __del__(self): """Cleanup on deletion.""" # Any cleanup needed when provider is destroyed diff --git a/tests/test_hopx_provider.py b/tests/test_hopx_provider.py index 7a63f75..40ab7b0 100644 --- a/tests/test_hopx_provider.py +++ b/tests/test_hopx_provider.py @@ -532,10 +532,8 @@ async def test_hopx_binary_file_upload(): "last_accessed": 0, } - # Upload binary file - success = await provider.upload_file( - sandbox_id, temp_path, "/workspace/image.png", binary=True - ) + # Upload binary file (binary detected automatically by .png extension) + success = await provider.upload_file(sandbox_id, temp_path, "/workspace/image.png") assert success # Verify SDK was called with bytes @@ -569,10 +567,8 @@ async def test_hopx_binary_file_download(): "last_accessed": 0, } - # Download binary file - success = await provider.download_file( - sandbox_id, "/workspace/plot.png", output_path, binary=True - ) + # Download binary file (binary detected automatically by SDK) + success = await provider.download_file(sandbox_id, "/workspace/plot.png", output_path) assert success # Verify binary content @@ -710,6 +706,72 @@ async def test_hopx_live_integration(): await provider.destroy_sandbox(sandbox.id) +@pytest.mark.asyncio +async def test_hopx_get_preview_url(): + """Test get_preview_url method for accessing sandbox services.""" + provider = HopxProvider(api_key="test-key") + sandbox_id = "preview-url-test" + + # Mock sandbox with get_preview_url method (SDK v0.3.0+) + mock_sandbox = AsyncMock() + mock_sandbox.sandbox_id = sandbox_id + mock_sandbox.get_preview_url = AsyncMock( + return_value="https://8080-sandbox123.eu-1001.vms.hopx.dev/" + ) + + provider._sandboxes[sandbox_id] = { + "hopx_sandbox": mock_sandbox, + "labels": {}, + "last_accessed": 0, + } + + # Test custom port + url = await provider.get_preview_url(sandbox_id, port=8080) + assert url == "https://8080-sandbox123.eu-1001.vms.hopx.dev/" + mock_sandbox.get_preview_url.assert_called_once_with(8080) + + # Test default port (7777) + mock_sandbox.get_preview_url.reset_mock() + mock_sandbox.get_preview_url.return_value = "https://7777-sandbox123.eu-1001.vms.hopx.dev/" + url = await provider.get_preview_url(sandbox_id) + assert url == "https://7777-sandbox123.eu-1001.vms.hopx.dev/" + mock_sandbox.get_preview_url.assert_called_once_with(7777) + + +@pytest.mark.asyncio +async def test_hopx_get_agent_url(): + """Test get_agent_url convenience method.""" + provider = HopxProvider(api_key="test-key") + sandbox_id = "agent-url-test" + + # Mock sandbox + mock_sandbox = AsyncMock() + mock_sandbox.sandbox_id = sandbox_id + mock_sandbox.get_preview_url = AsyncMock( + return_value="https://7777-sandbox123.eu-1001.vms.hopx.dev/" + ) + + provider._sandboxes[sandbox_id] = { + "hopx_sandbox": mock_sandbox, + "labels": {}, + "last_accessed": 0, + } + + # Test agent URL (should call get_preview_url with port 7777) + url = await provider.get_agent_url(sandbox_id) + assert url == "https://7777-sandbox123.eu-1001.vms.hopx.dev/" + mock_sandbox.get_preview_url.assert_called_once_with(7777) + + +@pytest.mark.asyncio +async def test_hopx_get_preview_url_not_found(): + """Test get_preview_url raises SandboxNotFoundError for unknown sandbox.""" + provider = HopxProvider(api_key="test-key") + + with pytest.raises(SandboxNotFoundError, match="Sandbox .* not found"): + await provider.get_preview_url("unknown-sandbox", port=8080) + + @pytest.mark.asyncio async def test_hopx_timeout_parameter_compatibility(): """Test that timeout parameter is correctly passed to SDK methods."""