diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 6c68707a..ded15446 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -1,17 +1,28 @@ +import asyncio +import functools +import time from typing import Dict, Optional, Union from ....exceptions import HyperbrowserError from ....models.sandbox import ( + CompleteSandboxImageBuildParams, CreateSandboxParams, + CreateSandboxImageBuildParams, SandboxDetail, SandboxExecParams, SandboxExposeParams, SandboxExposeResult, + SandboxImageBuild, + SandboxImageBuildCreateResult, + SandboxImageListParams, SandboxImageListResponse, + SandboxImageInit, SandboxListParams, SandboxListResponse, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, + SandboxNetworkPolicy, + SandboxNetworkUpdateResult, SandboxRuntimeSession, SandboxSnapshotListParams, SandboxSnapshotListResponse, @@ -30,6 +41,15 @@ _copy_model, _expires_within_buffer, ) +from ..sandboxes.image_build import ( + IMAGE_BUILD_SOURCE_PLATFORM, + build_docker_image_from_dockerfile, + is_terminal_image_build_status, + make_temp_docker_tag, + package_docker_image, + remove_docker_image, + upload_image_build_artifact, +) from .sandboxes.sandbox_files import ( DEFAULT_WATCH_TIMEOUT_MS, SandboxFileWatchHandle, @@ -67,6 +87,12 @@ ] +async def _run_blocking(func, *args, **kwargs): + loop = asyncio.get_running_loop() + call = functools.partial(func, *args, **kwargs) + return await loop.run_in_executor(None, call) + + class SandboxHandle: def __init__(self, service: "SandboxManager", detail: SandboxDetail): self._service = service @@ -130,6 +156,10 @@ def disk_mib(self): def exposed_ports(self): return self._detail.exposed_ports + @property + def network(self): + return self._detail.network + def to_dict(self): return self._detail.model_dump() @@ -166,6 +196,25 @@ async def create_memory_snapshot( raise TypeError("params must be a SandboxMemorySnapshotParams instance") return await self._service.create_memory_snapshot(self.id, normalized) + async def update_network( + self, + policy: SandboxNetworkPolicy, + ) -> SandboxNetworkUpdateResult: + if not isinstance(policy, SandboxNetworkPolicy): + raise TypeError("policy must be a SandboxNetworkPolicy instance") + result = await self._service.update_network(self.id, policy) + self._detail = self._detail.model_copy(update={"network": result.network}) + return result + + async def clear_network(self) -> SandboxNetworkUpdateResult: + return await self.update_network( + SandboxNetworkPolicy( + allow_internet_access=True, + allow_out=[], + deny_out=[], + ) + ) + async def expose(self, params: SandboxExposeParams) -> SandboxExposeResult: if not isinstance(params, SandboxExposeParams): raise TypeError("params must be a SandboxExposeParams instance") @@ -350,8 +399,16 @@ async def list( ) return SandboxListResponse(**payload) - async def list_images(self) -> SandboxImageListResponse: - payload = await self._request("GET", "/images") + async def list_images( + self, params: SandboxImageListParams = SandboxImageListParams() + ) -> SandboxImageListResponse: + if not isinstance(params, SandboxImageListParams): + raise TypeError("params must be a SandboxImageListParams instance") + payload = await self._request( + "GET", + "/images", + params=params.model_dump(exclude_none=True, by_alias=True), + ) return SandboxImageListResponse(**payload) async def list_snapshots( @@ -370,6 +427,170 @@ async def stop(self, sandbox_id: str) -> BasicResponse: payload = await self._request("PUT", f"/sandbox/{sandbox_id}/stop") return BasicResponse(**payload) + async def create_image_build( + self, + params: CreateSandboxImageBuildParams, + ) -> SandboxImageBuildCreateResult: + if not isinstance(params, CreateSandboxImageBuildParams): + raise TypeError("params must be a CreateSandboxImageBuildParams instance") + payload = await self._request( + "POST", + "/images/builds", + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxImageBuildCreateResult(**payload) + + async def get_image_build(self, build_id: str) -> SandboxImageBuild: + payload = await self._request("GET", f"/images/builds/{build_id}") + return SandboxImageBuild(**payload["build"]) + + async def complete_image_build( + self, + build_id: str, + params: CompleteSandboxImageBuildParams, + ) -> SandboxImageBuild: + if not isinstance(params, CompleteSandboxImageBuildParams): + raise TypeError("params must be a CompleteSandboxImageBuildParams instance") + payload = await self._request( + "POST", + f"/images/builds/{build_id}/complete", + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxImageBuild(**payload["build"]) + + async def cancel_image_build(self, build_id: str) -> SandboxImageBuild: + payload = await self._request("POST", f"/images/builds/{build_id}/cancel") + return SandboxImageBuild(**payload["build"]) + + async def wait_for_image_build( + self, + build_id: str, + *, + poll_interval: float = 3.0, + timeout: Optional[float] = 35 * 60, + ) -> SandboxImageBuild: + deadline = None if timeout is None else time.monotonic() + timeout + while True: + build = await self.get_image_build(build_id) + if build.status == "completed": + return build + if build.status == "failed": + message = build.error_message or "image build failed" + if build.error_code: + message = f"image build failed [{build.error_code}]: {message}" + raise RuntimeError(message) + if is_terminal_image_build_status(build.status): + raise RuntimeError(f"image build {build.status}") + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for image build {build_id}") + await asyncio.sleep(poll_interval) + + async def build_image_from_docker_image( + self, + *, + docker_image: str, + image_name: str, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + image_init: Optional[SandboxImageInit] = None, + image_config_user: Optional[str] = None, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + temp_dir: Optional[str] = None, + upload_timeout: Optional[float] = None, + ) -> SandboxImageBuild: + artifact = await _run_blocking( + package_docker_image, + docker_image, + platform=platform, + temp_dir=temp_dir, + ) + try: + create_result = await self.create_image_build( + CreateSandboxImageBuildParams( + image_name=image_name, + input_sha256=artifact.sha256_hex, + input_size_bytes=artifact.size_bytes, + input_format=artifact.input_format, + source_platform=artifact.source_platform, + image_config_user=( + image_config_user + if image_config_user is not None + else artifact.image_config_user + ), + image_init=image_init + if image_init is not None + else artifact.image_init, + ) + ) + await _run_blocking( + upload_image_build_artifact, + create_result.upload, + artifact.path, + timeout=upload_timeout, + ) + build = await self.complete_image_build( + create_result.build.id, + CompleteSandboxImageBuildParams( + input_sha256=artifact.sha256_hex, + input_size_bytes=artifact.size_bytes, + input_format=artifact.input_format, + ), + ) + if wait: + return await self.wait_for_image_build( + build.id, + poll_interval=poll_interval, + timeout=wait_timeout, + ) + return build + finally: + artifact.cleanup() + + async def build_image_from_dockerfile( + self, + *, + context_path, + image_name: str, + dockerfile="Dockerfile", + docker_tag: Optional[str] = None, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + build_args: Optional[Dict[str, str]] = None, + image_init: Optional[SandboxImageInit] = None, + image_config_user: Optional[str] = None, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + temp_dir: Optional[str] = None, + upload_timeout: Optional[float] = None, + ) -> SandboxImageBuild: + tag = docker_tag or make_temp_docker_tag() + remove_tag = docker_tag is None + try: + await _run_blocking( + build_docker_image_from_dockerfile, + context_path=context_path, + dockerfile=dockerfile, + tag=tag, + platform=platform, + build_args=build_args, + ) + return await self.build_image_from_docker_image( + docker_image=tag, + image_name=image_name, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + wait=wait, + poll_interval=poll_interval, + wait_timeout=wait_timeout, + temp_dir=temp_dir, + upload_timeout=upload_timeout, + ) + finally: + if remove_tag: + await _run_blocking(remove_docker_image, tag) + async def get_runtime_session(self, sandbox_id: str) -> SandboxRuntimeSession: detail = await self.get_detail(sandbox_id) session = SandboxHandle._to_runtime_session(detail) @@ -404,6 +625,20 @@ async def create_memory_snapshot( ) return SandboxMemorySnapshotResult(**payload) + async def update_network( + self, + sandbox_id: str, + policy: SandboxNetworkPolicy, + ) -> SandboxNetworkUpdateResult: + if not isinstance(policy, SandboxNetworkPolicy): + raise TypeError("policy must be a SandboxNetworkPolicy instance") + payload = await self._request( + "PUT", + f"/sandbox/{sandbox_id}/network", + data=policy.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxNetworkUpdateResult(**payload) + async def expose( self, sandbox_id: str, diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py index d807224f..e20a1144 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py @@ -1,5 +1,6 @@ import asyncio import base64 +import functools import inspect import io import json @@ -43,6 +44,42 @@ ) from .sandbox_transport import RuntimeTransport +DEFAULT_TRANSFER_CHUNK_SIZE = 64 * 1024 + + +async def _run_blocking(func, *args, **kwargs): + loop = asyncio.get_running_loop() + call = functools.partial(func, *args, **kwargs) + return await loop.run_in_executor(None, call) + + +async def _aiter_stream_content(stream, chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE): + if isinstance(stream, str): + yield stream.encode("utf-8") + return + if isinstance(stream, (bytes, bytearray)): + yield bytes(stream) + return + if hasattr(stream, "__aiter__"): + async for chunk in stream: + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + yield bytes(chunk) + return + if hasattr(stream, "read"): + while True: + chunk = await _run_blocking(stream.read, chunk_size) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + yield bytes(chunk) + return + for chunk in stream: + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + yield bytes(chunk) + class SandboxFileWatchHandle: def __init__( @@ -439,12 +476,45 @@ async def upload(self, path: str, data: Union[str, bytes, bytearray]): ) return SandboxFileTransferResult(**payload) + async def upload_stream( + self, + path: str, + stream, + *, + content_length: Optional[int] = None, + chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, + ): + headers = {} + if content_length is not None: + headers["content-length"] = str(content_length) + payload = await self._transport.request_json( + "/sandbox/files/upload", + method="PUT", + params=self._with_run_as_params({"path": path}), + content=_aiter_stream_content(stream, chunk_size=chunk_size), + headers=headers or None, + ) + return SandboxFileTransferResult(**payload) + async def download(self, path: str) -> bytes: return await self._transport.request_bytes( "/sandbox/files/download", params=self._with_run_as_params({"path": path}), ) + async def download_stream( + self, + path: str, + *, + chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, + ) -> AsyncIterator[bytes]: + async for chunk in self._transport.stream_bytes( + "/sandbox/files/download", + params=self._with_run_as_params({"path": path}), + chunk_size=chunk_size, + ): + yield chunk + async def make_dir( self, path: str, diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py index a3061759..d2b10ad3 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py @@ -1,5 +1,5 @@ import json -from typing import AsyncIterator, Dict, Optional, Union +from typing import Any, AsyncIterator, Dict, Optional import httpx @@ -11,7 +11,7 @@ parse_json_response, resolve_runtime_transport_target, ) -from ...sandboxes.shared import _build_query_path +from ...sandboxes.shared import _build_query_path, _is_replayable_http_content class RuntimeTransport: @@ -32,7 +32,7 @@ async def request_json( method: str = "GET", params: Optional[Dict[str, object]] = None, json_body: Optional[Dict[str, object]] = None, - content: Optional[Union[str, bytes]] = None, + content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, ): response = await self._request( @@ -58,6 +58,29 @@ async def request_bytes( ) return response.content + async def stream_bytes( + self, + path: str, + *, + method: str = "GET", + params: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + chunk_size: int = 65536, + ) -> AsyncIterator[bytes]: + client, response = await self._open_binary_stream( + path, + method=method, + params=params, + headers=headers, + ) + try: + async for chunk in response.aiter_bytes(chunk_size=chunk_size): + if chunk: + yield chunk + finally: + await response.aclose() + await client.aclose() + async def stream_sse( self, path: str, params: Optional[Dict[str, object]] = None ) -> AsyncIterator[Dict[str, object]]: @@ -127,7 +150,7 @@ async def _request( method: str = "GET", params: Optional[Dict[str, object]] = None, json_body: Optional[Dict[str, object]] = None, - content: Optional[Union[str, bytes]] = None, + content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, ) -> httpx.Response: @@ -144,6 +167,12 @@ async def _request( if response.status_code == 401 and allow_refresh: await response.aclose() + if not _is_replayable_http_content(content): + return ensure_response_ok( + response, + "runtime", + "Runtime token expired during a streaming request; retry with a fresh stream.", + ) refreshed = await self._resolve_connection(True) retry = await self._send( refreshed, @@ -178,6 +207,40 @@ async def _open_stream( ensure_response_ok(response, "runtime") return client, response + async def _open_binary_stream( + self, + path: str, + *, + method: str = "GET", + params: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + allow_refresh: bool = True, + ): + connection = await self._resolve_connection(False) + client, response = await self._send_binary_stream( + connection, + path, + method=method, + params=params, + headers=headers, + ) + if response.status_code == 401 and allow_refresh: + await response.aclose() + await client.aclose() + refreshed = await self._resolve_connection(True) + client, response = await self._send_binary_stream( + refreshed, + path, + method=method, + params=params, + headers=headers, + ) + + if not response.is_success: + await response.aread() + ensure_response_ok(response, "runtime") + return client, response + async def _send( self, connection: RuntimeConnection, @@ -186,7 +249,7 @@ async def _send( method: str, params: Optional[Dict[str, object]], json_body: Optional[Dict[str, object]], - content: Optional[Union[str, bytes]], + content: Optional[Any], headers: Optional[Dict[str, str]], ) -> httpx.Response: request_path = _build_query_path(path, params) @@ -218,6 +281,36 @@ async def _send( await client.aclose() return response + async def _send_binary_stream( + self, + connection: RuntimeConnection, + path: str, + *, + method: str, + params: Optional[Dict[str, object]], + headers: Optional[Dict[str, str]], + ): + request_path = _build_query_path(path, params) + target = resolve_runtime_transport_target( + connection.base_url, + request_path, + self._runtime_proxy_override, + ) + merged_headers = build_headers(connection.token, headers, target.host_header) + client = httpx.AsyncClient(timeout=self._timeout) + + try: + request = client.build_request(method, target.url, headers=merged_headers) + response = await client.send(request, stream=True) + return client, response + except BaseException as error: + await client.aclose() + raise normalize_network_error( + error, + "runtime", + "Unknown runtime request error", + ) + async def _send_stream( self, connection: RuntimeConnection, diff --git a/hyperbrowser/client/managers/sandboxes/image_build.py b/hyperbrowser/client/managers/sandboxes/image_build.py new file mode 100644 index 00000000..34dd9b5d --- /dev/null +++ b/hyperbrowser/client/managers/sandboxes/image_build.py @@ -0,0 +1,375 @@ +import gzip +import hashlib +import json +import os +import re +import subprocess +import tempfile +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, FrozenSet, List, Optional, Sequence + +import httpx + +from ....models.sandbox import ( + SandboxImageBuildInputFormat, + SandboxImageBuildStatus, + SandboxImageBuildUpload, + SandboxImageInit, +) + +IMAGE_BUILD_INPUT_FORMAT: SandboxImageBuildInputFormat = "rootfs_export_tar_gz" +IMAGE_BUILD_SOURCE_PLATFORM = "linux/amd64" +TERMINAL_IMAGE_BUILD_STATUSES: FrozenSet[SandboxImageBuildStatus] = frozenset( + {"completed", "failed", "canceled", "cancelled"} +) +_IMAGE_INIT_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_RESERVED_IMAGE_INIT_ENV_KEYS = { + "SANDBOX_ENABLED", + "SANDBOX_DEFAULT_WORKING_DIR", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "PATH", + "PWD", + "DISPLAY", +} + + +@dataclass +class DockerImageBuildArtifact: + path: str + sha256_hex: str + size_bytes: int + input_format: SandboxImageBuildInputFormat + source_platform: str + image_config_user: str + image_init: Optional[SandboxImageInit] + + def cleanup(self) -> None: + try: + os.remove(self.path) + except FileNotFoundError: + pass + + +class _HashingCountingWriter: + def __init__(self, fileobj, hasher): + self._fileobj = fileobj + self._hasher = hasher + self.total = 0 + + def write(self, data): + self._hasher.update(data) + self.total += len(data) + return self._fileobj.write(data) + + def flush(self): + return self._fileobj.flush() + + +def build_docker_image_from_dockerfile( + *, + context_path, + dockerfile="Dockerfile", + tag: str, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + build_args: Optional[Dict[str, str]] = None, +) -> None: + context = Path(context_path) + if not context.exists(): + raise FileNotFoundError(f"Docker build context not found: {context}") + dockerfile_path = Path(dockerfile) + if not dockerfile_path.is_absolute(): + dockerfile_path = context / dockerfile_path + + args = [ + "docker", + "buildx", + "build", + "--platform", + platform, + "-t", + tag, + "-f", + str(dockerfile_path), + "--load", + ] + for key, value in (build_args or {}).items(): + args.extend(["--build-arg", f"{key}={value}"]) + args.append(str(context)) + _run_command(args) + + +def package_docker_image( + docker_image: str, + *, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + temp_dir: Optional[str] = None, +) -> DockerImageBuildArtifact: + _ensure_docker_image_source_platform(docker_image, platform) + container_id = "" + try: + container_id = _run_command_output( + ["docker", "create", f"--platform={platform}", docker_image] + ).strip() + if not container_id: + raise RuntimeError("docker create returned empty container ID") + config = _inspect_docker_container_config(container_id) + return _package_docker_container( + docker_image, + container_id, + config, + platform=platform, + temp_dir=temp_dir, + ) + except Exception: + if container_id: + _remove_docker_container(container_id) + raise + + +def remove_docker_image(image: str) -> None: + try: + _run_command(["docker", "image", "rm", image]) + except RuntimeError: + pass + + +def upload_image_build_artifact( + upload: SandboxImageBuildUpload, + artifact_path: str, + *, + timeout: Optional[float] = None, +) -> None: + method = upload.method.strip() if upload.method else "PUT" + headers = dict(upload.headers or {}) + headers.setdefault("content-length", str(os.path.getsize(artifact_path))) + with open(artifact_path, "rb") as artifact: + response = httpx.request( + method, + upload.url, + content=artifact, + headers=headers, + timeout=timeout, + ) + if response.is_success: + return + body = response.text.strip() + if body: + raise RuntimeError( + f"image artifact upload failed: {response.status_code}: {body}" + ) + raise RuntimeError(f"image artifact upload failed: {response.status_code}") + + +def make_temp_docker_tag(prefix: str = "hyperbrowser-sdk-build") -> str: + return f"{prefix}:{uuid.uuid4().hex}" + + +def is_terminal_image_build_status(status: SandboxImageBuildStatus) -> bool: + return status in TERMINAL_IMAGE_BUILD_STATUSES + + +def _ensure_docker_image_source_platform(docker_image: str, platform: str) -> None: + try: + output = _run_command_output( + [ + "docker", + "image", + "inspect", + "--format", + "{{.Os}}/{{.Architecture}}", + docker_image, + ] + ) + except RuntimeError: + return + + local_platform = output.strip().lower() + expected_platform = platform.strip().lower() + if not local_platform or local_platform == expected_platform: + return + + raise RuntimeError( + "\n".join( + [ + "docker image platform is not supported for Hyperbrowser image " + f"builds: {docker_image} is {local_platform} (expected {platform}).", + f"Please rebuild the image for {platform} and try again:", + " cd ", + f" docker buildx build --platform {platform} -t {docker_image} " + "-f --load .", + ] + ) + ) + + +def _inspect_docker_container_config(container_id: str) -> Dict[str, object]: + output = _run_command_output( + [ + "docker", + "container", + "inspect", + "--format", + "{{json .Config}}", + container_id, + ] + ).strip() + if not output or output == "null": + raise RuntimeError("docker inspect returned empty container config") + return json.loads(output) + + +def _package_docker_container( + docker_image: str, + container_id: str, + config: Dict[str, object], + *, + platform: str, + temp_dir: Optional[str], +) -> DockerImageBuildArtifact: + tmp = tempfile.NamedTemporaryFile( + prefix="hb-image-", + suffix=".tar.gz", + dir=temp_dir, + delete=False, + ) + tmp_path = tmp.name + hasher = hashlib.sha256() + writer = _HashingCountingWriter(tmp, hasher) + stderr_file = tempfile.TemporaryFile() + process = None + try: + process = subprocess.Popen( + ["docker", "export", container_id], + stdout=subprocess.PIPE, + stderr=stderr_file, + ) + if process.stdout is None: + raise RuntimeError("docker export did not provide stdout") + with gzip.GzipFile(fileobj=writer, mode="wb", compresslevel=9) as gzip_file: + while True: + chunk = process.stdout.read(1024 * 1024) + if not chunk: + break + gzip_file.write(chunk) + return_code = process.wait() + if return_code != 0: + stderr_file.seek(0) + message = stderr_file.read().decode("utf-8", errors="replace").strip() + if message: + raise RuntimeError(f"docker export {docker_image} failed: {message}") + raise RuntimeError( + f"docker export {docker_image} failed with code {return_code}" + ) + tmp.flush() + return DockerImageBuildArtifact( + path=tmp_path, + sha256_hex=hasher.hexdigest(), + size_bytes=writer.total, + input_format=IMAGE_BUILD_INPUT_FORMAT, + source_platform=platform, + image_config_user=str(config.get("User") or "").strip(), + image_init=_derive_auto_image_init(config), + ) + except Exception: + try: + os.remove(tmp_path) + except FileNotFoundError: + pass + raise + finally: + if process is not None: + _cleanup_docker_export_process(process) + tmp.close() + stderr_file.close() + _remove_docker_container(container_id) + + +def _cleanup_docker_export_process(process) -> None: + if process.stdout is not None: + process.stdout.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + return + process.wait() + + +def _derive_auto_image_init(config: Dict[str, object]) -> Optional[SandboxImageInit]: + env = _derive_auto_image_env(_list_string_config(config.get("Env"))) + args = _derive_auto_startup_args( + _list_string_config(config.get("Entrypoint")), + _list_string_config(config.get("Cmd")), + ) + if not env and not args: + return None + return SandboxImageInit(env=env or None, args=args or None) + + +def _derive_auto_image_env(entries: Sequence[str]) -> Dict[str, str]: + env = {} + for entry in entries: + key, sep, value = entry.partition("=") + if not sep: + continue + key = key.strip() + if not key: + continue + if not _IMAGE_INIT_ENV_KEY_PATTERN.match(key): + continue + if key in _RESERVED_IMAGE_INIT_ENV_KEYS: + continue + env[key] = value + return env + + +def _derive_auto_startup_args( + entrypoint: Sequence[str], cmd: Sequence[str] +) -> List[str]: + argv = list(entrypoint) + list(cmd) if entrypoint else list(cmd) + return [arg for arg in argv if arg] + + +def _list_string_config(value) -> List[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] + + +def _remove_docker_container(container_id: str) -> None: + try: + _run_command(["docker", "rm", "-f", container_id]) + except RuntimeError: + pass + + +def _run_command(args: Sequence[str]) -> None: + _run_command_result(args) + + +def _run_command_output(args: Sequence[str]) -> str: + return _run_command_result(args).stdout + + +def _run_command_result(args: Sequence[str]) -> subprocess.CompletedProcess: + result = subprocess.run( + list(args), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode == 0: + return result + message = result.stderr.strip() + if message: + raise RuntimeError(f"{' '.join(args)}: {message}") + raise RuntimeError(f"{' '.join(args)} failed with code {result.returncode}") diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index a2c372ee..943d1759 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -1,4 +1,5 @@ import base64 +import collections.abc import posixpath import re from datetime import datetime, timedelta, timezone @@ -28,6 +29,24 @@ def _copy_model(model): return model.model_copy(deep=True) +def _is_replayable_http_content(content) -> bool: + if content is None: + return True + if isinstance(content, (bytes, bytearray, memoryview, str)): + return True + if hasattr(content, "read"): + return False + if hasattr(content, "__aiter__"): + return False + if isinstance(content, collections.abc.Iterator): + return False + try: + iter(content) + except TypeError: + return True + return True + + def _quote_shell_token(token: str) -> str: if token == "": return "''" diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 1856bc8b..6ad4f849 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -1,17 +1,26 @@ +import time from typing import Dict, Optional, Union from ....exceptions import HyperbrowserError from ....models.sandbox import ( + CompleteSandboxImageBuildParams, CreateSandboxParams, + CreateSandboxImageBuildParams, SandboxDetail, SandboxExecParams, SandboxExposeParams, SandboxExposeResult, + SandboxImageBuild, + SandboxImageBuildCreateResult, + SandboxImageListParams, SandboxImageListResponse, + SandboxImageInit, SandboxListParams, SandboxListResponse, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, + SandboxNetworkPolicy, + SandboxNetworkUpdateResult, SandboxRuntimeSession, SandboxSnapshotListParams, SandboxSnapshotListResponse, @@ -30,6 +39,15 @@ _copy_model, _expires_within_buffer, ) +from ..sandboxes.image_build import ( + IMAGE_BUILD_SOURCE_PLATFORM, + build_docker_image_from_dockerfile, + is_terminal_image_build_status, + make_temp_docker_tag, + package_docker_image, + remove_docker_image, + upload_image_build_artifact, +) from .sandboxes.sandbox_files import ( DEFAULT_WATCH_TIMEOUT_MS, SandboxFileWatchHandle, @@ -130,6 +148,10 @@ def disk_mib(self): def exposed_ports(self): return self._detail.exposed_ports + @property + def network(self): + return self._detail.network + def to_dict(self): return self._detail.model_dump() @@ -166,6 +188,25 @@ def create_memory_snapshot( raise TypeError("params must be a SandboxMemorySnapshotParams instance") return self._service.create_memory_snapshot(self.id, normalized) + def update_network( + self, + policy: SandboxNetworkPolicy, + ) -> SandboxNetworkUpdateResult: + if not isinstance(policy, SandboxNetworkPolicy): + raise TypeError("policy must be a SandboxNetworkPolicy instance") + result = self._service.update_network(self.id, policy) + self._detail = self._detail.model_copy(update={"network": result.network}) + return result + + def clear_network(self) -> SandboxNetworkUpdateResult: + return self.update_network( + SandboxNetworkPolicy( + allow_internet_access=True, + allow_out=[], + deny_out=[], + ) + ) + def expose(self, params: SandboxExposeParams) -> SandboxExposeResult: if not isinstance(params, SandboxExposeParams): raise TypeError("params must be a SandboxExposeParams instance") @@ -350,8 +391,16 @@ def list( ) return SandboxListResponse(**payload) - def list_images(self) -> SandboxImageListResponse: - payload = self._request("GET", "/images") + def list_images( + self, params: SandboxImageListParams = SandboxImageListParams() + ) -> SandboxImageListResponse: + if not isinstance(params, SandboxImageListParams): + raise TypeError("params must be a SandboxImageListParams instance") + payload = self._request( + "GET", + "/images", + params=params.model_dump(exclude_none=True, by_alias=True), + ) return SandboxImageListResponse(**payload) def list_snapshots( @@ -370,6 +419,167 @@ def stop(self, sandbox_id: str) -> BasicResponse: payload = self._request("PUT", f"/sandbox/{sandbox_id}/stop") return BasicResponse(**payload) + def create_image_build( + self, + params: CreateSandboxImageBuildParams, + ) -> SandboxImageBuildCreateResult: + if not isinstance(params, CreateSandboxImageBuildParams): + raise TypeError("params must be a CreateSandboxImageBuildParams instance") + payload = self._request( + "POST", + "/images/builds", + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxImageBuildCreateResult(**payload) + + def get_image_build(self, build_id: str) -> SandboxImageBuild: + payload = self._request("GET", f"/images/builds/{build_id}") + return SandboxImageBuild(**payload["build"]) + + def complete_image_build( + self, + build_id: str, + params: CompleteSandboxImageBuildParams, + ) -> SandboxImageBuild: + if not isinstance(params, CompleteSandboxImageBuildParams): + raise TypeError("params must be a CompleteSandboxImageBuildParams instance") + payload = self._request( + "POST", + f"/images/builds/{build_id}/complete", + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxImageBuild(**payload["build"]) + + def cancel_image_build(self, build_id: str) -> SandboxImageBuild: + payload = self._request("POST", f"/images/builds/{build_id}/cancel") + return SandboxImageBuild(**payload["build"]) + + def wait_for_image_build( + self, + build_id: str, + *, + poll_interval: float = 3.0, + timeout: Optional[float] = 35 * 60, + ) -> SandboxImageBuild: + deadline = None if timeout is None else time.monotonic() + timeout + while True: + build = self.get_image_build(build_id) + if build.status == "completed": + return build + if build.status == "failed": + message = build.error_message or "image build failed" + if build.error_code: + message = f"image build failed [{build.error_code}]: {message}" + raise RuntimeError(message) + if is_terminal_image_build_status(build.status): + raise RuntimeError(f"image build {build.status}") + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for image build {build_id}") + time.sleep(poll_interval) + + def build_image_from_docker_image( + self, + *, + docker_image: str, + image_name: str, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + image_init: Optional[SandboxImageInit] = None, + image_config_user: Optional[str] = None, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + temp_dir: Optional[str] = None, + upload_timeout: Optional[float] = None, + ) -> SandboxImageBuild: + artifact = package_docker_image( + docker_image, + platform=platform, + temp_dir=temp_dir, + ) + try: + create_result = self.create_image_build( + CreateSandboxImageBuildParams( + image_name=image_name, + input_sha256=artifact.sha256_hex, + input_size_bytes=artifact.size_bytes, + input_format=artifact.input_format, + source_platform=artifact.source_platform, + image_config_user=( + image_config_user + if image_config_user is not None + else artifact.image_config_user + ), + image_init=image_init + if image_init is not None + else artifact.image_init, + ) + ) + upload_image_build_artifact( + create_result.upload, + artifact.path, + timeout=upload_timeout, + ) + build = self.complete_image_build( + create_result.build.id, + CompleteSandboxImageBuildParams( + input_sha256=artifact.sha256_hex, + input_size_bytes=artifact.size_bytes, + input_format=artifact.input_format, + ), + ) + if wait: + return self.wait_for_image_build( + build.id, + poll_interval=poll_interval, + timeout=wait_timeout, + ) + return build + finally: + artifact.cleanup() + + def build_image_from_dockerfile( + self, + *, + context_path, + image_name: str, + dockerfile="Dockerfile", + docker_tag: Optional[str] = None, + platform: str = IMAGE_BUILD_SOURCE_PLATFORM, + build_args: Optional[Dict[str, str]] = None, + image_init: Optional[SandboxImageInit] = None, + image_config_user: Optional[str] = None, + wait: bool = True, + poll_interval: float = 3.0, + wait_timeout: Optional[float] = 35 * 60, + temp_dir: Optional[str] = None, + upload_timeout: Optional[float] = None, + ) -> SandboxImageBuild: + tag = docker_tag or make_temp_docker_tag() + remove_tag = docker_tag is None + try: + build_docker_image_from_dockerfile( + context_path=context_path, + dockerfile=dockerfile, + tag=tag, + platform=platform, + build_args=build_args, + ) + return self.build_image_from_docker_image( + docker_image=tag, + image_name=image_name, + platform=platform, + image_init=image_init, + image_config_user=image_config_user, + wait=wait, + poll_interval=poll_interval, + wait_timeout=wait_timeout, + temp_dir=temp_dir, + upload_timeout=upload_timeout, + ) + finally: + if remove_tag: + remove_docker_image(tag) + def get_runtime_session(self, sandbox_id: str) -> SandboxRuntimeSession: detail = self.get_detail(sandbox_id) session = SandboxHandle._to_runtime_session(detail) @@ -404,6 +614,20 @@ def create_memory_snapshot( ) return SandboxMemorySnapshotResult(**payload) + def update_network( + self, + sandbox_id: str, + policy: SandboxNetworkPolicy, + ) -> SandboxNetworkUpdateResult: + if not isinstance(policy, SandboxNetworkPolicy): + raise TypeError("policy must be a SandboxNetworkPolicy instance") + payload = self._request( + "PUT", + f"/sandbox/{sandbox_id}/network", + data=policy.model_dump(exclude_none=True, by_alias=True), + ) + return SandboxNetworkUpdateResult(**payload) + def expose( self, sandbox_id: str, diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py index ebaaca57..3a473ae8 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py @@ -4,7 +4,7 @@ import socket import threading from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, Iterator, List, Optional, Union from urllib.parse import urlencode from websockets.exceptions import ConnectionClosed @@ -42,6 +42,30 @@ ) from .sandbox_transport import RuntimeTransport +DEFAULT_TRANSFER_CHUNK_SIZE = 64 * 1024 + + +def _iter_stream_content(stream, chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE): + if isinstance(stream, str): + yield stream.encode("utf-8") + return + if isinstance(stream, (bytes, bytearray)): + yield bytes(stream) + return + if hasattr(stream, "read"): + while True: + chunk = stream.read(chunk_size) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + yield bytes(chunk) + return + for chunk in stream: + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + yield bytes(chunk) + class SandboxFileWatchHandle: def __init__( @@ -420,12 +444,44 @@ def upload(self, path: str, data: Union[str, bytes, bytearray]): ) return SandboxFileTransferResult(**payload) + def upload_stream( + self, + path: str, + stream, + *, + content_length: Optional[int] = None, + chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, + ): + headers = {} + if content_length is not None: + headers["content-length"] = str(content_length) + payload = self._transport.request_json( + "/sandbox/files/upload", + method="PUT", + params=self._with_run_as_params({"path": path}), + content=_iter_stream_content(stream, chunk_size=chunk_size), + headers=headers or None, + ) + return SandboxFileTransferResult(**payload) + def download(self, path: str) -> bytes: return self._transport.request_bytes( "/sandbox/files/download", params=self._with_run_as_params({"path": path}), ) + def download_stream( + self, + path: str, + *, + chunk_size: int = DEFAULT_TRANSFER_CHUNK_SIZE, + ) -> Iterator[bytes]: + return self._transport.stream_bytes( + "/sandbox/files/download", + params=self._with_run_as_params({"path": path}), + chunk_size=chunk_size, + ) + def make_dir( self, path: str, diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py index d51d2647..1204b819 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py @@ -1,5 +1,5 @@ import json -from typing import Dict, Iterator, Optional, Union +from typing import Any, Dict, Iterator, Optional import httpx @@ -11,7 +11,7 @@ parse_json_response, resolve_runtime_transport_target, ) -from ...sandboxes.shared import _build_query_path +from ...sandboxes.shared import _build_query_path, _is_replayable_http_content class RuntimeTransport: @@ -32,7 +32,7 @@ def request_json( method: str = "GET", params: Optional[Dict[str, object]] = None, json_body: Optional[Dict[str, object]] = None, - content: Optional[Union[str, bytes]] = None, + content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, ): response = self._request( @@ -56,6 +56,29 @@ def request_bytes( response = self._request(path, method=method, params=params, headers=headers) return response.content + def stream_bytes( + self, + path: str, + *, + method: str = "GET", + params: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + chunk_size: int = 65536, + ) -> Iterator[bytes]: + client, response = self._open_binary_stream( + path, + method=method, + params=params, + headers=headers, + ) + try: + for chunk in response.iter_bytes(chunk_size=chunk_size): + if chunk: + yield chunk + finally: + response.close() + client.close() + def stream_sse( self, path: str, params: Optional[Dict[str, object]] = None ) -> Iterator[Dict[str, object]]: @@ -125,7 +148,7 @@ def _request( method: str = "GET", params: Optional[Dict[str, object]] = None, json_body: Optional[Dict[str, object]] = None, - content: Optional[Union[str, bytes]] = None, + content: Optional[Any] = None, headers: Optional[Dict[str, str]] = None, allow_refresh: bool = True, ) -> httpx.Response: @@ -142,6 +165,12 @@ def _request( if response.status_code == 401 and allow_refresh: response.close() + if not _is_replayable_http_content(content): + return ensure_response_ok( + response, + "runtime", + "Runtime token expired during a streaming request; retry with a fresh stream.", + ) refreshed = self._resolve_connection(True) retry = self._send( refreshed, @@ -176,6 +205,40 @@ def _open_stream( ensure_response_ok(response, "runtime") return client, response + def _open_binary_stream( + self, + path: str, + *, + method: str = "GET", + params: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, + allow_refresh: bool = True, + ): + connection = self._resolve_connection(False) + client, response = self._send_binary_stream( + connection, + path, + method=method, + params=params, + headers=headers, + ) + if response.status_code == 401 and allow_refresh: + response.close() + client.close() + refreshed = self._resolve_connection(True) + client, response = self._send_binary_stream( + refreshed, + path, + method=method, + params=params, + headers=headers, + ) + + if not response.is_success: + response.read() + ensure_response_ok(response, "runtime") + return client, response + def _send( self, connection: RuntimeConnection, @@ -184,7 +247,7 @@ def _send( method: str, params: Optional[Dict[str, object]], json_body: Optional[Dict[str, object]], - content: Optional[Union[str, bytes]], + content: Optional[Any], headers: Optional[Dict[str, str]], ) -> httpx.Response: request_path = _build_query_path(path, params) @@ -216,6 +279,36 @@ def _send( client.close() return response + def _send_binary_stream( + self, + connection: RuntimeConnection, + path: str, + *, + method: str, + params: Optional[Dict[str, object]], + headers: Optional[Dict[str, str]], + ): + request_path = _build_query_path(path, params) + target = resolve_runtime_transport_target( + connection.base_url, + request_path, + self._runtime_proxy_override, + ) + merged_headers = build_headers(connection.token, headers, target.host_header) + client = httpx.Client(timeout=self._timeout) + + try: + request = client.build_request(method, target.url, headers=merged_headers) + response = client.send(request, stream=True) + return client, response + except BaseException as error: + client.close() + raise normalize_network_error( + error, + "runtime", + "Unknown runtime request error", + ) + def _send_stream( self, connection: RuntimeConnection, diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 4eaabe06..24fe6363 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -288,10 +288,13 @@ SandboxRuntimeSession, SandboxVolumeMountType, SandboxVolumeMount, + SandboxNetworkPolicy, + SandboxNetworkUpdateResult, CreateSandboxParams, StartSandboxFromSnapshotParams, SandboxListParams, SandboxListResponse, + SandboxImageListParams, SandboxImageListResponse, SandboxImageSummary, SandboxSnapshotStatus, @@ -300,6 +303,14 @@ SandboxSnapshotListParams, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, + SandboxImageBuildInputFormat, + SandboxImageBuildStatus, + SandboxImageInit, + CreateSandboxImageBuildParams, + CompleteSandboxImageBuildParams, + SandboxImageBuildUpload, + SandboxImageBuild, + SandboxImageBuildCreateResult, SandboxExposeParams, SandboxExposeResult, SandboxUnexposeResult, @@ -561,10 +572,13 @@ "SandboxRuntimeSession", "SandboxVolumeMountType", "SandboxVolumeMount", + "SandboxNetworkPolicy", + "SandboxNetworkUpdateResult", "CreateSandboxParams", "StartSandboxFromSnapshotParams", "SandboxListParams", "SandboxListResponse", + "SandboxImageListParams", "SandboxImageListResponse", "SandboxImageSummary", "SandboxSnapshotStatus", @@ -573,6 +587,14 @@ "SandboxSnapshotListParams", "SandboxMemorySnapshotParams", "SandboxMemorySnapshotResult", + "SandboxImageBuildInputFormat", + "SandboxImageBuildStatus", + "SandboxImageInit", + "CreateSandboxImageBuildParams", + "CompleteSandboxImageBuildParams", + "SandboxImageBuildUpload", + "SandboxImageBuild", + "SandboxImageBuildCreateResult", "SandboxExposeParams", "SandboxExposeResult", "SandboxUnexposeResult", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index aa7faf0a..233e4e41 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -1,7 +1,14 @@ from datetime import datetime, timezone from typing import Callable, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) from ._parsers import _parse_optional_int from .session import SessionLaunchState, SessionStatus @@ -31,6 +38,16 @@ SandboxFileWatchRoute = Literal["ws", "stream"] SandboxFileSystemEventType = Literal["chmod", "create", "remove", "rename", "write"] SandboxVolumeMountType = Literal["rw", "ro"] +SandboxImageBuildInputFormat = Literal["rootfs_export_tar_gz"] +SandboxImageBuildStatus = Literal[ + "awaiting_upload", + "upload_verified", + "building", + "completed", + "failed", + "canceled", + "cancelled", +] def _parse_optional_datetime(value): @@ -93,6 +110,19 @@ class SandboxVolumeMount(SandboxBaseModel): shared: Optional[bool] = None +class SandboxNetworkPolicy(SandboxBaseModel): + allow_internet_access: Optional[bool] = Field( + default=None, + alias="allowInternetAccess", + ) + allow_out: List[str] = Field(default_factory=list, alias="allowOut") + deny_out: List[str] = Field(default_factory=list, alias="denyOut") + + +class SandboxNetworkUpdateResult(SandboxBaseModel): + network: SandboxNetworkPolicy + + class Sandbox(SandboxBaseModel): id: str team_id: str = Field(alias="teamId") @@ -122,6 +152,7 @@ class Sandbox(SandboxBaseModel): default_factory=list, alias="exposedPorts", ) + network: Optional[SandboxNetworkPolicy] = None @field_validator( "end_time", @@ -190,6 +221,12 @@ class CreateSandboxParams(SandboxBaseModel): disk_mib: Optional[int] = Field( default=None, ge=1, serialization_alias="diskSizeMiB" ) + allow_internet_access: Optional[bool] = Field( + default=None, + serialization_alias="allowInternetAccess", + ) + allow_out: Optional[List[str]] = Field(default=None, serialization_alias="allowOut") + deny_out: Optional[List[str]] = Field(default=None, serialization_alias="denyOut") @model_validator(mode="after") def validate_launch_source(self): @@ -233,10 +270,25 @@ class SandboxListResponse(SandboxBaseModel): per_page: int = Field(alias="perPage") +class SandboxImageInit(SandboxBaseModel): + env: Optional[Dict[str, str]] = None + command: Optional[str] = None + args: Optional[List[str]] = None + + +class SandboxImageListParams(SandboxBaseModel): + page: Optional[int] = Field(default=None, ge=1) + limit: Optional[int] = Field(default=None, ge=1, le=100) + search: Optional[str] = None + sources: Optional[List[str]] = Field(default=None, serialization_alias="source") + + class SandboxImageSummary(SandboxBaseModel): id: str image_name: str = Field(alias="imageName") namespace: str + source: Optional[str] = None + image_init: Optional[SandboxImageInit] = Field(default=None, alias="imageInit") uploaded: bool created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") @@ -244,10 +296,9 @@ class SandboxImageSummary(SandboxBaseModel): class SandboxImageListResponse(SandboxBaseModel): images: List[SandboxImageSummary] - # TODO: add pagination metadata when /api/images supports it. - # total_count: Optional[int] = Field(default=None, alias="totalCount") - # page: Optional[int] = None - # per_page: Optional[int] = Field(default=None, alias="perPage") + total_count: Optional[int] = Field(default=None, alias="totalCount") + page: Optional[int] = None + per_page: Optional[int] = Field(default=None, alias="perPage") class SandboxSnapshotSummary(SandboxBaseModel): @@ -266,17 +317,21 @@ class SandboxSnapshotSummary(SandboxBaseModel): class SandboxSnapshotListParams(SandboxBaseModel): - status: Optional[SandboxSnapshotStatus] = Field(default=None, exclude=None) - limit: Optional[int] = Field(default=None, ge=1) + status: Optional[Union[SandboxSnapshotStatus, List[SandboxSnapshotStatus]]] = Field( + default=None, + exclude=None, + ) + limit: Optional[int] = Field(default=None, ge=1, le=100) image_name: Optional[str] = Field(default=None, serialization_alias="imageName") + search: Optional[str] = None + page: Optional[int] = Field(default=None, ge=1) class SandboxSnapshotListResponse(SandboxBaseModel): snapshots: List[SandboxSnapshotSummary] - # TODO: add pagination metadata when /api/snapshots supports it. - # total_count: Optional[int] = Field(default=None, alias="totalCount") - # page: Optional[int] = None - # per_page: Optional[int] = Field(default=None, alias="perPage") + total_count: Optional[int] = Field(default=None, alias="totalCount") + page: Optional[int] = None + per_page: Optional[int] = Field(default=None, alias="perPage") class SandboxMemorySnapshotParams(SandboxBaseModel): @@ -295,6 +350,79 @@ class SandboxMemorySnapshotResult(SandboxBaseModel): image_namespace: str = Field(alias="imageNamespace") +class CreateSandboxImageBuildParams(SandboxBaseModel): + image_name: str = Field(serialization_alias="imageName") + input_sha256: str = Field(serialization_alias="inputSha256") + input_size_bytes: int = Field(serialization_alias="inputSizeBytes") + input_format: SandboxImageBuildInputFormat = Field( + default="rootfs_export_tar_gz", + serialization_alias="inputFormat", + ) + source_platform: str = Field( + default="linux/amd64", + serialization_alias="sourcePlatform", + ) + image_config_user: Optional[str] = Field( + default=None, + serialization_alias="imageConfigUser", + ) + image_init: Optional[SandboxImageInit] = Field( + default=None, + serialization_alias="imageInit", + ) + + +class CompleteSandboxImageBuildParams(SandboxBaseModel): + input_sha256: str = Field(serialization_alias="inputSha256") + input_size_bytes: int = Field(serialization_alias="inputSizeBytes") + input_format: SandboxImageBuildInputFormat = Field( + default="rootfs_export_tar_gz", + serialization_alias="inputFormat", + ) + + +class SandboxImageBuildUpload(SandboxBaseModel): + url: str + method: str + headers: Dict[str, str] + object_key: str = Field(alias="objectKey") + expires_in_seconds: int = Field(alias="expiresInSeconds") + max_upload_bytes: int = Field(alias="maxUploadBytes") + + +class SandboxImageBuild(SandboxBaseModel): + id: str + team_id: Optional[str] = Field(default=None, alias="teamId") + user_id: Optional[str] = Field(default=None, alias="userId") + namespace: Optional[str] = None + image_name: str = Field(alias="imageName") + image_id: Optional[str] = Field(default=None, alias="imageId") + status: SandboxImageBuildStatus + input_bucket: Optional[str] = Field(default=None, alias="inputBucket") + input_key: Optional[str] = Field(default=None, alias="inputKey") + input_sha256: Optional[str] = Field(default=None, alias="inputSha256") + input_size_bytes: Optional[int] = Field(default=None, alias="inputSizeBytes") + output_bucket: Optional[str] = Field(default=None, alias="outputBucket") + output_key: Optional[str] = Field(default=None, alias="outputKey") + vm_id: Optional[str] = Field(default=None, alias="vmId") + error_code: Optional[str] = Field(default=None, alias="errorCode") + error_message: Optional[str] = Field(default=None, alias="errorMessage") + metadata: Optional[Dict[str, object]] = None + completed_at: Optional[datetime] = Field(default=None, alias="completedAt") + created_at: Optional[datetime] = Field(default=None, alias="createdAt") + updated_at: Optional[datetime] = Field(default=None, alias="updatedAt") + + @field_validator("completed_at", "created_at", "updated_at", mode="before") + @classmethod + def parse_image_build_datetimes(cls, value): + return _parse_optional_datetime(value) + + +class SandboxImageBuildCreateResult(SandboxBaseModel): + build: SandboxImageBuild + upload: SandboxImageBuildUpload + + class SandboxExecParams(SandboxBaseModel): command: str args: Optional[List[str]] = None @@ -313,19 +441,41 @@ class SandboxProcessSummary(SandboxBaseModel): args: Optional[List[str]] = None cwd: str pid: Optional[int] = None - exit_code: Optional[int] = Field(default=None, alias="exit_code") - started_at: int = Field(alias="started_at") - completed_at: Optional[int] = Field(default=None, alias="completed_at") + exit_code: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("exit_code", "exitCode"), + serialization_alias="exit_code", + ) + started_at: int = Field( + validation_alias=AliasChoices("started_at", "startedAt"), + serialization_alias="started_at", + ) + completed_at: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("completed_at", "completedAt"), + serialization_alias="completed_at", + ) class SandboxProcessResult(SandboxBaseModel): id: str status: SandboxProcessStatus - exit_code: Optional[int] = Field(default=None, alias="exit_code") + exit_code: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("exit_code", "exitCode"), + serialization_alias="exit_code", + ) stdout: str stderr: str - started_at: int = Field(alias="started_at") - completed_at: Optional[int] = Field(default=None, alias="completed_at") + started_at: int = Field( + validation_alias=AliasChoices("started_at", "startedAt"), + serialization_alias="started_at", + ) + completed_at: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("completed_at", "completedAt"), + serialization_alias="completed_at", + ) error: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 67894c8a..b28c9bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "0.92.3" +version = "0.93.0" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" diff --git a/tests/helpers/sandbox.py b/tests/helpers/sandbox.py index a7f7b146..ef9f862f 100644 --- a/tests/helpers/sandbox.py +++ b/tests/helpers/sandbox.py @@ -5,7 +5,7 @@ from tests.helpers.config import DEFAULT_IMAGE_NAME -SNAPSHOT_LIST_LIMIT = 200 +SNAPSHOT_LIST_LIMIT = 100 LIST_POLL_DELAY_SECONDS = 0.5 LIST_POLL_TIMEOUT_SECONDS = 90 diff --git a/tests/sandbox/e2e/test_async_list.py b/tests/sandbox/e2e/test_async_list.py index c46a3afb..a6b3ef33 100644 --- a/tests/sandbox/e2e/test_async_list.py +++ b/tests/sandbox/e2e/test_async_list.py @@ -99,7 +99,7 @@ async def test_async_sandbox_list_e2e(): created_snapshots = await client.sandboxes.list_snapshots( SandboxSnapshotListParams( status="created", - limit=200, + limit=100, ) ) assert any( diff --git a/tests/sandbox/e2e/test_list.py b/tests/sandbox/e2e/test_list.py index 294fa75b..81737b88 100644 --- a/tests/sandbox/e2e/test_list.py +++ b/tests/sandbox/e2e/test_list.py @@ -95,7 +95,7 @@ def test_sandbox_list_e2e(): created_snapshots = client.sandboxes.list_snapshots( SandboxSnapshotListParams( status="created", - limit=200, + limit=100, ) ) assert any( diff --git a/tests/test_create_sandbox_params.py b/tests/test_create_sandbox_params.py index 6ef57f62..d6f69051 100644 --- a/tests/test_create_sandbox_params.py +++ b/tests/test_create_sandbox_params.py @@ -2,10 +2,14 @@ from pydantic import ValidationError from hyperbrowser.models import ( + CompleteSandboxImageBuildParams, CreateSandboxParams, + CreateSandboxImageBuildParams, SandboxExposeParams, SandboxFileWriteEntry, SandboxExecParams, + SandboxImageInit, + SandboxNetworkPolicy, SandboxProcessListParams, SandboxProcessWaitParams, SandboxSnapshotListParams, @@ -61,6 +65,74 @@ def test_create_sandbox_params_serializes_mounts(): } +def test_create_sandbox_params_serializes_network_policy(): + params = CreateSandboxParams( + image_name="node", + allow_internet_access=False, + allow_out=["1.1.1.1", "example.com"], + deny_out=["0.0.0.0/0"], + ) + + assert params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "node", + "allowInternetAccess": False, + "allowOut": ["1.1.1.1", "example.com"], + "denyOut": ["0.0.0.0/0"], + } + + +def test_sandbox_network_policy_serializes_update_payload(): + policy = SandboxNetworkPolicy( + allow_internet_access=True, + allow_out=[], + deny_out=[], + ) + + assert policy.model_dump(by_alias=True, exclude_none=True) == { + "allowInternetAccess": True, + "allowOut": [], + "denyOut": [], + } + + +def test_sandbox_image_build_params_serialize_expected_wire_keys(): + create_params = CreateSandboxImageBuildParams( + image_name="custom_node", + input_sha256="abc123", + input_size_bytes=123, + source_platform="linux/amd64", + image_config_user="node", + image_init=SandboxImageInit( + env={"NODE_ENV": "production"}, + args=["node", "server.js"], + ), + ) + + assert create_params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "custom_node", + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + "sourcePlatform": "linux/amd64", + "imageConfigUser": "node", + "imageInit": { + "env": {"NODE_ENV": "production"}, + "args": ["node", "server.js"], + }, + } + + complete_params = CompleteSandboxImageBuildParams( + input_sha256="abc123", + input_size_bytes=123, + ) + + assert complete_params.model_dump(by_alias=True, exclude_none=True) == { + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + } + + def test_create_sandbox_params_accepts_snapshot_source(): params = CreateSandboxParams(snapshot_name="snap", snapshot_id="snap-id") @@ -156,18 +228,27 @@ def test_sandbox_process_list_params_serialize_created_filters_as_snake_case(): def test_sandbox_snapshot_list_params_serialize_image_name_as_camel_case(): params = SandboxSnapshotListParams( - status="created", + status=["created", "failed"], limit=10, image_name="node", + search="snap", + page=2, ) assert params.model_dump(by_alias=True, exclude_none=True) == { - "status": "created", + "status": ["created", "failed"], "limit": 10, "imageName": "node", + "search": "snap", + "page": 2, } +def test_sandbox_snapshot_list_params_rejects_limit_above_api_max(): + with pytest.raises(ValidationError): + SandboxSnapshotListParams(limit=101) + + def test_sandbox_file_write_entry_supports_batch_write_options(): entry = SandboxFileWriteEntry( path="/tmp/example.txt", diff --git a/tests/test_sandbox_image_build_helpers.py b/tests/test_sandbox_image_build_helpers.py new file mode 100644 index 00000000..054839d1 --- /dev/null +++ b/tests/test_sandbox_image_build_helpers.py @@ -0,0 +1,369 @@ +import subprocess +from types import SimpleNamespace + +import httpx +import pytest +from pydantic import ValidationError + +import hyperbrowser.client.managers.async_manager.sandbox as async_sandbox_module +import hyperbrowser.client.managers.sync_manager.sandbox as sync_sandbox_module +from hyperbrowser.client.managers.sandboxes import image_build +from hyperbrowser.client.managers.async_manager.sandbox import ( + SandboxManager as AsyncSandboxManager, +) +from hyperbrowser.client.managers.sync_manager.sandbox import SandboxManager +from hyperbrowser.models import SandboxImageBuild, SandboxImageBuildUpload + + +def _image_build( + status: str, + *, + error_code: str = "", + error_message: str = "", +) -> SandboxImageBuild: + return SandboxImageBuild( + id="build-123", + imageName="custom", + status=status, + errorCode=error_code, + errorMessage=error_message, + ) + + +def test_image_build_model_rejects_invalid_status_casing(): + with pytest.raises(ValidationError): + _image_build(" Completed ") + + +def test_build_docker_image_from_dockerfile_targets_linux_amd64(monkeypatch, tmp_path): + calls = [] + + def fake_run(args, check, stdout, stderr, text): + calls.append(args) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(image_build.subprocess, "run", fake_run) + (tmp_path / "Dockerfile").write_text("FROM scratch\n") + + image_build.build_docker_image_from_dockerfile( + context_path=tmp_path, + dockerfile="Dockerfile", + tag="local/app:test", + build_args={"FOO": "bar"}, + ) + + assert calls == [ + [ + "docker", + "buildx", + "build", + "--platform", + "linux/amd64", + "-t", + "local/app:test", + "-f", + str(tmp_path / "Dockerfile"), + "--load", + "--build-arg", + "FOO=bar", + str(tmp_path), + ] + ] + + +def test_package_docker_image_rejects_non_amd64_local_image(monkeypatch): + def fake_run(args, check, stdout, stderr, text): + assert args[:4] == ["docker", "image", "inspect", "--format"] + return subprocess.CompletedProcess(args, 0, stdout="linux/arm64\n", stderr="") + + monkeypatch.setattr(image_build.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="expected linux/amd64"): + image_build.package_docker_image("local/app:latest") + + +def test_ensure_docker_image_source_platform_compares_case_insensitively( + monkeypatch, +): + def fake_run(args, check, stdout, stderr, text): + assert args[:4] == ["docker", "image", "inspect", "--format"] + return subprocess.CompletedProcess(args, 0, stdout="linux/amd64\n", stderr="") + + monkeypatch.setattr(image_build.subprocess, "run", fake_run) + + image_build._ensure_docker_image_source_platform("local/app:latest", "Linux/AMD64") + + +def test_package_docker_container_reaps_export_process_on_read_failure( + monkeypatch, + tmp_path, +): + removed = [] + + class BrokenStdout: + def __init__(self): + self.closed = False + + def read(self, size): + raise RuntimeError("read failed") + + def close(self): + self.closed = True + + class FakeProcess: + def __init__(self): + self.stdout = BrokenStdout() + self.terminated = False + self.killed = False + self.waits = 0 + self.return_code = None + + def poll(self): + return self.return_code + + def terminate(self): + self.terminated = True + self.return_code = -15 + + def kill(self): + self.killed = True + self.return_code = -9 + + def wait(self, timeout=None): + self.waits += 1 + if self.return_code is None: + self.return_code = 0 + return self.return_code + + fake_process = FakeProcess() + + def fake_popen(args, stdout, stderr): + return fake_process + + monkeypatch.setattr(image_build.subprocess, "Popen", fake_popen) + monkeypatch.setattr(image_build, "_remove_docker_container", removed.append) + + with pytest.raises(RuntimeError, match="read failed"): + image_build._package_docker_container( + "local/app:latest", + "container-123", + {}, + platform="linux/amd64", + temp_dir=str(tmp_path), + ) + + assert fake_process.stdout.closed is True + assert fake_process.terminated is True + assert fake_process.killed is False + assert fake_process.waits == 1 + assert removed == ["container-123"] + + +def test_upload_image_build_artifact_streams_file_with_content_length( + monkeypatch, + tmp_path, +): + artifact = tmp_path / "rootfs.tar.gz" + artifact.write_bytes(b"compressed-rootfs") + calls = [] + + def fake_request(method, url, content, headers, timeout): + calls.append( + { + "method": method, + "url": url, + "body": content.read(), + "headers": headers, + "timeout": timeout, + } + ) + return httpx.Response(200, request=httpx.Request(method, url)) + + monkeypatch.setattr(image_build.httpx, "request", fake_request) + + image_build.upload_image_build_artifact( + SandboxImageBuildUpload( + url="https://upload.example.com/rootfs", + method="PUT", + headers={"x-upload": "yes"}, + objectKey="input/key", + expiresInSeconds=900, + maxUploadBytes=1000, + ), + str(artifact), + timeout=None, + ) + + assert calls == [ + { + "method": "PUT", + "url": "https://upload.example.com/rootfs", + "body": b"compressed-rootfs", + "headers": { + "x-upload": "yes", + "content-length": str(len(b"compressed-rootfs")), + }, + "timeout": None, + } + ] + + +def test_sync_dockerfile_image_build_cleans_temp_tag_on_build_failure(monkeypatch): + removed = [] + + def fake_build(**kwargs): + raise RuntimeError("build failed") + + monkeypatch.setattr(sync_sandbox_module, "make_temp_docker_tag", lambda: "temp:tag") + monkeypatch.setattr( + sync_sandbox_module, + "build_docker_image_from_dockerfile", + fake_build, + ) + monkeypatch.setattr(sync_sandbox_module, "remove_docker_image", removed.append) + + manager = SandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + + with pytest.raises(RuntimeError, match="build failed"): + manager.build_image_from_dockerfile( + context_path=".", + image_name="custom", + ) + + assert removed == ["temp:tag"] + + +def test_sync_wait_for_image_build_returns_completed_status(monkeypatch): + manager = SandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + monkeypatch.setattr( + manager, + "get_image_build", + lambda build_id: _image_build("completed"), + ) + + build = manager.wait_for_image_build("build-123", poll_interval=0, timeout=1) + + assert build.status == "completed" + + +def test_sync_wait_for_image_build_raises_detailed_failed_status(monkeypatch): + manager = SandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + monkeypatch.setattr( + manager, + "get_image_build", + lambda build_id: _image_build( + "failed", + error_code="E_BAD", + error_message="backend failure", + ), + ) + + with pytest.raises( + RuntimeError, + match=r"image build failed \[E_BAD\]: backend failure", + ): + manager.wait_for_image_build("build-123", poll_interval=0, timeout=1) + + +@pytest.mark.anyio +async def test_async_dockerfile_image_build_cleans_temp_tag_on_build_failure( + monkeypatch, +): + removed = [] + + def fake_build(**kwargs): + raise RuntimeError("build failed") + + monkeypatch.setattr( + async_sandbox_module, + "make_temp_docker_tag", + lambda: "temp:tag", + ) + monkeypatch.setattr( + async_sandbox_module, + "build_docker_image_from_dockerfile", + fake_build, + ) + monkeypatch.setattr(async_sandbox_module, "remove_docker_image", removed.append) + + manager = AsyncSandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + + with pytest.raises(RuntimeError, match="build failed"): + await manager.build_image_from_dockerfile( + context_path=".", + image_name="custom", + ) + + assert removed == ["temp:tag"] + + +@pytest.mark.anyio +async def test_async_wait_for_image_build_returns_completed_status(monkeypatch): + manager = AsyncSandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + + async def fake_get_image_build(build_id): + return _image_build("completed") + + monkeypatch.setattr(manager, "get_image_build", fake_get_image_build) + + build = await manager.wait_for_image_build( + "build-123", + poll_interval=0, + timeout=1, + ) + + assert build.status == "completed" + + +@pytest.mark.anyio +async def test_async_wait_for_image_build_raises_detailed_failed_status(monkeypatch): + manager = AsyncSandboxManager( + SimpleNamespace( + timeout=30, + config=SimpleNamespace(runtime_proxy_override=None), + ) + ) + + async def fake_get_image_build(build_id): + return _image_build( + "failed", + error_code="E_BAD", + error_message="backend failure", + ) + + monkeypatch.setattr(manager, "get_image_build", fake_get_image_build) + + with pytest.raises( + RuntimeError, + match=r"image build failed \[E_BAD\]: backend failure", + ): + await manager.wait_for_image_build( + "build-123", + poll_interval=0, + timeout=1, + ) diff --git a/tests/test_sandbox_runtime_transport.py b/tests/test_sandbox_runtime_transport.py new file mode 100644 index 00000000..d888d1bf --- /dev/null +++ b/tests/test_sandbox_runtime_transport.py @@ -0,0 +1,115 @@ +import httpx +import pytest + +from hyperbrowser.client.managers.async_manager.sandboxes import ( + sandbox_transport as async_transport_module, +) +from hyperbrowser.client.managers.sync_manager.sandboxes import ( + sandbox_transport as sync_transport_module, +) +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.sandbox_common import RuntimeConnection + + +def _connection(token: str) -> RuntimeConnection: + return RuntimeConnection( + sandbox_id="sbx_123", + base_url="https://runtime.example.com/sandbox/sbx_123", + token=token, + ) + + +def test_sync_runtime_transport_does_not_retry_consumed_stream_body(monkeypatch): + calls = [] + + class FakeClient: + def __init__(self, timeout): + self.timeout = timeout + + def request(self, method, url, headers, json, content): + body = b"".join(content) + calls.append({"headers": headers, "body": body}) + return httpx.Response( + 401, + request=httpx.Request(method, url), + ) + + def close(self): + pass + + refresh_flags = [] + + def resolve_connection(force_refresh): + refresh_flags.append(force_refresh) + return _connection("fresh-token" if force_refresh else "old-token") + + monkeypatch.setattr(sync_transport_module.httpx, "Client", FakeClient) + transport = sync_transport_module.RuntimeTransport(resolve_connection) + + with pytest.raises(HyperbrowserError) as exc_info: + transport.request_json( + "/sandbox/files/upload", + method="PUT", + content=(chunk for chunk in [b"payload"]), + ) + + assert exc_info.value.status_code == 401 + assert refresh_flags == [False] + assert calls == [ + { + "headers": {"Authorization": "Bearer old-token"}, + "body": b"payload", + } + ] + + +@pytest.mark.anyio +async def test_async_runtime_transport_does_not_retry_consumed_stream_body( + monkeypatch, +): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def request(self, method, url, headers, json, content): + chunks = [] + async for chunk in content: + chunks.append(chunk) + calls.append({"headers": headers, "body": b"".join(chunks)}) + return httpx.Response( + 401, + request=httpx.Request(method, url), + ) + + async def aclose(self): + pass + + async def stream_body(): + yield b"payload" + + refresh_flags = [] + + async def resolve_connection(force_refresh): + refresh_flags.append(force_refresh) + return _connection("fresh-token" if force_refresh else "old-token") + + monkeypatch.setattr(async_transport_module.httpx, "AsyncClient", FakeAsyncClient) + transport = async_transport_module.RuntimeTransport(resolve_connection) + + with pytest.raises(HyperbrowserError) as exc_info: + await transport.request_json( + "/sandbox/files/upload", + method="PUT", + content=stream_body(), + ) + + assert exc_info.value.status_code == 401 + assert refresh_flags == [False] + assert calls == [ + { + "headers": {"Authorization": "Bearer old-token"}, + "body": b"payload", + } + ] diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 6806439d..e403a3f1 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -1,3 +1,4 @@ +import io import httpx import pytest from types import SimpleNamespace @@ -28,15 +29,22 @@ ) from hyperbrowser.client.managers.sandboxes.shared import _build_sandbox_exposed_url from hyperbrowser.models import ( + CompleteSandboxImageBuildParams, CreateSandboxParams, + CreateSandboxImageBuildParams, SandboxDetail, SandboxExposeParams, SandboxExecParams, SandboxFileWriteEntry, + SandboxImageInit, + SandboxImageListParams, SandboxListParams, SandboxMemorySnapshotParams, + SandboxNetworkPolicy, SandboxPresignFileParams, SandboxProcessListParams, + SandboxProcessResult, + SandboxProcessSummary, SandboxProcessWaitParams, SandboxSnapshotListParams, SandboxSnapshotSummary, @@ -84,6 +92,11 @@ "browserUrlExpiresAt": "2026-03-12T02:00:00Z", } ], + "network": { + "allowInternetAccess": False, + "allowOut": ["1.1.1.1"], + "denyOut": ["0.0.0.0/0"], + }, "token": "tok", "tokenExpiresAt": "2026-03-12T01:00:00Z", } @@ -135,6 +148,11 @@ "browserUrl": "https://3000-sbx_123.runtime.example.com/", } ], + "network": { + "allowInternetAccess": True, + "allowOut": [], + "denyOut": [], + }, } ], "totalCount": 1, @@ -148,11 +166,16 @@ "id": "img_123", "imageName": "node", "namespace": "team_1", + "source": "registry", + "imageInit": {"args": ["node", "server.js"]}, "uploaded": True, "createdAt": "2026-03-12T00:00:00Z", "updatedAt": "2026-03-12T00:00:01Z", } - ] + ], + "totalCount": 1, + "page": 2, + "perPage": 5, } SNAPSHOT_LIST_PAYLOAD = { @@ -171,7 +194,10 @@ "createdAt": "2026-03-12T00:00:00Z", "updatedAt": "2026-03-12T00:00:01Z", } - ] + ], + "totalCount": 1, + "page": 2, + "perPage": 5, } SNAPSHOT_PAYLOAD_WITHOUT_COMPATIBILITY_TAG = { @@ -192,11 +218,11 @@ "result": { "id": "proc_1", "status": "exited", - "exit_code": 0, + "exitCode": 0, "stdout": "", "stderr": "", - "started_at": 1, - "completed_at": 2, + "startedAt": 1, + "completedAt": 2, "error": None, } } @@ -209,9 +235,9 @@ "args": ["-lc", "echo hi"], "cwd": "/tmp", "pid": 123, - "exit_code": None, - "started_at": 1, - "completed_at": None, + "exitCode": None, + "startedAt": 1, + "completedAt": None, } } @@ -308,6 +334,57 @@ "exposed": False, } +NETWORK_UPDATE_PAYLOAD = { + "network": { + "allowInternetAccess": False, + "allowOut": ["1.1.1.1"], + "denyOut": ["0.0.0.0/0"], + } +} + +IMAGE_BUILD_RECORD = { + "id": "build-123", + "teamId": "team_1", + "userId": "user_1", + "namespace": "team_1", + "imageName": "custom_node", + "imageId": "img_123", + "status": "upload_verified", + "inputBucket": "bucket", + "inputKey": "input/key", + "inputSha256": "abc123", + "inputSizeBytes": 123, + "outputBucket": "out", + "outputKey": "output/key", + "vmId": "vm_123", + "errorCode": "", + "errorMessage": "", + "metadata": {}, + "completedAt": None, + "createdAt": "2026-03-12T00:00:00Z", + "updatedAt": "2026-03-12T00:00:01Z", +} + +IMAGE_BUILD_CREATE_PAYLOAD = { + "build": { + **IMAGE_BUILD_RECORD, + "status": "awaiting_upload", + "imageId": "", + }, + "upload": { + "url": "https://upload.example.com/rootfs", + "method": "PUT", + "headers": {"x-upload": "yes"}, + "objectKey": "input/key", + "expiresInSeconds": 900, + "maxUploadBytes": 1000, + }, +} + +IMAGE_BUILD_PAYLOAD = { + "build": IMAGE_BUILD_RECORD, +} + class RecordingHTTPClient: def __init__(self): @@ -325,12 +402,18 @@ def request(self, method, url, params=None, json=None): if url.endswith("/sandboxes"): payload = SANDBOX_LIST_PAYLOAD + elif url.endswith("/images/builds"): + payload = IMAGE_BUILD_CREATE_PAYLOAD + elif "/images/builds/" in url: + payload = IMAGE_BUILD_PAYLOAD elif url.endswith("/images"): payload = IMAGE_LIST_PAYLOAD elif url.endswith("/snapshots"): payload = SNAPSHOT_LIST_PAYLOAD elif url.endswith("/sandbox"): payload = SANDBOX_DETAIL_PAYLOAD + elif url.endswith("/network"): + payload = NETWORK_UPDATE_PAYLOAD elif url.endswith("/expose"): payload = EXPOSE_PAYLOAD elif url.endswith("/unexpose"): @@ -390,10 +473,52 @@ def request_json( return DOWNLOAD_PRESIGN_PAYLOAD if path == "/sandbox/files/write": return WRITE_FILE_PAYLOAD + if path == "/sandbox/files/upload": + return { + "path": params["path"], + "bytesWritten": -1, + } if path == "/sandbox/files/move": return MOVE_FILE_PAYLOAD raise AssertionError(f"Unexpected request path: {path}") + def request_bytes(self, path, *, method="GET", params=None, headers=None): + self.calls.append( + { + "path": path, + "method": method, + "params": params, + "headers": headers, + "bytes": True, + } + ) + if path == "/sandbox/files/download": + return b"downloaded" + raise AssertionError(f"Unexpected request path: {path}") + + def stream_bytes( + self, + path, + *, + method="GET", + params=None, + headers=None, + chunk_size=65536, + ): + self.calls.append( + { + "path": path, + "method": method, + "params": params, + "headers": headers, + "chunk_size": chunk_size, + "stream": True, + } + ) + if path == "/sandbox/files/download": + return iter([b"down", b"loaded"]) + raise AssertionError(f"Unexpected request path: {path}") + class AsyncRecordingTransport(RecordingTransport): async def request_json( @@ -415,6 +540,32 @@ async def request_json( headers=headers, ) + async def request_bytes(self, path, *, method="GET", params=None, headers=None): + return super().request_bytes( + path, + method=method, + params=params, + headers=headers, + ) + + async def stream_bytes( + self, + path, + *, + method="GET", + params=None, + headers=None, + chunk_size=65536, + ): + for chunk in super().stream_bytes( + path, + method=method, + params=params, + headers=headers, + chunk_size=chunk_size, + ): + yield chunk + class FakeSyncClient: def __init__(self): @@ -443,12 +594,18 @@ async def request(self, method, url, params=None, json=None): if url.endswith("/sandboxes"): payload = SANDBOX_LIST_PAYLOAD + elif url.endswith("/images/builds"): + payload = IMAGE_BUILD_CREATE_PAYLOAD + elif "/images/builds/" in url: + payload = IMAGE_BUILD_PAYLOAD elif url.endswith("/images"): payload = IMAGE_LIST_PAYLOAD elif url.endswith("/snapshots"): payload = SNAPSHOT_LIST_PAYLOAD elif url.endswith("/sandbox"): payload = SANDBOX_DETAIL_PAYLOAD + elif url.endswith("/network"): + payload = NETWORK_UPDATE_PAYLOAD elif url.endswith("/expose"): payload = EXPOSE_PAYLOAD elif url.endswith("/unexpose"): @@ -509,6 +666,9 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): ) }, timeout_minutes=15, + allow_internet_access=False, + allow_out=["1.1.1.1", "example.com"], + deny_out=["0.0.0.0/0"], ).model_dump(by_alias=True, exclude_none=True) == { "imageName": "node", "imageId": "img-id", @@ -525,6 +685,63 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): } }, "timeoutMinutes": 15, + "allowInternetAccess": False, + "allowOut": ["1.1.1.1", "example.com"], + "denyOut": ["0.0.0.0/0"], + } + + assert SandboxNetworkPolicy( + allow_internet_access=True, + allow_out=[], + deny_out=[], + ).model_dump(by_alias=True, exclude_none=True) == { + "allowInternetAccess": True, + "allowOut": [], + "denyOut": [], + } + + assert SandboxImageListParams( + page=2, + limit=5, + search="node", + sources=["registry", "uploaded"], + ).model_dump(by_alias=True, exclude_none=True) == { + "page": 2, + "limit": 5, + "search": "node", + "source": ["registry", "uploaded"], + } + + assert CreateSandboxImageBuildParams( + image_name="custom_node", + input_sha256="abc123", + input_size_bytes=123, + source_platform="linux/amd64", + image_config_user="node", + image_init=SandboxImageInit( + env={"NODE_ENV": "production"}, + args=["node", "server.js"], + ), + ).model_dump(by_alias=True, exclude_none=True) == { + "imageName": "custom_node", + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + "sourcePlatform": "linux/amd64", + "imageConfigUser": "node", + "imageInit": { + "env": {"NODE_ENV": "production"}, + "args": ["node", "server.js"], + }, + } + + assert CompleteSandboxImageBuildParams( + input_sha256="abc123", + input_size_bytes=123, + ).model_dump(by_alias=True, exclude_none=True) == { + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", } assert SandboxMemorySnapshotParams(snapshot_name="snap").model_dump( @@ -570,13 +787,17 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): } assert SandboxSnapshotListParams( - status="created", + status=["created", "failed"], limit=10, image_name="node", + search="snap", + page=2, ).model_dump(by_alias=True, exclude_none=True) == { - "status": "created", + "status": ["created", "failed"], "limit": 10, "imageName": "node", + "search": "snap", + "page": 2, } assert SandboxPresignFileParams( @@ -620,6 +841,75 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): } +def test_sandbox_process_models_accept_current_camel_case_wire_keys(): + summary = SandboxProcessSummary(**PROCESS_SUMMARY_PAYLOAD["process"]) + result = SandboxProcessResult(**PROCESS_RESULT_PAYLOAD["result"]) + + assert summary.exit_code is None + assert summary.started_at == 1 + assert summary.completed_at is None + assert result.exit_code == 0 + assert result.started_at == 1 + assert result.completed_at == 2 + + +def test_sync_sandbox_image_build_manager_uses_expected_wire_keys(): + client = FakeSyncClient() + manager = SandboxManager(client) + + created = manager.create_image_build( + CreateSandboxImageBuildParams( + image_name="custom_node", + input_sha256="abc123", + input_size_bytes=123, + image_config_user="node", + image_init=SandboxImageInit(args=["node", "server.js"]), + ) + ) + build = manager.get_image_build("build-123") + completed = manager.complete_image_build( + "build-123", + CompleteSandboxImageBuildParams( + input_sha256="abc123", + input_size_bytes=123, + ), + ) + canceled = manager.cancel_image_build("build-123") + + create_call = client.transport.client.calls[0] + get_call = client.transport.client.calls[1] + complete_call = client.transport.client.calls[2] + cancel_call = client.transport.client.calls[3] + + assert create_call["method"] == "POST" + assert create_call["url"].endswith("/images/builds") + assert create_call["json"] == { + "imageName": "custom_node", + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + "sourcePlatform": "linux/amd64", + "imageConfigUser": "node", + "imageInit": {"args": ["node", "server.js"]}, + } + assert created.build.status == "awaiting_upload" + assert created.upload.url == "https://upload.example.com/rootfs" + assert get_call["method"] == "GET" + assert get_call["url"].endswith("/images/builds/build-123") + assert complete_call["method"] == "POST" + assert complete_call["url"].endswith("/images/builds/build-123/complete") + assert complete_call["json"] == { + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + } + assert cancel_call["method"] == "POST" + assert cancel_call["url"].endswith("/images/builds/build-123/cancel") + assert build.image_name == "custom_node" + assert completed.status == "upload_verified" + assert canceled.status == "upload_verified" + + def test_sync_sandbox_control_manager_uses_expected_wire_keys(): client = FakeSyncClient() manager = SandboxManager(client) @@ -635,9 +925,22 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): search="sandbox", ) ) - images = manager.list_images() + images = manager.list_images( + SandboxImageListParams( + page=2, + limit=5, + search="node", + sources=["registry", "uploaded"], + ) + ) snapshots = manager.list_snapshots( - SandboxSnapshotListParams(status="created", limit=10, image_name="node") + SandboxSnapshotListParams( + status=["created", "failed"], + limit=10, + image_name="node", + search="snap", + page=2, + ) ) sandbox = manager.create( CreateSandboxParams( @@ -656,12 +959,22 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): ) }, timeout_minutes=15, + allow_internet_access=False, + allow_out=["1.1.1.1", "example.com"], + deny_out=["0.0.0.0/0"], ) ) manager.create_memory_snapshot( "sbx_123", SandboxMemorySnapshotParams(snapshot_name="snap"), ) + network = sandbox.update_network( + SandboxNetworkPolicy( + allow_internet_access=False, + allow_out=["1.1.1.1"], + deny_out=["0.0.0.0/0"], + ) + ) exposed = manager.expose( "sbx_123", SandboxExposeParams(port=3000, auth=True), @@ -674,8 +987,9 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): snapshots_call = client.transport.client.calls[2] create_call = client.transport.client.calls[3] snapshot_call = client.transport.client.calls[4] - expose_call = client.transport.client.calls[5] - unexpose_call = client.transport.client.calls[6] + network_call = client.transport.client.calls[5] + expose_call = client.transport.client.calls[6] + unexpose_call = client.transport.client.calls[7] assert list_call["params"] == { "status": "active", @@ -689,14 +1003,31 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): assert listed.page == 2 assert listed.per_page == 5 assert images_call["url"].endswith("/images") + assert images_call["params"] == { + "page": 2, + "limit": 5, + "search": "node", + "source": ["registry", "uploaded"], + } assert images.images[0].image_name == "node" + assert images.images[0].source == "registry" + assert images.images[0].image_init is not None + assert images.images[0].image_init.args == ["node", "server.js"] + assert images.total_count == 1 + assert images.page == 2 + assert images.per_page == 5 assert snapshots_call["url"].endswith("/snapshots") assert snapshots_call["params"] == { - "status": "created", + "status": ["created", "failed"], "limit": 10, "imageName": "node", + "search": "snap", + "page": 2, } assert snapshots.snapshots[0].compatibility_tag is None + assert snapshots.total_count == 1 + assert snapshots.page == 2 + assert snapshots.per_page == 5 assert create_call["json"] == { "imageName": "node", "imageId": "img-id", @@ -713,14 +1044,27 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): } }, "timeoutMinutes": 15, + "allowInternetAccess": False, + "allowOut": ["1.1.1.1", "example.com"], + "denyOut": ["0.0.0.0/0"], } assert snapshot_call["json"] == { "snapshotName": "snap", } + assert network_call["method"] == "PUT" + assert network_call["url"].endswith("/sandbox/sbx_123/network") + assert network_call["json"] == { + "allowInternetAccess": False, + "allowOut": ["1.1.1.1"], + "denyOut": ["0.0.0.0/0"], + } assert sandbox.cpu == 2 assert sandbox.memory_mib == 2048 assert sandbox.disk_mib == 8192 + assert sandbox.network is not None + assert sandbox.network.allow_out == ["1.1.1.1"] assert sandbox.exposed_ports[0].browser_url is not None + assert network.network.allow_internet_access is False assert sandbox.get_exposed_url(3000) == "https://3000-sbx_123.runtime.example.com/" assert expose_call["json"] == {"port": 3000, "auth": True} assert exposed.browser_url is not None @@ -817,6 +1161,13 @@ def test_sync_sandbox_runtime_apis_use_expected_wire_keys(): destination="/tmp/destination.txt", overwrite=True, ) + upload_result = files.upload_stream( + "/tmp/upload.bin", + io.BytesIO(b"abcdef"), + content_length=6, + chunk_size=2, + ) + downloaded = b"".join(files.download_stream("/tmp/file.txt", chunk_size=4)) assert transport.calls[0]["json_body"] == { "command": "echo 'hi world'", @@ -880,6 +1231,20 @@ def test_sync_sandbox_runtime_apis_use_expected_wire_keys(): "to": "/tmp/destination.txt", "overwrite": True, } + assert transport.calls[12]["path"] == "/sandbox/files/upload" + assert transport.calls[12]["params"] == {"path": "/tmp/upload.bin"} + assert transport.calls[12]["headers"] == {"content-length": "6"} + assert b"".join(transport.calls[12]["content"]) == b"abcdef" + assert upload_result.path == "/tmp/upload.bin" + assert transport.calls[13] == { + "path": "/sandbox/files/download", + "method": "GET", + "params": {"path": "/tmp/file.txt"}, + "headers": None, + "chunk_size": 4, + "stream": True, + } + assert downloaded == b"downloaded" def test_sync_sandbox_process_string_calls_support_run_as(): @@ -942,6 +1307,64 @@ def fake_exec(input, **kwargs): ] +@pytest.mark.anyio +async def test_async_sandbox_image_build_manager_uses_expected_wire_keys(): + client = FakeAsyncClient() + manager = AsyncSandboxManager(client) + + created = await manager.create_image_build( + CreateSandboxImageBuildParams( + image_name="custom_node", + input_sha256="abc123", + input_size_bytes=123, + image_config_user="node", + image_init=SandboxImageInit(args=["node", "server.js"]), + ) + ) + build = await manager.get_image_build("build-123") + completed = await manager.complete_image_build( + "build-123", + CompleteSandboxImageBuildParams( + input_sha256="abc123", + input_size_bytes=123, + ), + ) + canceled = await manager.cancel_image_build("build-123") + + create_call = client.transport.client.calls[0] + get_call = client.transport.client.calls[1] + complete_call = client.transport.client.calls[2] + cancel_call = client.transport.client.calls[3] + + assert create_call["method"] == "POST" + assert create_call["url"].endswith("/images/builds") + assert create_call["json"] == { + "imageName": "custom_node", + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + "sourcePlatform": "linux/amd64", + "imageConfigUser": "node", + "imageInit": {"args": ["node", "server.js"]}, + } + assert created.build.status == "awaiting_upload" + assert created.upload.url == "https://upload.example.com/rootfs" + assert get_call["method"] == "GET" + assert get_call["url"].endswith("/images/builds/build-123") + assert complete_call["method"] == "POST" + assert complete_call["url"].endswith("/images/builds/build-123/complete") + assert complete_call["json"] == { + "inputSha256": "abc123", + "inputSizeBytes": 123, + "inputFormat": "rootfs_export_tar_gz", + } + assert cancel_call["method"] == "POST" + assert cancel_call["url"].endswith("/images/builds/build-123/cancel") + assert build.image_name == "custom_node" + assert completed.status == "upload_verified" + assert canceled.status == "upload_verified" + + @pytest.mark.anyio async def test_async_sandbox_control_manager_uses_expected_wire_keys(): client = FakeAsyncClient() @@ -958,9 +1381,22 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): search="sandbox", ) ) - images = await manager.list_images() + images = await manager.list_images( + SandboxImageListParams( + page=2, + limit=5, + search="node", + sources=["registry", "uploaded"], + ) + ) snapshots = await manager.list_snapshots( - SandboxSnapshotListParams(status="created", limit=10, image_name="node") + SandboxSnapshotListParams( + status=["created", "failed"], + limit=10, + image_name="node", + search="snap", + page=2, + ) ) sandbox = await manager.create( CreateSandboxParams( @@ -979,12 +1415,22 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): ) }, timeout_minutes=15, + allow_internet_access=False, + allow_out=["1.1.1.1", "example.com"], + deny_out=["0.0.0.0/0"], ) ) await manager.create_memory_snapshot( "sbx_123", SandboxMemorySnapshotParams(snapshot_name="snap"), ) + network = await sandbox.update_network( + SandboxNetworkPolicy( + allow_internet_access=False, + allow_out=["1.1.1.1"], + deny_out=["0.0.0.0/0"], + ) + ) exposed = await manager.expose( "sbx_123", SandboxExposeParams(port=3000, auth=True), @@ -997,8 +1443,9 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): snapshots_call = client.transport.client.calls[2] create_call = client.transport.client.calls[3] snapshot_call = client.transport.client.calls[4] - expose_call = client.transport.client.calls[5] - unexpose_call = client.transport.client.calls[6] + network_call = client.transport.client.calls[5] + expose_call = client.transport.client.calls[6] + unexpose_call = client.transport.client.calls[7] assert list_call["params"] == { "status": "active", @@ -1012,14 +1459,31 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): assert listed.page == 2 assert listed.per_page == 5 assert images_call["url"].endswith("/images") + assert images_call["params"] == { + "page": 2, + "limit": 5, + "search": "node", + "source": ["registry", "uploaded"], + } assert images.images[0].image_name == "node" + assert images.images[0].source == "registry" + assert images.images[0].image_init is not None + assert images.images[0].image_init.args == ["node", "server.js"] + assert images.total_count == 1 + assert images.page == 2 + assert images.per_page == 5 assert snapshots_call["url"].endswith("/snapshots") assert snapshots_call["params"] == { - "status": "created", + "status": ["created", "failed"], "limit": 10, "imageName": "node", + "search": "snap", + "page": 2, } assert snapshots.snapshots[0].compatibility_tag is None + assert snapshots.total_count == 1 + assert snapshots.page == 2 + assert snapshots.per_page == 5 assert create_call["json"] == { "imageName": "node", "imageId": "img-id", @@ -1036,14 +1500,27 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): } }, "timeoutMinutes": 15, + "allowInternetAccess": False, + "allowOut": ["1.1.1.1", "example.com"], + "denyOut": ["0.0.0.0/0"], } assert snapshot_call["json"] == { "snapshotName": "snap", } + assert network_call["method"] == "PUT" + assert network_call["url"].endswith("/sandbox/sbx_123/network") + assert network_call["json"] == { + "allowInternetAccess": False, + "allowOut": ["1.1.1.1"], + "denyOut": ["0.0.0.0/0"], + } assert sandbox.cpu == 2 assert sandbox.memory_mib == 2048 assert sandbox.disk_mib == 8192 + assert sandbox.network is not None + assert sandbox.network.allow_out == ["1.1.1.1"] assert sandbox.exposed_ports[0].browser_url is not None + assert network.network.allow_internet_access is False assert sandbox.get_exposed_url(3000) == "https://3000-sbx_123.runtime.example.com/" assert expose_call["json"] == {"port": 3000, "auth": True} assert exposed.browser_url is not None @@ -1109,6 +1586,16 @@ async def test_async_sandbox_runtime_apis_use_expected_wire_keys(): destination="/tmp/destination.txt", overwrite=True, ) + upload_result = await files.upload_stream( + "/tmp/upload.bin", + io.BytesIO(b"abcdef"), + content_length=6, + chunk_size=2, + ) + chunks = [] + async for chunk in files.download_stream("/tmp/file.txt", chunk_size=4): + chunks.append(chunk) + downloaded = b"".join(chunks) assert transport.calls[0]["json_body"] == { "command": "echo 'hi world'", @@ -1172,6 +1659,23 @@ async def test_async_sandbox_runtime_apis_use_expected_wire_keys(): "to": "/tmp/destination.txt", "overwrite": True, } + assert transport.calls[12]["path"] == "/sandbox/files/upload" + assert transport.calls[12]["params"] == {"path": "/tmp/upload.bin"} + assert transport.calls[12]["headers"] == {"content-length": "6"} + async_upload_chunks = [] + async for chunk in transport.calls[12]["content"]: + async_upload_chunks.append(chunk) + assert b"".join(async_upload_chunks) == b"abcdef" + assert upload_result.path == "/tmp/upload.bin" + assert transport.calls[13] == { + "path": "/sandbox/files/download", + "method": "GET", + "params": {"path": "/tmp/file.txt"}, + "headers": None, + "chunk_size": 4, + "stream": True, + } + assert downloaded == b"downloaded" @pytest.mark.anyio