From 09dc197f89162b2621b0c882101b0dc631f7a072 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Sun, 24 May 2026 22:00:17 -0400 Subject: [PATCH 01/22] Add Sailbox sandbox provider --- .../ref/extensions/sandbox/sailbox/sandbox.md | 1 + docs/sandbox/clients.md | 3 + examples/sandbox/extensions/README.md | 32 +- examples/sandbox/extensions/sailbox_runner.py | 162 ++++ pyproject.toml | 5 + src/agents/extensions/sandbox/__init__.py | 22 + .../extensions/sandbox/sailbox/__init__.py | 15 + .../extensions/sandbox/sailbox/sandbox.py | 752 ++++++++++++++++++ tests/extensions/sandbox/test_sailbox.py | 572 +++++++++++++ tests/sandbox/test_compatibility_guards.py | 56 ++ uv.lock | 161 ++-- 11 files changed, 1715 insertions(+), 66 deletions(-) create mode 100644 docs/ref/extensions/sandbox/sailbox/sandbox.md create mode 100644 examples/sandbox/extensions/sailbox_runner.py create mode 100644 src/agents/extensions/sandbox/sailbox/__init__.py create mode 100644 src/agents/extensions/sandbox/sailbox/sandbox.py create mode 100644 tests/extensions/sandbox/test_sailbox.py diff --git a/docs/ref/extensions/sandbox/sailbox/sandbox.md b/docs/ref/extensions/sandbox/sailbox/sandbox.md new file mode 100644 index 0000000000..3fab79878f --- /dev/null +++ b/docs/ref/extensions/sandbox/sailbox/sandbox.md @@ -0,0 +1 @@ +::: agents.extensions.sandbox.sailbox.sandbox diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index bd21da63d3..2d90a061ea 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -96,6 +96,7 @@ For provider-specific setup notes and links for the checked-in extension example | `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | | `ModalSandboxClient` | `openai-agents[modal]` | [Modal runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) | | `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) | +| `SailboxSandboxClient` | `openai-agents[sailbox]` | [Sailbox runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/sailbox_runner.py) | | `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) | @@ -113,6 +114,7 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac | `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | +| `SailboxSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | | `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | @@ -130,6 +132,7 @@ The table below summarizes which remote storage entries each backend can mount d | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | +| `SailboxSandboxClient` | - | - | - | - | - | - | | `VercelSandboxClient` | - | - | - | - | - | - | diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md index 837d9dfa28..a2cf044e4a 100644 --- a/examples/sandbox/extensions/README.md +++ b/examples/sandbox/extensions/README.md @@ -7,7 +7,7 @@ They intentionally keep the flow simple: 1. Build a tiny manifest in memory. 2. Create a `SandboxAgent` that inspects that workspace through one shell tool. -3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel. +3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Sailbox, Blaxel, or Vercel. All of these examples require `OPENAI_API_KEY`, because they call the model through the normal `Runner` path. Each cloud backend also needs its own provider credentials. @@ -328,6 +328,36 @@ the default home and working directory become `/root`, so the example also uses `/root` as its manifest workspace root. If you configure root launch in your own code, either rely on that root-mode default or explicitly choose a `manifest.root` under `/root`. + +## Sailbox + +### Setup + +Install the repo extra: + +```bash +uv sync --extra sailbox +``` + +Export the required environment variables: + +```bash +export OPENAI_API_KEY=... +export SAIL_API_KEY=... +``` + +### Run + +```bash +uv run python examples/sandbox/extensions/sailbox_runner.py --stream +``` + +Useful flags: + +- `--image debian-arm64` +- `--image debian-amd64` +- `--pause-on-exit` + ## Blaxel ### Setup diff --git a/examples/sandbox/extensions/sailbox_runner.py b/examples/sandbox/extensions/sailbox_runner.py new file mode 100644 index 0000000000..2511e86482 --- /dev/null +++ b/examples/sandbox/extensions/sailbox_runner.py @@ -0,0 +1,162 @@ +""" +Minimal Sailbox-backed sandbox example for manual validation. + +This mirrors the other cloud extension examples: it creates a tiny workspace, asks a sandboxed +agent to inspect it through one shell tool, and prints a short answer. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from pathlib import Path +from typing import Literal, cast + +from openai.types.responses import ResponseTextDeltaEvent + +from agents import ModelSettings, Runner +from agents.run import RunConfig +from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig + +if __package__ is None or __package__ == "": + sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from examples.sandbox.misc.example_support import text_manifest +from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability + +try: + from sail.image import Image, ImageDefinition + + from agents.extensions.sandbox import SailboxSandboxClient, SailboxSandboxClientOptions +except Exception as exc: # pragma: no cover - import path depends on optional extras + raise SystemExit( + "Sailbox sandbox examples require the optional repo extra.\n" + "Install it with: uv sync --extra sailbox" + ) from exc + + +DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences." + + +def _build_manifest() -> Manifest: + return text_manifest( + { + "README.md": ( + "# Sailbox Demo Workspace\n\n" + "This workspace exists to validate the Sailbox sandbox backend manually.\n" + ), + "handoff.md": ( + "# Handoff\n\n" + "- Customer: Northwind Traders.\n" + "- Goal: validate Sailbox sandbox exec and workspace flows.\n" + "- Current status: the OpenAI Agents SDK provider is wired for manual smoke tests.\n" + ), + "todo.md": ( + "# Todo\n\n" + "1. Inspect the workspace files.\n" + "2. Summarize the current status in two sentences.\n" + ), + } + ) + + +def _require_env(name: str) -> None: + if os.environ.get(name): + return + raise SystemExit(f"{name} must be set before running this example.") + + +def _image_from_name(name: Literal["debian-arm64", "debian-amd64"]) -> ImageDefinition: + if name == "debian-amd64": + return Image.debian_amd64 + return Image.debian_arm64 + + +async def main( + *, + model: str, + question: str, + image: Literal["debian-arm64", "debian-amd64"], + pause_on_exit: bool, + stream: bool, +) -> None: + _require_env("OPENAI_API_KEY") + _require_env("SAIL_API_KEY") + + manifest = _build_manifest() + agent = SandboxAgent( + name="Sailbox Sandbox Assistant", + model=model, + instructions=( + "Answer questions about the sandbox workspace. Inspect the files before answering " + "and keep the response concise. " + "Do not invent files or statuses that are not present in the workspace. Cite the " + "file names you inspected." + ), + default_manifest=manifest, + capabilities=[WorkspaceShellCapability()], + model_settings=ModelSettings(tool_choice="required"), + ) + + client = SailboxSandboxClient() + run_config = RunConfig( + sandbox=SandboxRunConfig( + client=client, + options=SailboxSandboxClientOptions( + image=_image_from_name(image), + pause_on_exit=pause_on_exit, + ), + ), + workflow_name="Sailbox sandbox example", + ) + + if not stream: + result = await Runner.run(agent, question, run_config=run_config) + print(result.final_output) + return + + stream_result = Runner.run_streamed(agent, question, run_config=run_config) + saw_text_delta = False + async for event in stream_result.stream_events(): + if event.type == "raw_response_event" and isinstance( + event.data, ResponseTextDeltaEvent + ): + if not saw_text_delta: + print("assistant> ", end="", flush=True) + saw_text_delta = True + print(event.data.delta, end="", flush=True) + + if saw_text_delta: + print() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--model", default="gpt-5.5", help="Model name to use.") + parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.") + parser.add_argument( + "--image", + choices=("debian-arm64", "debian-amd64"), + default="debian-arm64", + help="Sailbox base image to use.", + ) + parser.add_argument( + "--pause-on-exit", + action="store_true", + default=False, + help="Pause the Sailbox on shutdown instead of terminating it.", + ) + parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.") + args = parser.parse_args() + + asyncio.run( + main( + model=args.model, + question=args.question, + image=cast(Literal["debian-arm64", "debian-amd64"], args.image), + pause_on_exit=args.pause_on_exit, + stream=args.stream, + ) + ) diff --git a/pyproject.toml b/pyproject.toml index ad2b314ead..9a19ca6f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ cloudflare = ["aiohttp>=3.12,<4"] e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"] modal = ["modal==1.3.5"] runloop = ["runloop_api_client>=1.16.0,<2.0.0"] +sailbox = ["sail-sdk>=0.1.24"] vercel = ["vercel>=0.5.6,<0.6"] s3 = ["boto3>=1.34"] temporal = [ @@ -164,6 +165,10 @@ ignore_missing_imports = true module = ["vercel", "vercel.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["sail", "sail.*"] +ignore_missing_imports = true + [tool.coverage.run] source = ["src/agents"] omit = [ diff --git a/src/agents/extensions/sandbox/__init__.py b/src/agents/extensions/sandbox/__init__.py index d7b082ba1f..0afb9b90da 100644 --- a/src/agents/extensions/sandbox/__init__.py +++ b/src/agents/extensions/sandbox/__init__.py @@ -97,6 +97,18 @@ except Exception: # pragma: no cover _HAS_RUNLOOP = False +try: + from .sailbox import ( + SailboxSandboxClient as SailboxSandboxClient, + SailboxSandboxClientOptions as SailboxSandboxClientOptions, + SailboxSandboxSession as SailboxSandboxSession, + SailboxSandboxSessionState as SailboxSandboxSessionState, + ) + + _HAS_SAILBOX = True +except Exception: # pragma: no cover + _HAS_SAILBOX = False + try: from .vercel import ( VercelSandboxClient as VercelSandboxClient, @@ -177,6 +189,16 @@ ] ) +if _HAS_SAILBOX: + __all__.extend( + [ + "SailboxSandboxClient", + "SailboxSandboxClientOptions", + "SailboxSandboxSession", + "SailboxSandboxSessionState", + ] + ) + if _HAS_VERCEL: __all__.extend( [ diff --git a/src/agents/extensions/sandbox/sailbox/__init__.py b/src/agents/extensions/sandbox/sailbox/__init__.py new file mode 100644 index 0000000000..ed1b6a76c1 --- /dev/null +++ b/src/agents/extensions/sandbox/sailbox/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .sandbox import ( + SailboxSandboxClient, + SailboxSandboxClientOptions, + SailboxSandboxSession, + SailboxSandboxSessionState, +) + +__all__ = [ + "SailboxSandboxClient", + "SailboxSandboxClientOptions", + "SailboxSandboxSession", + "SailboxSandboxSessionState", +] diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py new file mode 100644 index 0000000000..f71e6c3787 --- /dev/null +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -0,0 +1,752 @@ +""" +Sailbox sandbox implementation. + +This module provides a Sailbox-backed sandbox client/session implementation backed by +`sail.sailbox.Sailbox`. + +The `sail-sdk` dependency is optional, so package-level exports should guard imports of this +module. Within this module, Sail SDK imports are normal so users with the extra installed get +full type navigation. +""" + +from __future__ import annotations + +import asyncio +import io +import math +import shlex +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Any, Literal, TypeVar, cast +from urllib.parse import urlsplit + +from pydantic import field_serializer +from sail.app import App +from sail.image import Image, ImageDefinition +from sail.sailbox import Sailbox + +from ....sandbox.errors import ( + ExecTransportError, + ExposedPortUnavailableError, + WorkspaceArchiveReadError, + WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, + WorkspaceStartError, +) +from ....sandbox.manifest import Manifest +from ....sandbox.session import SandboxSession, SandboxSessionState +from ....sandbox.session.base_sandbox_session import BaseSandboxSession +from ....sandbox.session.dependencies import Dependencies +from ....sandbox.session.manager import Instrumentation +from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER +from ....sandbox.session.sandbox_client import ( + BaseSandboxClient, + BaseSandboxClientOptions, +) +from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot +from ....sandbox.types import ExecResult, ExposedPortEndpoint, User +from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes +from ....sandbox.workspace_paths import sandbox_path_str + +_DEFAULT_APP_NAME = "openai-agents-sandbox" +_DEFAULT_NAME_PREFIX = "openai-agent" +_DEFAULT_MEMORY_MIB = 1024 +_DEFAULT_CPU = 1 +_DEFAULT_DISK_GIB = 8 +_DEFAULT_IMAGE_BUILD_TIMEOUT = 1800 + +R = TypeVar("R") + + +async def _call_sailbox(fn: Callable[..., R], *args: object, **kwargs: object) -> R: + return await asyncio.to_thread(fn, *args, **kwargs) + + +def _sailbox_provider_error_detail(error: BaseException) -> str | None: + message = str(error) + status = getattr(error, "status_code", None) or getattr(error, "status", None) + if isinstance(status, int): + if message: + return f"HTTP {status}: {message}" + return f"HTTP {status}" + if message: + return f"{type(error).__name__}: {message}" + return type(error).__name__ + + +def _sailbox_error_context( + *, + cause: BaseException, + extra: dict[str, object] | None = None, +) -> dict[str, object]: + context: dict[str, object] = {"backend": "sailbox", **(extra or {})} + detail = _sailbox_provider_error_detail(cause) + if detail: + context["provider_error"] = detail + status = getattr(cause, "status_code", None) or getattr(cause, "status", None) + if isinstance(status, int): + context["http_status"] = status + return context + + +def _sailbox_error_message(prefix: str, cause: BaseException) -> str: + detail = _sailbox_provider_error_detail(cause) + if detail: + return f"{prefix}: {detail}" + return prefix + + +class SailboxSandboxClientOptions(BaseSandboxClientOptions): + """Client options for creating OpenAI Agents SDK sessions on Sailboxes.""" + + type: Literal["sailbox"] = "sailbox" + app: App | None = None + app_name: str | None = _DEFAULT_APP_NAME + image: ImageDefinition | None = None + name_prefix: str = _DEFAULT_NAME_PREFIX + image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT + memory_mib: int = _DEFAULT_MEMORY_MIB + cpu: int = _DEFAULT_CPU + disk_gib: int = _DEFAULT_DISK_GIB + exposed_ports: tuple[int, ...] = () + pause_on_exit: bool = False + + def __init__( + self, + app: App | None = None, + app_name: str | None = _DEFAULT_APP_NAME, + image: ImageDefinition | None = None, + name_prefix: str = _DEFAULT_NAME_PREFIX, + image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT, + memory_mib: int = _DEFAULT_MEMORY_MIB, + cpu: int = _DEFAULT_CPU, + disk_gib: int = _DEFAULT_DISK_GIB, + exposed_ports: tuple[int, ...] = (), + pause_on_exit: bool = False, + *, + type: Literal["sailbox"] = "sailbox", + ) -> None: + super().__init__( + type=type, + app=app, + app_name=app_name, + image=image, + name_prefix=name_prefix, + image_build_timeout=image_build_timeout, + memory_mib=memory_mib, + cpu=cpu, + disk_gib=disk_gib, + exposed_ports=tuple(exposed_ports), + pause_on_exit=pause_on_exit, + ) + + @field_serializer("app", when_used="json") + def _serialize_app(self, app: App | None) -> str | None: + return app.id if app is not None else None + + @field_serializer("image", when_used="json") + def _serialize_image(self, image: ImageDefinition | None) -> str | None: + # ImageDefinition contains protobuf state. Options are not persisted by + # OpenAI Agents run state, but keep model_dump(mode="json") well-defined. + return getattr(image, "_image_id", None) if image is not None else None + + +class SailboxSandboxSessionState(SandboxSessionState): + """Serializable state for a Sailbox-backed OpenAI Agents SDK session.""" + + type: Literal["sailbox"] = "sailbox" + sailbox_id: str = "" + sailbox_name: str = "" + app_name: str | None = None + exec_endpoint: str = "" + worker_address: str = "" + status: str = "" + image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT + memory_mib: int = _DEFAULT_MEMORY_MIB + cpu: int = _DEFAULT_CPU + disk_gib: int = _DEFAULT_DISK_GIB + pause_on_exit: bool = False + + +class SailboxSandboxSession(BaseSandboxSession): + """OpenAI Agents SDK sandbox session backed by a single Sailbox.""" + + state: SailboxSandboxSessionState + + def __init__( + self, + *, + state: SailboxSandboxSessionState, + sailbox: Sailbox | None = None, + ) -> None: + self.state = state + self._sailbox = sailbox + + @classmethod + def from_state( + cls, + state: SailboxSandboxSessionState, + *, + sailbox: Sailbox | None = None, + ) -> SailboxSandboxSession: + return cls(state=state, sailbox=sailbox) + + @property + def sailbox(self) -> Sailbox: + if self._sailbox is None: + raise RuntimeError("sailbox session has not been started") + return self._sailbox + + def _set_sailbox(self, sailbox: Sailbox) -> None: + self._sailbox = sailbox + self.state.sailbox_id = sailbox.sailbox_id + self.state.sailbox_name = sailbox.name + self.state.status = sailbox.status + self.state.worker_address = sailbox.worker_address + self.state.exec_endpoint = sailbox.exec_endpoint + + async def _ensure_backend_started(self) -> None: + if self._sailbox is not None: + if self._sailbox.status != "running": + await _call_sailbox(self._sailbox.resume) + self._set_sailbox(self._sailbox) + return + + if not self.state.sailbox_id: + raise WorkspaceStartError( + path=self._workspace_root_path(), + context={"reason": "missing_sailbox_id"}, + ) + + try: + sailbox = await _call_sailbox(_connect_sailbox, self.state.sailbox_id) + except Exception as exc: + raise WorkspaceStartError( + path=self._workspace_root_path(), + context=_sailbox_error_context( + cause=exc, + extra={ + "reason": "connect_failed", + "sailbox_id": self.state.sailbox_id, + }, + ), + cause=exc, + message=_sailbox_error_message("Sailbox connect failed", exc), + ) from exc + self._set_sailbox(sailbox) + self._set_start_state_preserved(True) + + async def _prepare_backend_workspace(self) -> None: + root = self.state.manifest.root + try: + request = await _call_sailbox( + self.sailbox.exec, + f"mkdir -p {shlex.quote(root)}", + ) + result = await _call_sailbox(request.wait) + except Exception as exc: + raise WorkspaceStartError( + path=self._workspace_root_path(), + context=_sailbox_error_context( + cause=exc, + extra={"reason": "mkdir_failed"}, + ), + cause=exc, + message=_sailbox_error_message("Sailbox workspace root preparation failed", exc), + ) from exc + if result.returncode != 0: + stdout = ( + result.stdout + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8", errors="replace") + ) + stderr = ( + result.stderr + if isinstance(result.stderr, str) + else result.stderr.decode("utf-8", errors="replace") + ) + raise WorkspaceStartError( + path=self._workspace_root_path(), + context={ + "exit_code": result.returncode, + "stdout": stdout, + "stderr": stderr, + }, + ) + + async def _shutdown_backend(self) -> None: + sailbox = self._sailbox + if sailbox is None: + return + if self.state.pause_on_exit: + await _call_sailbox(sailbox.pause) + self.state.status = "paused" + self.state.worker_address = "" + return + await _call_sailbox(sailbox.terminate) + self.state.status = "terminated" + self.state.worker_address = "" + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + command_tuple = tuple(str(part) for part in command) + shell_command = await self._shell_command(command_tuple) + try: + request = await _call_sailbox( + self.sailbox.exec, + shell_command, + timeout=_coerce_timeout(timeout), + ) + result = await _call_sailbox(request.wait) + except Exception as exc: + raise ExecTransportError( + command=command_tuple, + context=_sailbox_error_context( + cause=exc, + extra={"sailbox_id": self.state.sailbox_id}, + ), + cause=exc, + message=_sailbox_error_message("Sailbox exec failed", exc), + ) from exc + + return ExecResult( + stdout=result.stdout.encode("utf-8", errors="replace"), + stderr=result.stderr.encode("utf-8", errors="replace"), + exit_code=result.returncode, + ) + + async def _shell_command(self, command: tuple[str, ...]) -> str: + env = await self.state.manifest.environment.resolve() + parts = ["cd", shlex.quote(self.state.manifest.root), "&&"] + if env: + parts.append("env") + parts.extend(f"{key}={shlex.quote(value)}" for key, value in sorted(env.items())) + parts.append(shlex.join(command)) + return " ".join(parts) + + async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: + try: + listener = await _call_sailbox(self.sailbox.listener, port) + except Exception as exc: + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="backend_unavailable", + context=_sailbox_error_context( + cause=exc, + extra={"detail": "listener_lookup_failed"}, + ), + cause=exc, + ) from exc + + parsed = urlsplit(listener.url) + if not parsed.hostname: + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="backend_unavailable", + context={ + "backend": "sailbox", + "detail": "invalid_listener_url", + "url": listener.url, + }, + ) + tls = parsed.scheme in {"https", "wss"} + endpoint_port = parsed.port or (443 if tls else 80) + return ExposedPortEndpoint( + host=parsed.hostname, + port=endpoint_port, + tls=tls, + query=parsed.query, + ) + + async def _validate_path_access( + self, + path: Path | str, + *, + for_write: bool = False, + ) -> Path: + return await self._validate_remote_path_access(path, for_write=for_write) + + def _runtime_helpers(self) -> tuple[Any, ...]: + return (RESOLVE_WORKSPACE_PATH_HELPER,) + + def _current_runtime_helper_cache_key(self) -> str | None: + return self.state.sailbox_id or None + + async def read( + self, + path: Path | str, + *, + user: str | User | None = None, + ) -> io.IOBase: + if user is not None: + await self._check_read_with_exec(path, user=user) + + error_path = Path(path) + workspace_path = await self._validate_path_access(path) + try: + data = await _call_sailbox( + self.sailbox.read, + sandbox_path_str(workspace_path), + ) + except FileNotFoundError as exc: + raise WorkspaceReadNotFoundError(path=error_path, cause=exc) from exc + except Exception as exc: + raise WorkspaceArchiveReadError(path=error_path, cause=exc) from exc + return io.BytesIO(data) + + async def write( + self, + path: Path | str, + data: io.IOBase, + *, + user: str | User | None = None, + ) -> None: + if user is not None: + await self._check_write_with_exec(path, user=user) + + payload = data.read() + if isinstance(payload, str): + payload = payload.encode("utf-8") + if not isinstance(payload, bytes | bytearray): + raise WorkspaceArchiveWriteError( + path=Path(path), + context={ + "reason": "invalid_write_payload", + "type": type(payload).__name__, + }, + ) + + workspace_path = await self._validate_path_access(path, for_write=True) + try: + await _call_sailbox( + self.sailbox.write, + sandbox_path_str(workspace_path), + bytes(payload), + ) + except Exception as exc: + raise WorkspaceArchiveWriteError(path=workspace_path, cause=exc) from exc + + async def running(self) -> bool: + return self._sailbox is not None and self.state.status == "running" + + async def persist_workspace(self) -> io.IOBase: + root = self.state.manifest.root + archive_path = f"/tmp/openai-agents-{self.state.session_id.hex}.tar" + excludes = " ".join( + "--exclude=" + shlex.quote(f"./{path.as_posix()}") + for path in sorted( + self._persist_workspace_skip_relpaths(), + key=lambda item: item.as_posix(), + ) + ) + command = f"cd {shlex.quote(root)} && tar cf {shlex.quote(archive_path)} {excludes} ." + try: + result = await self.exec(command) + if not result.ok(): + raise WorkspaceArchiveReadError( + path=self._workspace_root_path(), + context={ + "exit_code": result.exit_code, + "stdout": result.stdout.decode("utf-8", errors="replace"), + "stderr": result.stderr.decode("utf-8", errors="replace"), + }, + ) + data = await _call_sailbox(self.sailbox.read, archive_path) + return io.BytesIO(data) + except WorkspaceArchiveReadError: + raise + except Exception as exc: + raise WorkspaceArchiveReadError( + path=self._workspace_root_path(), + cause=exc, + ) from exc + finally: + try: + await self.exec("rm", "-f", archive_path, shell=False) + except Exception: + pass + + async def hydrate_workspace(self, data: io.IOBase) -> None: + raw = data.read() + if isinstance(raw, str): + raw = raw.encode("utf-8") + if not isinstance(raw, bytes | bytearray): + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + context={ + "reason": "invalid_archive_payload", + "type": type(raw).__name__, + }, + ) + try: + validate_tar_bytes(bytes(raw), allow_external_symlink_targets=False) + except UnsafeTarMemberError as exc: + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + context={"reason": exc.reason, "member": exc.member}, + cause=exc, + ) from exc + + root = self.state.manifest.root + archive_path = f"/tmp/openai-agents-{self.state.session_id.hex}.tar" + try: + await _call_sailbox(self.sailbox.write, archive_path, bytes(raw)) + mkdir = await self.exec("mkdir", "-p", root, shell=False) + if not mkdir.ok(): + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + context={ + "exit_code": mkdir.exit_code, + "stdout": mkdir.stdout.decode("utf-8", errors="replace"), + "stderr": mkdir.stderr.decode("utf-8", errors="replace"), + }, + ) + result = await self.exec( + "tar", + "xf", + archive_path, + "-C", + root, + shell=False, + ) + if not result.ok(): + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + context={ + "exit_code": result.exit_code, + "stdout": result.stdout.decode("utf-8", errors="replace"), + "stderr": result.stderr.decode("utf-8", errors="replace"), + }, + ) + except WorkspaceArchiveWriteError: + raise + except Exception as exc: + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + cause=exc, + ) from exc + finally: + try: + await self.exec("rm", "-f", archive_path, shell=False) + except Exception: + pass + + +class SailboxSandboxClient(BaseSandboxClient[SailboxSandboxClientOptions | None]): + """OpenAI Agents SDK sandbox client that creates and resumes Sailboxes.""" + + backend_id = "sailbox" + supports_default_options = True + + def __init__( + self, + *, + app: App | None = None, + app_name: str | None = _DEFAULT_APP_NAME, + image: ImageDefinition | None = None, + name_prefix: str = _DEFAULT_NAME_PREFIX, + image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT, + memory_mib: int = _DEFAULT_MEMORY_MIB, + cpu: int = _DEFAULT_CPU, + disk_gib: int = _DEFAULT_DISK_GIB, + pause_on_exit: bool = False, + instrumentation: Instrumentation | None = None, + dependencies: Dependencies | None = None, + ) -> None: + self._app = app + self._app_name = app_name + self._image = image + self._name_prefix = name_prefix + self._image_build_timeout = image_build_timeout + self._memory_mib = memory_mib + self._cpu = cpu + self._disk_gib = disk_gib + self._pause_on_exit = pause_on_exit + self._instrumentation = instrumentation or Instrumentation() + self._dependencies = dependencies + + async def create( + self, + *, + snapshot: SnapshotSpec | SnapshotBase | None = None, + manifest: Manifest | None = None, + options: SailboxSandboxClientOptions | None = None, + ) -> SandboxSession: + resolved_options = self._resolve_options(options) + resolved_manifest = manifest or Manifest() + session_id = uuid.uuid4() + snapshot_instance = resolve_snapshot(snapshot, str(session_id)) + sailbox = await self._create_sailbox(session_id, resolved_options) + state = SailboxSandboxSessionState( + session_id=session_id, + manifest=resolved_manifest, + snapshot=snapshot_instance, + sailbox_id=sailbox.sailbox_id, + sailbox_name=sailbox.name, + app_name=resolved_options.app_name, + exec_endpoint=sailbox.exec_endpoint, + worker_address=sailbox.worker_address, + status=sailbox.status, + image_build_timeout=resolved_options.image_build_timeout, + memory_mib=resolved_options.memory_mib, + cpu=resolved_options.cpu, + disk_gib=resolved_options.disk_gib, + exposed_ports=resolved_options.exposed_ports, + pause_on_exit=resolved_options.pause_on_exit, + ) + inner = SailboxSandboxSession.from_state(state, sailbox=sailbox) + return self._wrap_session(inner, instrumentation=self._instrumentation) + + async def delete(self, session: SandboxSession) -> SandboxSession: + inner = session._inner + if not isinstance(inner, SailboxSandboxSession): + raise TypeError("SailboxSandboxClient.delete expects a SailboxSandboxSession") + # The OpenAI Agents cleanup lifecycle calls session.shutdown() before + # delete(). Sailbox shutdown already pauses or terminates the backend. + return session + + async def resume(self, state: SandboxSessionState) -> SandboxSession: + if not isinstance(state, SailboxSandboxSessionState): + raise TypeError("SailboxSandboxClient.resume expects a SailboxSandboxSessionState") + + try: + sailbox = await _call_sailbox(_connect_sailbox, state.sailbox_id) + state.sailbox_id = sailbox.sailbox_id + state.sailbox_name = sailbox.name + state.status = sailbox.status + state.worker_address = sailbox.worker_address + state.exec_endpoint = sailbox.exec_endpoint + inner = SailboxSandboxSession.from_state(state, sailbox=sailbox) + inner._set_start_state_preserved(True) + return self._wrap_session(inner, instrumentation=self._instrumentation) + except Exception: + state.workspace_root_ready = False + + options = self._resolve_options( + SailboxSandboxClientOptions( + app_name=state.app_name or self._app_name, + image_build_timeout=state.image_build_timeout, + memory_mib=state.memory_mib, + cpu=state.cpu, + disk_gib=state.disk_gib, + exposed_ports=state.exposed_ports, + pause_on_exit=state.pause_on_exit, + ) + ) + sailbox = await self._create_sailbox(state.session_id, options) + state.sailbox_id = sailbox.sailbox_id + state.sailbox_name = sailbox.name + state.status = sailbox.status + state.worker_address = sailbox.worker_address + state.exec_endpoint = sailbox.exec_endpoint + inner = SailboxSandboxSession.from_state(state, sailbox=sailbox) + return self._wrap_session(inner, instrumentation=self._instrumentation) + + def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: + return SailboxSandboxSessionState.model_validate(payload) + + def _resolve_options( + self, + options: SailboxSandboxClientOptions | None, + ) -> SailboxSandboxClientOptions: + if options is None: + return SailboxSandboxClientOptions( + app=self._app, + app_name=self._app_name, + image=self._image or Image.debian_arm64, + name_prefix=self._name_prefix, + image_build_timeout=self._image_build_timeout, + memory_mib=self._memory_mib, + cpu=self._cpu, + disk_gib=self._disk_gib, + pause_on_exit=self._pause_on_exit, + ) + return SailboxSandboxClientOptions( + app=options.app or self._app, + app_name=(options.app_name if options.app_name is not None else self._app_name), + image=options.image or self._image or Image.debian_arm64, + name_prefix=options.name_prefix or self._name_prefix, + image_build_timeout=options.image_build_timeout, + memory_mib=options.memory_mib, + cpu=options.cpu, + disk_gib=options.disk_gib, + exposed_ports=options.exposed_ports, + pause_on_exit=options.pause_on_exit, + ) + + async def _resolve_app(self, options: SailboxSandboxClientOptions) -> App: + if options.app is not None: + return options.app + if not options.app_name: + raise ValueError("SailboxSandboxClientOptions requires app or app_name") + return await _call_sailbox( + App.find, + name=options.app_name, + mint_if_missing=True, + ) + + async def _create_sailbox( + self, + session_id: uuid.UUID, + options: SailboxSandboxClientOptions, + ) -> Sailbox: + app = await self._resolve_app(options) + image = options.image or self._image or Image.debian_arm64 + name = f"{options.name_prefix}-{session_id.hex[:12]}" + try: + return await _call_sailbox( + Sailbox.create, + image=image, + app=app, + name=name, + image_build_timeout=options.image_build_timeout, + memory_mib=options.memory_mib, + cpu=options.cpu, + ingress_ports=list(options.exposed_ports), + disk_gib=options.disk_gib, + ) + except Exception as exc: + raise WorkspaceStartError( + path=Path(options.name_prefix), + context=_sailbox_error_context( + cause=exc, + extra={"reason": "create_sailbox_failed"}, + ), + cause=exc, + message=_sailbox_error_message("Sailbox create failed", exc), + ) from exc + + +def _coerce_timeout(timeout: float | None) -> int | None: + if timeout is None: + return None + if timeout <= 0: + return 1 + return int(math.ceil(timeout)) + + +def _connect_sailbox(sailbox_id: str) -> Sailbox: + connect = getattr(Sailbox, "connect", None) + if callable(connect): + return cast(Sailbox, connect(sailbox_id)) + return Sailbox( + sailbox_id=sailbox_id, + name=sailbox_id, + status="paused", + worker_address="", + exec_endpoint="", + ).resume() + + +__all__ = [ + "SailboxSandboxClient", + "SailboxSandboxClientOptions", + "SailboxSandboxSession", + "SailboxSandboxSessionState", +] diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py new file mode 100644 index 0000000000..bdeb865db7 --- /dev/null +++ b/tests/extensions/sandbox/test_sailbox.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import asyncio +import io +import json +import sys +import tarfile +import time +import types +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +import pytest + +pytest.importorskip("agents.sandbox") + +from agents.sandbox.entries import File +from agents.sandbox.errors import ( + ExecTransportError, + ExposedPortUnavailableError, + WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, + WorkspaceStartError, +) +from agents.sandbox.manifest import Manifest +from agents.sandbox.session import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot + + +@dataclass +class App: + id: str + name: str + created_at: int + + @staticmethod + def find(*, name: str, mint_if_missing: bool = False) -> App: + _ = mint_if_missing + return App(id=f"app_{name}", name=name, created_at=0) + + +class Image: + debian_amd64 = object() + debian_arm64 = object() + + +class _SdkSailbox: + @staticmethod + def create(**kwargs: object) -> _SdkSailbox: + _ = kwargs + raise NotImplementedError + + @staticmethod + def connect(sailbox_id: str) -> _SdkSailbox: + _ = sailbox_id + raise NotImplementedError + + +def _install_fake_sail_sdk() -> None: + sail_module = types.ModuleType("sail") + app_module = types.ModuleType("sail.app") + image_module = types.ModuleType("sail.image") + sailbox_module = types.ModuleType("sail.sailbox") + + cast(Any, app_module).App = App + cast(Any, image_module).Image = Image + cast(Any, image_module).ImageDefinition = object + cast(Any, sailbox_module).Sailbox = _SdkSailbox + + sys.modules.setdefault("sail", sail_module) + sys.modules["sail.app"] = app_module + sys.modules["sail.image"] = image_module + sys.modules["sail.sailbox"] = sailbox_module + + +_install_fake_sail_sdk() + + +from agents.extensions.sandbox.sailbox.sandbox import ( # noqa: E402 + SailboxSandboxClient, + SailboxSandboxClientOptions, + SailboxSandboxSession, + SailboxSandboxSessionState, +) + + +def test_sailbox_package_re_exports_backend_symbols() -> None: + package_module = __import__( + "agents.extensions.sandbox.sailbox", + fromlist=["SailboxSandboxClient"], + ) + + assert package_module.SailboxSandboxClient is SailboxSandboxClient + + +@dataclass +class _FakeExecResult: + stdout: str = "" + stderr: str = "" + returncode: int = 0 + + +class _FakeExecRequest: + def __init__(self, result: _FakeExecResult) -> None: + self._result = result + + def wait(self) -> _FakeExecResult: + return self._result + + +class _BlockingExecRequest: + def __init__(self, sailbox: _BlockingFakeSailbox) -> None: + self._sailbox = sailbox + + def wait(self) -> _FakeExecResult: + time.sleep(0.02) + self._sailbox.active_execs -= 1 + return _FakeExecResult(stdout="ok\n", returncode=0) + + +class _FakeListener: + url = "https://listener.example.test/route?token=abc" + + +class _FakeSailbox: + def __init__(self, sailbox_id: str = "sb-test") -> None: + self.sailbox_id = sailbox_id + self.name = "agent-sailbox" + self.status = "running" + self.worker_address = "worker.internal:50051" + self.exec_endpoint = "worker.proxy:443" + self.exec_commands: list[tuple[str, int | None]] = [] + self.files: dict[str, bytes] = {} + self.terminated = False + self.paused = False + + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + return _FakeExecRequest(_FakeExecResult(stdout="ok\n", returncode=0)) + + def read(self, path: str) -> bytes: + try: + return self.files[path] + except KeyError as exc: + raise FileNotFoundError(path) from exc + + def write(self, path: str, data: bytes) -> None: + self.files[path] = bytes(data) + + def listener(self, port: int) -> _FakeListener: + assert port == 8080 + return _FakeListener() + + def pause(self) -> None: + self.paused = True + self.status = "paused" + + def terminate(self) -> None: + self.terminated = True + self.status = "terminated" + + +class _FailingExecSailbox(_FakeSailbox): + def exec(self, command: str, *, timeout: int | None = None) -> Any: + _ = (command, timeout) + raise RuntimeError("worker unavailable") + + +class _BlockingFakeSailbox(_FakeSailbox): + def __init__(self) -> None: + super().__init__() + self.active_execs = 0 + self.max_active_execs = 0 + + def exec(self, command: str, *, timeout: int | None = None) -> _BlockingExecRequest: + self.exec_commands.append((command, timeout)) + self.active_execs += 1 + self.max_active_execs = max(self.max_active_execs, self.active_execs) + return _BlockingExecRequest(self) + + +def _state(sailbox: _FakeSailbox) -> SailboxSandboxSessionState: + return SailboxSandboxSessionState( + session_id=uuid.UUID("00000000-0000-4000-8000-000000000001"), + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="test"), + sailbox_id=sailbox.sailbox_id, + sailbox_name=sailbox.name, + exec_endpoint=sailbox.exec_endpoint, + worker_address=sailbox.worker_address, + status=sailbox.status, + exposed_ports=(8080,), + ) + + +def test_client_create_creates_sailbox(monkeypatch: pytest.MonkeyPatch) -> None: + created: list[dict[str, object]] = [] + fake_sailbox = _FakeSailbox("sb-created") + + def fake_create(**kwargs: object) -> _FakeSailbox: + created.append(kwargs) + return fake_sailbox + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(fake_create), + ) + + app = App(id="app_test", name="agents", created_at=1) + options = SailboxSandboxClientOptions( + app=app, + image=Image.debian_amd64, + exposed_ports=(8080,), + pause_on_exit=True, + ) + client = SailboxSandboxClient() + + session = asyncio.run( + client.create(snapshot=NoopSnapshot(id="test"), manifest=Manifest(), options=options) + ) + + inner = session._inner + assert isinstance(inner, SailboxSandboxSession) + assert inner.state.sailbox_id == "sb-created" + assert inner.state.exposed_ports == (8080,) + assert inner.state.pause_on_exit is True + assert created[0]["app"] == app + assert created[0]["ingress_ports"] == [8080] + + +def test_session_exec_file_io_and_ports(monkeypatch: pytest.MonkeyPatch) -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + + async def fake_validate( + self: SailboxSandboxSession, + path: Path | str, + *, + for_write: bool = False, + ) -> Path: + _ = (self, for_write) + return Path("/workspace") / Path(path) + + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", fake_validate) + + result = asyncio.run(session.exec("printf ok", timeout=1.2)) + assert result.stdout == b"ok\n" + assert fake_sailbox.exec_commands[-1] == ( + "cd /workspace && sh -lc 'printf ok'", + 2, + ) + + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"))) + assert fake_sailbox.files["/workspace/notes.txt"] == b"hello" + assert asyncio.run(session.read(Path("notes.txt"))).read() == b"hello" + + endpoint = asyncio.run(session.resolve_exposed_port(8080)) + assert endpoint.host == "listener.example.test" + assert endpoint.port == 443 + assert endpoint.tls is True + assert endpoint.query == "token=abc" + + +def test_session_state_json_roundtrip_preserves_sailbox_fields() -> None: + state = _state(_FakeSailbox("sb-roundtrip")) + payload = json.loads(state.model_dump_json()) + + reconstructed = SandboxSessionState.parse(payload) + + assert isinstance(reconstructed, SailboxSandboxSessionState) + assert reconstructed.sailbox_id == "sb-roundtrip" + assert reconstructed.exec_endpoint == "worker.proxy:443" + assert reconstructed.exposed_ports == (8080,) + + +def test_options_json_dump_serializes_sdk_objects() -> None: + options = SailboxSandboxClientOptions( + app=App(id="app_test", name="agents", created_at=1), + image=Image.debian_amd64, + exposed_ports=(8080,), + ) + + dumped = options.model_dump(mode="json") + + assert dumped["type"] == "sailbox" + assert dumped["app"] == "app_test" + assert dumped["image"] is None + assert dumped["exposed_ports"] == [8080] + + +def test_prepare_backend_workspace_bootstraps_root_without_cd() -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + + asyncio.run(session._prepare_backend_workspace()) + + assert fake_sailbox.exec_commands[-1] == ("mkdir -p /workspace", None) + + +def test_prepare_backend_workspace_quotes_literal_root() -> None: + fake_sailbox = _FakeSailbox() + state = _state(fake_sailbox) + state.manifest = Manifest(root="/workspace/my app") + session = SailboxSandboxSession.from_state( + state, + sailbox=fake_sailbox, + ) + + asyncio.run(session._prepare_backend_workspace()) + + assert fake_sailbox.exec_commands[-1] == ("mkdir -p '/workspace/my app'", None) + + +def test_exec_calls_can_overlap() -> None: + fake_sailbox = _BlockingFakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + + async def run_two_execs() -> None: + await asyncio.gather( + session.exec("printf one"), + session.exec("printf two"), + ) + + asyncio.run(run_two_execs()) + + assert fake_sailbox.max_active_execs == 2 + assert len(fake_sailbox.exec_commands) == 2 + + +def test_exec_transport_error_includes_provider_error() -> None: + fake_sailbox = _FailingExecSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + + with pytest.raises(ExecTransportError) as exc_info: + asyncio.run(session.exec("printf ok")) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["provider_error"] == "RuntimeError: worker unavailable" + assert "Sailbox exec failed: RuntimeError: worker unavailable" in str(exc_info.value) + + +def test_hydrate_workspace_rejects_unsafe_tar_members() -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w") as archive: + info = tarfile.TarInfo("../escape.txt") + payload = b"unsafe" + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + raw.seek(0) + + with pytest.raises(WorkspaceArchiveWriteError): + asyncio.run(session.hydrate_workspace(raw)) + + assert fake_sailbox.files == {} + assert fake_sailbox.exec_commands == [] + + +def test_hydrate_workspace_rejects_external_symlink_before_upload() -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w") as archive: + info = tarfile.TarInfo("leak") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + archive.addfile(info) + raw.seek(0) + + with pytest.raises(WorkspaceArchiveWriteError): + asyncio.run(session.hydrate_workspace(raw)) + + assert fake_sailbox.files == {} + assert fake_sailbox.exec_commands == [] + + +def test_hydrate_workspace_uploads_extracts_and_cleans_archive() -> None: + fake_sailbox = _FakeSailbox() + state = _state(fake_sailbox) + session = SailboxSandboxSession.from_state(state, sailbox=fake_sailbox) + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w") as archive: + info = tarfile.TarInfo("README.md") + payload = b"hello" + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + raw.seek(0) + + asyncio.run(session.hydrate_workspace(raw)) + + archive_path = f"/tmp/openai-agents-{state.session_id.hex}.tar" + assert fake_sailbox.files[archive_path] == raw.getvalue() + assert fake_sailbox.exec_commands == [ + ("cd /workspace && mkdir -p /workspace", None), + ( + f"cd /workspace && tar xf {archive_path} -C /workspace", + None, + ), + (f"cd /workspace && rm -f {archive_path}", None), + ] + + +def test_persist_workspace_tars_excluding_ephemeral_paths_and_cleans_up() -> None: + fake_sailbox = _FakeSailbox() + state = _state(fake_sailbox) + state.manifest = Manifest( + root="/workspace", + entries={"tmp.txt": File(content=b"tmp", ephemeral=True)}, + ) + archive_path = f"/tmp/openai-agents-{state.session_id.hex}.tar" + fake_sailbox.files[archive_path] = b"archive" + session = SailboxSandboxSession.from_state(state, sailbox=fake_sailbox) + + archive = asyncio.run(session.persist_workspace()) + + assert archive.read() == b"archive" + assert "--exclude=./tmp.txt" in fake_sailbox.exec_commands[0][0] + assert f"tar cf {archive_path}" in fake_sailbox.exec_commands[0][0] + assert fake_sailbox.exec_commands[-1] == ( + f"cd /workspace && rm -f {archive_path}", + None, + ) + + +def test_read_missing_file_maps_to_openai_workspace_error() -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + + with pytest.raises(WorkspaceReadNotFoundError): + asyncio.run(session.read(Path("missing.txt"))) + + +def test_invalid_listener_url_maps_to_openai_exposed_port_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_sailbox = _FakeSailbox() + session = SailboxSandboxSession.from_state( + _state(fake_sailbox), + sailbox=fake_sailbox, + ) + monkeypatch.setattr(_FakeListener, "url", "not-a-valid-listener-url") + + with pytest.raises(ExposedPortUnavailableError): + asyncio.run(session.resolve_exposed_port(8080)) + + +def test_client_resume_reconnects_existing_sailbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_sailbox = _FakeSailbox("sb-existing") + + def fake_connect(sailbox_id: str) -> _FakeSailbox: + assert sailbox_id == "sb-existing" + return fake_sailbox + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", + fake_connect, + ) + + client = SailboxSandboxClient() + session = asyncio.run(client.resume(_state(fake_sailbox))) + inner = session._inner + + assert isinstance(inner, SailboxSandboxSession) + assert inner.state.sailbox_id == "sb-existing" + assert inner._workspace_state_preserved_on_start() is True + + +def test_client_resume_recreates_sailbox_when_reconnect_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created: list[dict[str, object]] = [] + replacement = _FakeSailbox("sb-recreated") + + def fake_connect(_sailbox_id: str) -> _FakeSailbox: + raise LookupError("missing") + + def fake_create(**kwargs: object) -> _FakeSailbox: + created.append(kwargs) + return replacement + + monkeypatch.setattr("agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", fake_connect) + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(fake_create), + ) + + app = App(id="app_test", name="agents", created_at=1) + state = _state(_FakeSailbox("sb-missing")) + state.app_name = "agents" + state.workspace_root_ready = True + client = SailboxSandboxClient(app=app) + + session = asyncio.run(client.resume(state)) + inner = session._inner + + assert isinstance(inner, SailboxSandboxSession) + assert inner.state.sailbox_id == "sb-recreated" + assert inner.state.workspace_root_ready is False + assert inner._workspace_state_preserved_on_start() is False + assert created[0]["app"] == app + + +def test_client_create_failure_includes_provider_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_create(**kwargs: object) -> _FakeSailbox: + _ = kwargs + raise RuntimeError("quota exceeded") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(fake_create), + ) + + app = App(id="app_test", name="agents", created_at=1) + client = SailboxSandboxClient(app=app) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(client.create(options=SailboxSandboxClientOptions())) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["provider_error"] == "RuntimeError: quota exceeded" + assert "Sailbox create failed: RuntimeError: quota exceeded" in str(exc_info.value) + + +def test_shutdown_pauses_or_terminates_sailbox() -> None: + paused_sailbox = _FakeSailbox("sb-paused") + paused_state = _state(paused_sailbox) + paused_state.pause_on_exit = True + paused_session = SailboxSandboxSession.from_state( + paused_state, + sailbox=paused_sailbox, + ) + asyncio.run(paused_session.shutdown()) + assert paused_sailbox.paused is True + assert paused_sailbox.terminated is False + assert paused_session.state.status == "paused" + + terminated_sailbox = _FakeSailbox("sb-terminated") + terminated_session = SailboxSandboxSession.from_state( + _state(terminated_sailbox), + sailbox=terminated_sailbox, + ) + asyncio.run(terminated_session.shutdown()) + assert terminated_sailbox.terminated is True + assert terminated_session.state.status == "terminated" diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 5a11e5bf77..ca04361909 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -324,6 +324,15 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "_encode_runloop_snapshot_ref", }, ), + ( + "agents.extensions.sandbox.sailbox", + { + "SailboxSandboxClient", + "SailboxSandboxClientOptions", + "SailboxSandboxSession", + "SailboxSandboxSessionState", + }, + ), ( "agents.extensions.sandbox.vercel", { @@ -493,6 +502,22 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( "managed_secrets", ), ), + ( + "agents.extensions.sandbox.sailbox", + "SailboxSandboxClientOptions", + ( + "app", + "app_name", + "image", + "name_prefix", + "image_build_timeout", + "memory_mib", + "cpu", + "disk_gib", + "exposed_ports", + "pause_on_exit", + ), + ), ( "agents.extensions.sandbox.vercel", "VercelSandboxClientOptions", @@ -720,6 +745,31 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "secret_refs", ), ), + ( + "agents.extensions.sandbox.sailbox", + "SailboxSandboxSessionState", + ( + "type", + "session_id", + "snapshot", + "manifest", + "exposed_ports", + "snapshot_fingerprint", + "snapshot_fingerprint_version", + "workspace_root_ready", + "sailbox_id", + "sailbox_name", + "app_name", + "exec_endpoint", + "worker_address", + "status", + "image_build_timeout", + "memory_mib", + "cpu", + "disk_gib", + "pause_on_exit", + ), + ), ( "agents.extensions.sandbox.vercel", "VercelSandboxSessionState", @@ -785,6 +835,7 @@ def test_sandbox_session_state_field_order_is_stable( ), ("agents.extensions.sandbox.daytona", "DaytonaSandboxClientOptions", (), "daytona"), ("agents.extensions.sandbox.runloop", "RunloopSandboxClientOptions", (), "runloop"), + ("agents.extensions.sandbox.sailbox", "SailboxSandboxClientOptions", (), "sailbox"), ("agents.extensions.sandbox.vercel", "VercelSandboxClientOptions", (), "vercel"), ], ) @@ -846,6 +897,11 @@ def test_optional_sandbox_client_options_json_round_trip_preserves_type( "RunloopSandboxSessionState", {"devbox_id": "devbox-123"}, ), + ( + "agents.extensions.sandbox.sailbox", + "SailboxSandboxSessionState", + {"sailbox_id": "sb_123"}, + ), ( "agents.extensions.sandbox.vercel", "VercelSandboxSessionState", diff --git a/uv.lock b/uv.lock index 03d2dd0903..4b399095d1 100644 --- a/uv.lock +++ b/uv.lock @@ -584,6 +584,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1289,77 +1298,77 @@ wheels = [ [[package]] name = "grpcio" -version = "1.76.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/17/ff4795dc9a34b6aee6ec379f1b66438a3789cd1315aac0cbab60d92f74b3/grpcio-1.76.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:65a20de41e85648e00305c1bb09a3598f840422e522277641145a32d42dcefcc", size = 5840037, upload-time = "2025-10-21T16:20:25.069Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ff/35f9b96e3fa2f12e1dcd58a4513a2e2294a001d64dec81677361b7040c9a/grpcio-1.76.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:40ad3afe81676fd9ec6d9d406eda00933f218038433980aa19d401490e46ecde", size = 11836482, upload-time = "2025-10-21T16:20:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1c/8374990f9545e99462caacea5413ed783014b3b66ace49e35c533f07507b/grpcio-1.76.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:035d90bc79eaa4bed83f524331d55e35820725c9fbb00ffa1904d5550ed7ede3", size = 6407178, upload-time = "2025-10-21T16:20:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/36fd7d7c75a6c12542c90a6d647a27935a1ecaad03e0ffdb7c42db6b04d2/grpcio-1.76.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4215d3a102bd95e2e11b5395c78562967959824156af11fa93d18fdd18050990", size = 7075684, upload-time = "2025-10-21T16:20:35.435Z" }, - { url = "https://files.pythonhosted.org/packages/38/f7/e3cdb252492278e004722306c5a8935eae91e64ea11f0af3437a7de2e2b7/grpcio-1.76.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49ce47231818806067aea3324d4bf13825b658ad662d3b25fada0bdad9b8a6af", size = 6611133, upload-time = "2025-10-21T16:20:37.541Z" }, - { url = "https://files.pythonhosted.org/packages/7e/20/340db7af162ccd20a0893b5f3c4a5d676af7b71105517e62279b5b61d95a/grpcio-1.76.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8cc3309d8e08fd79089e13ed4819d0af72aa935dd8f435a195fd152796752ff2", size = 7195507, upload-time = "2025-10-21T16:20:39.643Z" }, - { url = "https://files.pythonhosted.org/packages/10/f0/b2160addc1487bd8fa4810857a27132fb4ce35c1b330c2f3ac45d697b106/grpcio-1.76.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:971fd5a1d6e62e00d945423a567e42eb1fa678ba89072832185ca836a94daaa6", size = 8160651, upload-time = "2025-10-21T16:20:42.492Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2c/ac6f98aa113c6ef111b3f347854e99ebb7fb9d8f7bb3af1491d438f62af4/grpcio-1.76.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d9adda641db7207e800a7f089068f6f645959f2df27e870ee81d44701dd9db3", size = 7620568, upload-time = "2025-10-21T16:20:45.995Z" }, - { url = "https://files.pythonhosted.org/packages/90/84/7852f7e087285e3ac17a2703bc4129fafee52d77c6c82af97d905566857e/grpcio-1.76.0-cp310-cp310-win32.whl", hash = "sha256:063065249d9e7e0782d03d2bca50787f53bd0fb89a67de9a7b521c4a01f1989b", size = 3998879, upload-time = "2025-10-21T16:20:48.592Z" }, - { url = "https://files.pythonhosted.org/packages/10/30/d3d2adcbb6dd3ff59d6ac3df6ef830e02b437fb5c90990429fd180e52f30/grpcio-1.76.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6ae758eb08088d36812dd5d9af7a9859c05b1e0f714470ea243694b49278e7b", size = 4706892, upload-time = "2025-10-21T16:20:50.697Z" }, - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] name = "grpcio-status" -version = "1.67.1" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/c7/fe0e79a80ac6346e0c6c0a24e9e3cbc3ae1c2a009acffb59eab484a6f69b/grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11", size = 13673, upload-time = "2024-10-29T06:30:21.787Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/18/56999a1da3577d8ccc8698a575d6638e15fe25650cc88b2ce0a087f180b9/grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd", size = 14427, upload-time = "2024-10-29T06:27:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, ] [[package]] @@ -2494,6 +2503,9 @@ runloop = [ s3 = [ { name = "boto3" }, ] +sailbox = [ + { name = "sail-sdk" }, +] sqlalchemy = [ { name = "asyncpg" }, { name = "sqlalchemy" }, @@ -2575,6 +2587,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "runloop-api-client", marker = "extra == 'runloop'", specifier = ">=1.16.0,<2.0.0" }, + { name = "sail-sdk", marker = "extra == 'sailbox'", specifier = ">=0.1.24" }, { name = "sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=2.0" }, { name = "temporalio", marker = "extra == 'temporal'", specifier = "==1.26.0" }, { name = "textual", marker = "extra == 'temporal'", specifier = ">=8.2.3,<8.3" }, @@ -2585,7 +2598,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<17" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<17" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "sailbox", "s3", "temporal"] [package.metadata.requires-dev] dev = [ @@ -2910,16 +2923,17 @@ wheels = [ [[package]] name = "protobuf" -version = "5.29.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" }, - { url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" }, - { url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -3814,6 +3828,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] +[[package]] +name = "sail-sdk" +version = "0.1.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "grpcio" }, + { name = "pathspec" }, + { name = "protobuf" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/97/2334c4669e2cd29fb65183d784c5a011cc3ca9cfaac37a7f4923a0645db4/sail_sdk-0.1.24.tar.gz", hash = "sha256:1b38fd5ea2c302ab6ab7e99e37596ebb1682d3f544338f73cf4fb9fd0d6d160c", size = 142463, upload-time = "2026-05-17T06:18:18.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/0d/e4aa622f02d8e4244d32afe27289a6002d4b00bdf239436c73cb73177296/sail_sdk-0.1.24-py3-none-any.whl", hash = "sha256:1395c0c937cfcb77eda7029f26f0931a863eb365f0587764364b59b9daedbbf8", size = 94124, upload-time = "2026-05-17T06:18:19.863Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" From 6b3d25e69f2124f317564b4024547eae817871a2 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 11:43:07 -0400 Subject: [PATCH 02/22] Bump Sail SDK sandbox dependency --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a19ca6f1a..785f2659b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ cloudflare = ["aiohttp>=3.12,<4"] e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"] modal = ["modal==1.3.5"] runloop = ["runloop_api_client>=1.16.0,<2.0.0"] -sailbox = ["sail-sdk>=0.1.24"] +sailbox = ["sail-sdk>=0.1.32"] vercel = ["vercel>=0.5.6,<0.6"] s3 = ["boto3>=1.34"] temporal = [ diff --git a/uv.lock b/uv.lock index 4b399095d1..382c37392a 100644 --- a/uv.lock +++ b/uv.lock @@ -2587,7 +2587,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "runloop-api-client", marker = "extra == 'runloop'", specifier = ">=1.16.0,<2.0.0" }, - { name = "sail-sdk", marker = "extra == 'sailbox'", specifier = ">=0.1.24" }, + { name = "sail-sdk", marker = "extra == 'sailbox'", specifier = ">=0.1.32" }, { name = "sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=2.0" }, { name = "temporalio", marker = "extra == 'temporal'", specifier = "==1.26.0" }, { name = "textual", marker = "extra == 'temporal'", specifier = ">=8.2.3,<8.3" }, From 11421363a660adbf2b0c45ecc8a40845a66b577e Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 11:58:01 -0400 Subject: [PATCH 03/22] Expand Sailbox sandbox provider tests --- tests/extensions/sandbox/test_sailbox.py | 752 ++++++++++++++++++++++- 1 file changed, 750 insertions(+), 2 deletions(-) diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index bdeb865db7..e2d944797e 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -20,13 +20,16 @@ from agents.sandbox.errors import ( ExecTransportError, ExposedPortUnavailableError, + WorkspaceArchiveReadError, WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, ) -from agents.sandbox.manifest import Manifest -from agents.sandbox.session import SandboxSessionState +from agents.sandbox.manifest import Environment, Manifest +from agents.sandbox.session import SandboxSession, SandboxSessionState +from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import NoopSnapshot +from agents.sandbox.types import ExecResult @dataclass @@ -157,17 +160,87 @@ def pause(self) -> None: self.paused = True self.status = "paused" + def resume(self) -> _FakeSailbox: + self.status = "running" + return self + def terminate(self) -> None: self.terminated = True self.status = "terminated" +class _StatusError(RuntimeError): + def __init__(self, message: str, *, status_code: int) -> None: + super().__init__(message) + self.status_code = status_code + + +class _WaitFailingRequest: + def wait(self) -> _FakeExecResult: + raise RuntimeError("wait failed") + + +class _WaitFailingSailbox(_FakeSailbox): + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + return _WaitFailingRequest() + + class _FailingExecSailbox(_FakeSailbox): def exec(self, command: str, *, timeout: int | None = None) -> Any: _ = (command, timeout) raise RuntimeError("worker unavailable") +class _NonzeroExecSailbox(_FakeSailbox): + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + return _FakeExecRequest(_FakeExecResult(stdout="out", stderr="err", returncode=7)) + + +class _ScriptedExecSailbox(_FakeSailbox): + def __init__(self, results: list[_FakeExecResult | BaseException]) -> None: + super().__init__() + self.results = results + + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + if not self.results: + return super().exec(command, timeout=timeout) + result = self.results.pop(0) + if isinstance(result, BaseException): + raise result + return _FakeExecRequest(result) + + +class _FailingReadSailbox(_FakeSailbox): + def read(self, path: str) -> bytes: + _ = path + raise RuntimeError("read failed") + + +class _FailingWriteSailbox(_FakeSailbox): + def write(self, path: str, data: bytes) -> None: + _ = (path, data) + raise RuntimeError("write failed") + + +class _FailingListenerSailbox(_FakeSailbox): + def listener(self, port: int) -> _FakeListener: + _ = port + raise RuntimeError("listener failed") + + +class _FailingPauseSailbox(_FakeSailbox): + def pause(self) -> None: + raise RuntimeError("pause failed") + + +class _FailingTerminateSailbox(_FakeSailbox): + def terminate(self) -> None: + raise RuntimeError("terminate failed") + + class _BlockingFakeSailbox(_FakeSailbox): def __init__(self) -> None: super().__init__() @@ -195,6 +268,26 @@ def _state(sailbox: _FakeSailbox) -> SailboxSandboxSessionState: ) +def _session(sailbox: _FakeSailbox) -> SailboxSandboxSession: + return SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + +def _tar_bytes(name: str = "README.md", payload: bytes = b"hello") -> io.BytesIO: + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w") as archive: + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + raw.seek(0) + return raw + + +class _InvalidPayload(io.IOBase): + def read(self, *args: object, **kwargs: object) -> object: + _ = (args, kwargs) + return object() + + def test_client_create_creates_sailbox(monkeypatch: pytest.MonkeyPatch) -> None: created: list[dict[str, object]] = [] fake_sailbox = _FakeSailbox("sb-created") @@ -570,3 +663,658 @@ def test_shutdown_pauses_or_terminates_sailbox() -> None: asyncio.run(terminated_session.shutdown()) assert terminated_sailbox.terminated is True assert terminated_session.state.status == "terminated" + + +def test_client_options_defaults_match_provider_defaults() -> None: + options = SailboxSandboxClientOptions() + + assert options.type == "sailbox" + assert options.app_name == "openai-agents-sandbox" + assert options.name_prefix == "openai-agent" + assert options.memory_mib == 1024 + assert options.cpu == 1 + assert options.disk_gib == 8 + assert options.exposed_ports == () + assert options.pause_on_exit is False + + +def test_client_resolve_options_uses_client_defaults() -> None: + app = App(id="app_client", name="client", created_at=1) + client = SailboxSandboxClient( + app=app, + app_name="client-app", + image=Image.debian_amd64, + name_prefix="client-prefix", + image_build_timeout=33, + memory_mib=2048, + cpu=2, + disk_gib=16, + pause_on_exit=True, + ) + + options = client._resolve_options(None) + + assert options.app == app + assert options.app_name == "client-app" + assert options.image is Image.debian_amd64 + assert options.name_prefix == "client-prefix" + assert options.image_build_timeout == 33 + assert options.memory_mib == 2048 + assert options.cpu == 2 + assert options.disk_gib == 16 + assert options.pause_on_exit is True + + +def test_client_resolve_options_prefers_explicit_values() -> None: + client = SailboxSandboxClient( + app=App(id="app_client", name="client", created_at=1), + image=Image.debian_arm64, + name_prefix="client-prefix", + ) + explicit_app = App(id="app_explicit", name="explicit", created_at=2) + + options = client._resolve_options( + SailboxSandboxClientOptions( + app=explicit_app, + app_name="explicit-app", + image=Image.debian_amd64, + name_prefix="explicit-prefix", + exposed_ports=(3000, 8080), + ) + ) + + assert options.app == explicit_app + assert options.app_name == "explicit-app" + assert options.image is Image.debian_amd64 + assert options.name_prefix == "explicit-prefix" + assert options.exposed_ports == (3000, 8080) + + +def test_client_resolve_options_falls_back_to_client_app() -> None: + app = App(id="app_client", name="client", created_at=1) + client = SailboxSandboxClient(app=app, app_name="client-app") + + options = client._resolve_options(SailboxSandboxClientOptions(app_name=None)) + + assert options.app == app + assert options.app_name == "client-app" + + +def test_resolve_app_returns_explicit_app_without_lookup(monkeypatch: pytest.MonkeyPatch) -> None: + app = App(id="app_direct", name="direct", created_at=1) + client = SailboxSandboxClient() + + def fail_find(**kwargs: object) -> App: + _ = kwargs + raise AssertionError("App.find should not be called") + + monkeypatch.setattr("agents.extensions.sandbox.sailbox.sandbox.App.find", fail_find) + + resolved = asyncio.run(client._resolve_app(SailboxSandboxClientOptions(app=app))) + + assert resolved == app + + +def test_resolve_app_finds_and_mints_by_name(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[dict[str, object]] = [] + client = SailboxSandboxClient() + + def fake_find(**kwargs: object) -> App: + calls.append(kwargs) + return App(id="app_found", name=cast(str, kwargs["name"]), created_at=1) + + monkeypatch.setattr("agents.extensions.sandbox.sailbox.sandbox.App.find", fake_find) + + resolved = asyncio.run( + client._resolve_app(SailboxSandboxClientOptions(app=None, app_name="agents")) + ) + + assert resolved.id == "app_found" + assert calls == [{"name": "agents", "mint_if_missing": True}] + + +def test_resolve_app_requires_app_or_name() -> None: + client = SailboxSandboxClient() + + with pytest.raises(ValueError, match="requires app or app_name"): + asyncio.run(client._resolve_app(SailboxSandboxClientOptions(app=None, app_name=None))) + + +def test_create_without_manifest_uses_default_manifest(monkeypatch: pytest.MonkeyPatch) -> None: + fake_sailbox = _FakeSailbox("sb-default-manifest") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(lambda **kwargs: fake_sailbox), + ) + + session = asyncio.run( + SailboxSandboxClient(app=App(id="app_test", name="agents", created_at=1)).create() + ) + + assert session.state.manifest.root == "/workspace" + assert session.state.sailbox_id == "sb-default-manifest" + + +def test_create_passes_resource_options_and_generated_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created: list[dict[str, object]] = [] + fake_sailbox = _FakeSailbox("sb-resources") + + def fake_create(**kwargs: object) -> _FakeSailbox: + created.append(kwargs) + return fake_sailbox + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(fake_create), + ) + app = App(id="app_test", name="agents", created_at=1) + + session = asyncio.run( + SailboxSandboxClient(app=app).create( + options=SailboxSandboxClientOptions( + image=Image.debian_amd64, + name_prefix="custom-prefix", + image_build_timeout=44, + memory_mib=4096, + cpu=4, + disk_gib=32, + exposed_ports=(8080, 3000), + ) + ) + ) + + assert session.state.sailbox_name == fake_sailbox.name + assert created[0]["image"] is Image.debian_amd64 + assert created[0]["app"] == app + assert cast(str, created[0]["name"]).startswith("custom-prefix-") + assert len(cast(str, created[0]["name"]).removeprefix("custom-prefix-")) == 12 + assert created[0]["image_build_timeout"] == 44 + assert created[0]["memory_mib"] == 4096 + assert created[0]["cpu"] == 4 + assert created[0]["disk_gib"] == 32 + assert created[0]["ingress_ports"] == [8080, 3000] + + +def test_create_failure_context_includes_http_status(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_create(**kwargs: object) -> _FakeSailbox: + _ = kwargs + raise _StatusError("forbidden", status_code=403) + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.create", + staticmethod(fake_create), + ) + + client = SailboxSandboxClient(app=App(id="app_test", name="agents", created_at=1)) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(client.create()) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["http_status"] == 403 + assert exc_info.value.context["provider_error"] == "HTTP 403: forbidden" + + +def test_resume_rejects_wrong_state_type() -> None: + state = SandboxSessionState( + type="other", + session_id=uuid.uuid4(), + snapshot=NoopSnapshot(id="test"), + manifest=Manifest(), + ) + + with pytest.raises(TypeError, match="SailboxSandboxSessionState"): + asyncio.run(SailboxSandboxClient().resume(state)) + + +def test_deserialize_session_state_returns_sailbox_state() -> None: + state = _state(_FakeSailbox("sb-json")) + payload = json.loads(state.model_dump_json()) + + parsed = SailboxSandboxClient().deserialize_session_state(payload) + + assert isinstance(parsed, SailboxSandboxSessionState) + assert parsed.sailbox_id == "sb-json" + assert parsed.exposed_ports == (8080,) + + +def test_session_state_defaults_are_serializable() -> None: + state = SailboxSandboxSessionState( + session_id=uuid.uuid4(), + snapshot=NoopSnapshot(id="test"), + manifest=Manifest(), + ) + + payload = state.model_dump(mode="json") + + assert payload["type"] == "sailbox" + assert payload["sailbox_id"] == "" + assert payload["image_build_timeout"] == 1800 + assert payload["memory_mib"] == 1024 + assert payload["cpu"] == 1 + assert payload["disk_gib"] == 8 + + +def test_running_false_without_backend_or_when_paused() -> None: + state = _state(_FakeSailbox()) + state.status = "paused" + without_backend = SailboxSandboxSession.from_state(state, sailbox=None) + paused = SailboxSandboxSession.from_state(state, sailbox=_FakeSailbox()) + paused.state.status = "paused" + + assert asyncio.run(without_backend.running()) is False + assert asyncio.run(paused.running()) is False + + +def test_set_sailbox_updates_state_fields() -> None: + session = SailboxSandboxSession.from_state(_state(_FakeSailbox("sb-old"))) + sailbox = _FakeSailbox("sb-new") + sailbox.name = "new-name" + sailbox.worker_address = "worker-new:50051" + sailbox.exec_endpoint = "exec-new:443" + + session._set_sailbox(sailbox) + + assert session.state.sailbox_id == "sb-new" + assert session.state.sailbox_name == "new-name" + assert session.state.status == "running" + assert session.state.worker_address == "worker-new:50051" + assert session.state.exec_endpoint == "exec-new:443" + + +def test_ensure_backend_started_resumes_paused_sailbox() -> None: + sailbox = _FakeSailbox("sb-paused") + sailbox.status = "paused" + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + asyncio.run(session._ensure_backend_started()) + + assert sailbox.status == "running" + assert session.state.status == "running" + + +def test_ensure_backend_started_requires_sailbox_id() -> None: + state = _state(_FakeSailbox()) + state.sailbox_id = "" + session = SailboxSandboxSession.from_state(state) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._ensure_backend_started()) + + assert exc_info.value.context["reason"] == "missing_sailbox_id" + + +def test_ensure_backend_started_connects_existing_sailbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connected = _FakeSailbox("sb-connect") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", + lambda sailbox_id: connected, + ) + + session = SailboxSandboxSession.from_state(_state(_FakeSailbox("sb-connect"))) + asyncio.run(session._ensure_backend_started()) + + assert session.sailbox is connected + assert session._workspace_state_preserved_on_start() is True + + +def test_ensure_backend_started_connect_failure_maps_start_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", + lambda sailbox_id: (_ for _ in ()).throw(RuntimeError("gone")), + ) + + session = SailboxSandboxSession.from_state(_state(_FakeSailbox("sb-missing"))) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._ensure_backend_started()) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["reason"] == "connect_failed" + assert exc_info.value.context["sailbox_id"] == "sb-missing" + + +def test_prepare_backend_workspace_nonzero_raises_start_error() -> None: + sailbox = _NonzeroExecSailbox() + session = _session(sailbox) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._prepare_backend_workspace()) + + assert exc_info.value.context["exit_code"] == 7 + assert exc_info.value.context["stdout"] == "out" + assert exc_info.value.context["stderr"] == "err" + + +def test_prepare_backend_workspace_wait_failure_maps_start_error() -> None: + session = _session(_WaitFailingSailbox()) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._prepare_backend_workspace()) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["reason"] == "mkdir_failed" + + +@pytest.mark.parametrize( + ("timeout", "expected"), + [ + (None, None), + (0, 1), + (-1, 1), + (1.01, 2), + ], +) +def test_exec_timeout_is_coerced(timeout: float | None, expected: int | None) -> None: + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.exec("printf ok", timeout=timeout)) + + assert sailbox.exec_commands[-1][1] == expected + + +def test_exec_shell_false_uses_direct_command() -> None: + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.exec("printf", "ok", shell=False)) + + assert sailbox.exec_commands[-1] == ("cd /workspace && printf ok", None) + + +def test_exec_custom_shell_prefix_is_respected() -> None: + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.exec("printf ok", shell=["bash", "-lc"])) + + assert sailbox.exec_commands[-1] == ("cd /workspace && bash -lc 'printf ok'", None) + + +def test_exec_user_wraps_command_with_sudo() -> None: + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.exec("id", shell=False, user="sandbox-user")) + + assert sailbox.exec_commands[-1] == ( + "cd /workspace && sudo -u sandbox-user -- id", + None, + ) + + +def test_exec_includes_sorted_manifest_environment() -> None: + sailbox = _FakeSailbox() + state = _state(sailbox) + state.manifest = Manifest( + root="/workspace", + environment=Environment(value={"ZED": "two words", "ALPHA": "1"}), + ) + session = SailboxSandboxSession.from_state(state, sailbox=sailbox) + + asyncio.run(session.exec("printf ok")) + + assert sailbox.exec_commands[-1] == ( + "cd /workspace && env ALPHA=1 ZED='two words' sh -lc 'printf ok'", + None, + ) + + +def test_exec_nonzero_result_is_returned_to_caller() -> None: + session = _session(_NonzeroExecSailbox()) + + result = asyncio.run(session.exec("false")) + + assert result.exit_code == 7 + assert result.stdout == b"out" + assert result.stderr == b"err" + + +def test_exec_wait_failure_maps_transport_error() -> None: + session = _session(_WaitFailingSailbox()) + + with pytest.raises(ExecTransportError) as exc_info: + asyncio.run(session.exec("printf ok")) + + assert exc_info.value.context["backend"] == "sailbox" + assert "wait failed" in exc_info.value.context["provider_error"] + + +async def _validate_direct_path( + self: SailboxSandboxSession, + path: Path | str, + *, + for_write: bool = False, +) -> Path: + _ = (self, for_write) + return Path("/workspace") / Path(path) + + +def test_read_generic_failure_maps_archive_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + session = _session(_FailingReadSailbox()) + + with pytest.raises(WorkspaceArchiveReadError): + asyncio.run(session.read(Path("notes.txt"))) + + +def test_write_accepts_text_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.write(Path("notes.txt"), io.StringIO("hello"))) + + assert sailbox.files["/workspace/notes.txt"] == b"hello" + + +def test_write_rejects_invalid_payload_type(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + session = _session(_FakeSailbox()) + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + asyncio.run(session.write(Path("notes.txt"), _InvalidPayload())) + + assert exc_info.value.context["reason"] == "invalid_write_payload" + + +def test_write_generic_failure_maps_archive_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + session = _session(_FailingWriteSailbox()) + + with pytest.raises(WorkspaceArchiveWriteError): + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"))) + + +def test_resolve_exposed_port_rejects_unconfigured_port() -> None: + session = _session(_FakeSailbox()) + + with pytest.raises(ExposedPortUnavailableError) as exc_info: + asyncio.run(session.resolve_exposed_port(3000)) + + assert exc_info.value.context["reason"] == "not_configured" + + +def test_resolve_exposed_port_listener_failure_maps_error() -> None: + state = _state(_FailingListenerSailbox()) + state.exposed_ports = (8080,) + session = SailboxSandboxSession.from_state(state, sailbox=_FailingListenerSailbox()) + + with pytest.raises(ExposedPortUnavailableError) as exc_info: + asyncio.run(session.resolve_exposed_port(8080)) + + assert exc_info.value.context["reason"] == "backend_unavailable" + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["detail"] == "listener_lookup_failed" + + +def test_resolve_exposed_port_parses_http_default_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_FakeListener, "url", "http://listener.example.test/path") + session = _session(_FakeSailbox()) + + endpoint = asyncio.run(session.resolve_exposed_port(8080)) + + assert endpoint.host == "listener.example.test" + assert endpoint.port == 80 + assert endpoint.tls is False + assert endpoint.query == "" + + +def test_persist_workspace_nonzero_tar_raises_archive_error() -> None: + session = _session(_NonzeroExecSailbox()) + + with pytest.raises(WorkspaceArchiveReadError) as exc_info: + asyncio.run(session.persist_workspace()) + + assert exc_info.value.context["exit_code"] == 7 + assert exc_info.value.context["stdout"] == "out" + assert exc_info.value.context["stderr"] == "err" + + +def test_persist_workspace_read_failure_maps_archive_error() -> None: + session = _session(_FailingReadSailbox()) + + with pytest.raises(WorkspaceArchiveReadError): + asyncio.run(session.persist_workspace()) + + +def test_persist_workspace_cleanup_failure_is_suppressed() -> None: + sailbox = _ScriptedExecSailbox( + [ + _FakeExecResult(returncode=0), + RuntimeError("cleanup failed"), + ] + ) + state = _state(sailbox) + archive_path = f"/tmp/openai-agents-{state.session_id.hex}.tar" + sailbox.files[archive_path] = b"archive" + session = SailboxSandboxSession.from_state(state, sailbox=sailbox) + + archive = asyncio.run(session.persist_workspace()) + + assert archive.read() == b"archive" + assert len(sailbox.exec_commands) == 2 + + +def test_hydrate_workspace_rejects_invalid_payload_type() -> None: + session = _session(_FakeSailbox()) + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + asyncio.run(session.hydrate_workspace(_InvalidPayload())) + + assert exc_info.value.context["reason"] == "invalid_archive_payload" + + +def test_hydrate_workspace_write_failure_maps_archive_error() -> None: + session = _session(_FailingWriteSailbox()) + + with pytest.raises(WorkspaceArchiveWriteError): + asyncio.run(session.hydrate_workspace(_tar_bytes())) + + +def test_hydrate_workspace_extract_failure_maps_archive_error() -> None: + sailbox = _ScriptedExecSailbox( + [ + _FakeExecResult(returncode=0), + _FakeExecResult(stdout="out", stderr="err", returncode=2), + _FakeExecResult(returncode=0), + ] + ) + session = _session(sailbox) + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + asyncio.run(session.hydrate_workspace(_tar_bytes())) + + assert exc_info.value.context["exit_code"] == 2 + assert exc_info.value.context["stdout"] == "out" + assert exc_info.value.context["stderr"] == "err" + + +def test_hydrate_workspace_cleanup_failure_is_suppressed() -> None: + sailbox = _ScriptedExecSailbox( + [ + _FakeExecResult(returncode=0), + _FakeExecResult(returncode=0), + RuntimeError("cleanup failed"), + ] + ) + session = _session(sailbox) + + asyncio.run(session.hydrate_workspace(_tar_bytes())) + + assert len(sailbox.exec_commands) == 3 + + +def test_shutdown_pause_failure_propagates() -> None: + state = _state(_FailingPauseSailbox()) + state.pause_on_exit = True + session = SailboxSandboxSession.from_state(state, sailbox=_FailingPauseSailbox()) + + with pytest.raises(RuntimeError, match="pause failed"): + asyncio.run(session.shutdown()) + + +def test_shutdown_terminate_failure_propagates() -> None: + session = _session(_FailingTerminateSailbox()) + + with pytest.raises(RuntimeError, match="terminate failed"): + asyncio.run(session.shutdown()) + + +def test_client_delete_rejects_non_sailbox_session() -> None: + class _OtherInner(BaseSandboxSession): + state: SandboxSessionState + + def __init__(self) -> None: + self.state = SandboxSessionState( + type="other", + session_id=uuid.uuid4(), + snapshot=NoopSnapshot(id="test"), + manifest=Manifest(), + ) + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + _ = (command, timeout) + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + async def read(self, path: Path, *, user: str | None = None) -> io.IOBase: + _ = (path, user) + return io.BytesIO() + + async def write( + self, + path: Path, + data: io.IOBase, + *, + user: str | None = None, + ) -> None: + _ = (path, data, user) + + async def running(self) -> bool: + return True + + async def persist_workspace(self) -> io.IOBase: + return io.BytesIO() + + async def hydrate_workspace(self, data: io.IOBase) -> None: + _ = data + + other = SandboxSession(_OtherInner()) + + with pytest.raises(TypeError, match="SailboxSandboxSession"): + asyncio.run(SailboxSandboxClient().delete(other)) From a8e285e758f8ad543822e15a3ca91d48d0ca8529 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 12:38:01 -0400 Subject: [PATCH 04/22] Update locked Sail SDK version --- uv.lock | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 382c37392a..fd651dfba4 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,9 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[options.exclude-newer-package] +sail-sdk = "2026-05-25T06:00:00Z" + [[package]] name = "aiofiles" version = "24.1.0" @@ -2598,7 +2601,7 @@ requires-dist = [ { name = "websockets", marker = "extra == 'realtime'", specifier = ">=15.0,<17" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<17" }, ] -provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "vercel", "sailbox", "s3", "temporal"] +provides-extras = ["voice", "viz", "litellm", "any-llm", "realtime", "sqlalchemy", "encrypt", "redis", "dapr", "mongodb", "docker", "blaxel", "daytona", "cloudflare", "e2b", "modal", "runloop", "sailbox", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [ @@ -3830,7 +3833,7 @@ wheels = [ [[package]] name = "sail-sdk" -version = "0.1.24" +version = "0.1.33" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -3839,10 +3842,11 @@ dependencies = [ { name = "pathspec" }, { name = "protobuf" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/97/2334c4669e2cd29fb65183d784c5a011cc3ca9cfaac37a7f4923a0645db4/sail_sdk-0.1.24.tar.gz", hash = "sha256:1b38fd5ea2c302ab6ab7e99e37596ebb1682d3f544338f73cf4fb9fd0d6d160c", size = 142463, upload-time = "2026-05-17T06:18:18.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/ea/83435959cbe909ed329a17cfa4b1b6302f4b1180a8e69acf8851e638f507/sail_sdk-0.1.33.tar.gz", hash = "sha256:d010202309611f706c275ac7dd083e8564df852786e914d04228755dc6ca14a0", size = 191083, upload-time = "2026-05-25T05:51:34.32Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/0d/e4aa622f02d8e4244d32afe27289a6002d4b00bdf239436c73cb73177296/sail_sdk-0.1.24-py3-none-any.whl", hash = "sha256:1395c0c937cfcb77eda7029f26f0931a863eb365f0587764364b59b9daedbbf8", size = 94124, upload-time = "2026-05-17T06:18:19.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/d72df7739903e70474c405da7290c2240f670e1948d6a56c9c4831f3d37d/sail_sdk-0.1.33-py3-none-any.whl", hash = "sha256:14b4e353ab261ce374d1ead51ae67353273645d98cb961b80eb615ad4669380f", size = 116002, upload-time = "2026-05-25T05:51:32.921Z" }, ] [[package]] From c281aa91a245150ed029b89c4337ed02369804d3 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 13:34:55 -0400 Subject: [PATCH 05/22] Address Sailbox sandbox review comments --- .../extensions/sandbox/sailbox/sandbox.py | 52 ++++++++++++++++--- tests/extensions/sandbox/test_sailbox.py | 10 +++- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index f71e6c3787..7a750ece55 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import base64 import io import math import shlex @@ -21,7 +22,7 @@ from typing import Any, Literal, TypeVar, cast from urllib.parse import urlsplit -from pydantic import field_serializer +from pydantic import field_serializer, field_validator from sail.app import App from sail.image import Image, ImageDefinition from sail.sailbox import Sailbox @@ -33,6 +34,7 @@ WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceWriteTypeError, ) from ....sandbox.manifest import Manifest from ....sandbox.session import SandboxSession, SandboxSessionState @@ -162,12 +164,50 @@ class SailboxSandboxSessionState(SandboxSessionState): exec_endpoint: str = "" worker_address: str = "" status: str = "" + image: ImageDefinition | None = None image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT memory_mib: int = _DEFAULT_MEMORY_MIB cpu: int = _DEFAULT_CPU disk_gib: int = _DEFAULT_DISK_GIB pause_on_exit: bool = False + @field_serializer("image", when_used="json") + def _serialize_image(self, image: ImageDefinition | None) -> dict[str, str | None] | None: + if image is None: + return None + to_proto = getattr(image, "to_proto", None) + if not callable(to_proto): + return None + spec = to_proto() + serialize = getattr(spec, "SerializeToString", None) + if not callable(serialize): + return None + return { + "image_id": getattr(image, "_image_id", None), + "spec": base64.b64encode(serialize()).decode("ascii"), + } + + @field_validator("image", mode="before") + @classmethod + def _deserialize_image(cls, image: object) -> object: + if isinstance(image, dict): + raw_spec = image.get("spec") + if not isinstance(raw_spec, str): + return None + raw_image_id = image.get("image_id") + image_id = raw_image_id if isinstance(raw_image_id, str) else None + try: + from sail.pb.image.v1 import image_pb2 + + spec = image_pb2.ImageSpec() + spec.ParseFromString(base64.b64decode(raw_spec)) + return ImageDefinition(spec, _image_id=image_id) + except Exception: + return None + if isinstance(image, str): + return None + return image + class SailboxSandboxSession(BaseSandboxSession): """OpenAI Agents SDK sandbox session backed by a single Sailbox.""" @@ -414,13 +454,7 @@ async def write( if isinstance(payload, str): payload = payload.encode("utf-8") if not isinstance(payload, bytes | bytearray): - raise WorkspaceArchiveWriteError( - path=Path(path), - context={ - "reason": "invalid_write_payload", - "type": type(payload).__name__, - }, - ) + raise WorkspaceWriteTypeError(path=Path(path), actual_type=type(payload).__name__) workspace_path = await self._validate_path_access(path, for_write=True) try: @@ -593,6 +627,7 @@ async def create( exec_endpoint=sailbox.exec_endpoint, worker_address=sailbox.worker_address, status=sailbox.status, + image=resolved_options.image, image_build_timeout=resolved_options.image_build_timeout, memory_mib=resolved_options.memory_mib, cpu=resolved_options.cpu, @@ -631,6 +666,7 @@ async def resume(self, state: SandboxSessionState) -> SandboxSession: options = self._resolve_options( SailboxSandboxClientOptions( app_name=state.app_name or self._app_name, + image=state.image, image_build_timeout=state.image_build_timeout, memory_mib=state.memory_mib, cpu=state.cpu, diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index e2d944797e..568918ee15 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -24,6 +24,7 @@ WorkspaceArchiveWriteError, WorkspaceReadNotFoundError, WorkspaceStartError, + WorkspaceWriteTypeError, ) from agents.sandbox.manifest import Environment, Manifest from agents.sandbox.session import SandboxSession, SandboxSessionState @@ -317,9 +318,11 @@ def fake_create(**kwargs: object) -> _FakeSailbox: inner = session._inner assert isinstance(inner, SailboxSandboxSession) assert inner.state.sailbox_id == "sb-created" + assert inner.state.image is Image.debian_amd64 assert inner.state.exposed_ports == (8080,) assert inner.state.pause_on_exit is True assert created[0]["app"] == app + assert created[0]["image"] is Image.debian_amd64 assert created[0]["ingress_ports"] == [8080] @@ -606,6 +609,7 @@ def fake_create(**kwargs: object) -> _FakeSailbox: app = App(id="app_test", name="agents", created_at=1) state = _state(_FakeSailbox("sb-missing")) state.app_name = "agents" + state.image = Image.debian_amd64 state.workspace_root_ready = True client = SailboxSandboxClient(app=app) @@ -617,6 +621,7 @@ def fake_create(**kwargs: object) -> _FakeSailbox: assert inner.state.workspace_root_ready is False assert inner._workspace_state_preserved_on_start() is False assert created[0]["app"] == app + assert created[0]["image"] is Image.debian_amd64 def test_client_create_failure_includes_provider_error( @@ -893,6 +898,7 @@ def test_session_state_defaults_are_serializable() -> None: assert payload["type"] == "sailbox" assert payload["sailbox_id"] == "" assert payload["image_build_timeout"] == 1800 + assert payload["image"] is None assert payload["memory_mib"] == 1024 assert payload["cpu"] == 1 assert payload["disk_gib"] == 8 @@ -1121,10 +1127,10 @@ def test_write_rejects_invalid_payload_type(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) session = _session(_FakeSailbox()) - with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + with pytest.raises(WorkspaceWriteTypeError) as exc_info: asyncio.run(session.write(Path("notes.txt"), _InvalidPayload())) - assert exc_info.value.context["reason"] == "invalid_write_payload" + assert exc_info.value.context["actual_type"] == "object" def test_write_generic_failure_maps_archive_error(monkeypatch: pytest.MonkeyPatch) -> None: From 2cb8fe7a9f893b3d3c98e306dd48f84af5d782a9 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 15:49:26 -0400 Subject: [PATCH 06/22] Wrap Sailbox resume start failures --- .../extensions/sandbox/sailbox/sandbox.py | 16 +++++++++++++++- tests/extensions/sandbox/test_sailbox.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 7a750ece55..56d087ffea 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -249,7 +249,21 @@ def _set_sailbox(self, sailbox: Sailbox) -> None: async def _ensure_backend_started(self) -> None: if self._sailbox is not None: if self._sailbox.status != "running": - await _call_sailbox(self._sailbox.resume) + try: + await _call_sailbox(self._sailbox.resume) + except Exception as exc: + raise WorkspaceStartError( + path=self._workspace_root_path(), + context=_sailbox_error_context( + cause=exc, + extra={ + "reason": "resume_failed", + "sailbox_id": self.state.sailbox_id, + }, + ), + cause=exc, + message=_sailbox_error_message("Sailbox resume failed", exc), + ) from exc self._set_sailbox(self._sailbox) return diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 568918ee15..ecde6c2f27 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -237,6 +237,11 @@ def pause(self) -> None: raise RuntimeError("pause failed") +class _FailingResumeSailbox(_FakeSailbox): + def resume(self) -> _FakeSailbox: + raise RuntimeError("resume failed") + + class _FailingTerminateSailbox(_FakeSailbox): def terminate(self) -> None: raise RuntimeError("terminate failed") @@ -942,6 +947,20 @@ def test_ensure_backend_started_resumes_paused_sailbox() -> None: assert session.state.status == "running" +def test_ensure_backend_started_resume_failure_maps_start_error() -> None: + sailbox = _FailingResumeSailbox("sb-paused") + sailbox.status = "paused" + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._ensure_backend_started()) + + assert exc_info.value.context["backend"] == "sailbox" + assert exc_info.value.context["reason"] == "resume_failed" + assert exc_info.value.context["sailbox_id"] == "sb-paused" + assert "Sailbox resume failed: RuntimeError: resume failed" in str(exc_info.value) + + def test_ensure_backend_started_requires_sailbox_id() -> None: state = _state(_FakeSailbox()) state.sailbox_id = "" From e355b6fca85cfad5328c689a92269579b7c5a1ad Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 15:52:34 -0400 Subject: [PATCH 07/22] Preserve Sailbox client option defaults --- .../extensions/sandbox/sailbox/sandbox.py | 90 ++++++++++++------- tests/extensions/sandbox/test_sailbox.py | 44 +++++++++ 2 files changed, 101 insertions(+), 33 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 56d087ffea..da1b91e8d5 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -57,6 +57,7 @@ _DEFAULT_CPU = 1 _DEFAULT_DISK_GIB = 8 _DEFAULT_IMAGE_BUILD_TIMEOUT = 1800 +_UNSET = object() R = TypeVar("R") @@ -116,32 +117,41 @@ class SailboxSandboxClientOptions(BaseSandboxClientOptions): def __init__( self, - app: App | None = None, - app_name: str | None = _DEFAULT_APP_NAME, - image: ImageDefinition | None = None, - name_prefix: str = _DEFAULT_NAME_PREFIX, - image_build_timeout: int = _DEFAULT_IMAGE_BUILD_TIMEOUT, - memory_mib: int = _DEFAULT_MEMORY_MIB, - cpu: int = _DEFAULT_CPU, - disk_gib: int = _DEFAULT_DISK_GIB, - exposed_ports: tuple[int, ...] = (), - pause_on_exit: bool = False, + app: App | None | object = _UNSET, + app_name: str | None | object = _UNSET, + image: ImageDefinition | None | object = _UNSET, + name_prefix: str | object = _UNSET, + image_build_timeout: int | object = _UNSET, + memory_mib: int | object = _UNSET, + cpu: int | object = _UNSET, + disk_gib: int | object = _UNSET, + exposed_ports: tuple[int, ...] | object = _UNSET, + pause_on_exit: bool | object = _UNSET, *, type: Literal["sailbox"] = "sailbox", ) -> None: - super().__init__( - type=type, - app=app, - app_name=app_name, - image=image, - name_prefix=name_prefix, - image_build_timeout=image_build_timeout, - memory_mib=memory_mib, - cpu=cpu, - disk_gib=disk_gib, - exposed_ports=tuple(exposed_ports), - pause_on_exit=pause_on_exit, - ) + values: dict[str, object] = {"type": type} + if app is not _UNSET: + values["app"] = app + if app_name is not _UNSET: + values["app_name"] = app_name + if image is not _UNSET: + values["image"] = image + if name_prefix is not _UNSET: + values["name_prefix"] = name_prefix + if image_build_timeout is not _UNSET: + values["image_build_timeout"] = image_build_timeout + if memory_mib is not _UNSET: + values["memory_mib"] = memory_mib + if cpu is not _UNSET: + values["cpu"] = cpu + if disk_gib is not _UNSET: + values["disk_gib"] = disk_gib + if exposed_ports is not _UNSET: + values["exposed_ports"] = tuple(cast(Any, exposed_ports)) + if pause_on_exit is not _UNSET: + values["pause_on_exit"] = pause_on_exit + super().__init__(**values) @field_serializer("app", when_used="json") def _serialize_app(self, app: App | None) -> str | None: @@ -705,6 +715,18 @@ def _resolve_options( self, options: SailboxSandboxClientOptions | None, ) -> SailboxSandboxClientOptions: + def option_or_default(field: str, client_default: R) -> R: + if options is not None and field in options.model_fields_set: + return cast(R, getattr(options, field)) + return client_default + + def optional_option_or_default(field: str, client_default: R | None) -> R | None: + if options is not None and field in options.model_fields_set: + value = getattr(options, field) + if value is not None: + return cast(R, value) + return client_default + if options is None: return SailboxSandboxClientOptions( app=self._app, @@ -718,16 +740,18 @@ def _resolve_options( pause_on_exit=self._pause_on_exit, ) return SailboxSandboxClientOptions( - app=options.app or self._app, - app_name=(options.app_name if options.app_name is not None else self._app_name), - image=options.image or self._image or Image.debian_arm64, - name_prefix=options.name_prefix or self._name_prefix, - image_build_timeout=options.image_build_timeout, - memory_mib=options.memory_mib, - cpu=options.cpu, - disk_gib=options.disk_gib, - exposed_ports=options.exposed_ports, - pause_on_exit=options.pause_on_exit, + app=optional_option_or_default("app", self._app), + app_name=optional_option_or_default("app_name", self._app_name), + image=optional_option_or_default("image", self._image) or Image.debian_arm64, + name_prefix=option_or_default("name_prefix", self._name_prefix), + image_build_timeout=option_or_default( + "image_build_timeout", self._image_build_timeout + ), + memory_mib=option_or_default("memory_mib", self._memory_mib), + cpu=option_or_default("cpu", self._cpu), + disk_gib=option_or_default("disk_gib", self._disk_gib), + exposed_ports=option_or_default("exposed_ports", ()), + pause_on_exit=option_or_default("pause_on_exit", self._pause_on_exit), ) async def _resolve_app(self, options: SailboxSandboxClientOptions) -> App: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index ecde6c2f27..88b6382ec4 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -679,6 +679,7 @@ def test_client_options_defaults_match_provider_defaults() -> None: options = SailboxSandboxClientOptions() assert options.type == "sailbox" + assert options.model_fields_set == {"type"} assert options.app_name == "openai-agents-sandbox" assert options.name_prefix == "openai-agent" assert options.memory_mib == 1024 @@ -720,6 +721,11 @@ def test_client_resolve_options_prefers_explicit_values() -> None: app=App(id="app_client", name="client", created_at=1), image=Image.debian_arm64, name_prefix="client-prefix", + image_build_timeout=33, + memory_mib=2048, + cpu=2, + disk_gib=16, + pause_on_exit=True, ) explicit_app = App(id="app_explicit", name="explicit", created_at=2) @@ -729,7 +735,12 @@ def test_client_resolve_options_prefers_explicit_values() -> None: app_name="explicit-app", image=Image.debian_amd64, name_prefix="explicit-prefix", + image_build_timeout=44, + memory_mib=4096, + cpu=4, + disk_gib=32, exposed_ports=(3000, 8080), + pause_on_exit=False, ) ) @@ -737,7 +748,40 @@ def test_client_resolve_options_prefers_explicit_values() -> None: assert options.app_name == "explicit-app" assert options.image is Image.debian_amd64 assert options.name_prefix == "explicit-prefix" + assert options.image_build_timeout == 44 + assert options.memory_mib == 4096 + assert options.cpu == 4 + assert options.disk_gib == 32 assert options.exposed_ports == (3000, 8080) + assert options.pause_on_exit is False + + +def test_client_resolve_options_partial_options_preserve_client_defaults() -> None: + app = App(id="app_client", name="client", created_at=1) + client = SailboxSandboxClient( + app=app, + app_name="client-app", + image=Image.debian_amd64, + name_prefix="client-prefix", + image_build_timeout=33, + memory_mib=2048, + cpu=2, + disk_gib=16, + pause_on_exit=True, + ) + + options = client._resolve_options(SailboxSandboxClientOptions(exposed_ports=(8080,))) + + assert options.app == app + assert options.app_name == "client-app" + assert options.image is Image.debian_amd64 + assert options.name_prefix == "client-prefix" + assert options.image_build_timeout == 33 + assert options.memory_mib == 2048 + assert options.cpu == 2 + assert options.disk_gib == 16 + assert options.exposed_ports == (8080,) + assert options.pause_on_exit is True def test_client_resolve_options_falls_back_to_client_app() -> None: From afac09e1f23e07824470656ec111d8e1c49fcde4 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 15:55:50 -0400 Subject: [PATCH 08/22] Make Sailbox app options round-trippable --- .../extensions/sandbox/sailbox/sandbox.py | 19 ++++++++++-- tests/extensions/sandbox/test_sailbox.py | 31 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index da1b91e8d5..c9493a2519 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -154,8 +154,23 @@ def __init__( super().__init__(**values) @field_serializer("app", when_used="json") - def _serialize_app(self, app: App | None) -> str | None: - return app.id if app is not None else None + def _serialize_app(self, app: App | None) -> dict[str, object] | None: + if app is None: + return None + return {"id": app.id, "name": app.name, "created_at": app.created_at} + + @field_validator("app", mode="before") + @classmethod + def _deserialize_app(cls, app: object) -> object: + if isinstance(app, str): + return App(id=app, name=app, created_at=0) + if isinstance(app, dict): + app_id = app.get("id") + name = app.get("name") + created_at = app.get("created_at") + if isinstance(app_id, str) and isinstance(name, str) and isinstance(created_at, int): + return App(id=app_id, name=name, created_at=created_at) + return app @field_serializer("image", when_used="json") def _serialize_image(self, image: ImageDefinition | None) -> str | None: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 88b6382ec4..aa5b18907a 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -27,7 +27,7 @@ WorkspaceWriteTypeError, ) from agents.sandbox.manifest import Environment, Manifest -from agents.sandbox.session import SandboxSession, SandboxSessionState +from agents.sandbox.session import BaseSandboxClientOptions, SandboxSession, SandboxSessionState from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult @@ -389,11 +389,38 @@ def test_options_json_dump_serializes_sdk_objects() -> None: dumped = options.model_dump(mode="json") assert dumped["type"] == "sailbox" - assert dumped["app"] == "app_test" + assert dumped["app"] == {"id": "app_test", "name": "agents", "created_at": 1} assert dumped["image"] is None assert dumped["exposed_ports"] == [8080] +def test_options_json_roundtrip_preserves_app() -> None: + options = SailboxSandboxClientOptions( + app=App(id="app_test", name="agents", created_at=1), + app_name=None, + exposed_ports=(8080,), + ) + + parsed = BaseSandboxClientOptions.parse(options.model_dump(mode="json")) + + assert isinstance(parsed, SailboxSandboxClientOptions) + assert parsed == options + assert parsed.app == App(id="app_test", name="agents", created_at=1) + + +def test_options_json_parse_accepts_legacy_app_id_string() -> None: + parsed = BaseSandboxClientOptions.parse( + { + "type": "sailbox", + "app": "app_test", + "app_name": None, + } + ) + + assert isinstance(parsed, SailboxSandboxClientOptions) + assert parsed.app == App(id="app_test", name="app_test", created_at=0) + + def test_prepare_backend_workspace_bootstraps_root_without_cd() -> None: fake_sailbox = _FakeSailbox() session = SailboxSandboxSession.from_state( From f72814d4ff6974bcc380364f4b41fe40cce435d7 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 15:59:04 -0400 Subject: [PATCH 09/22] Add Sailbox review regression coverage --- tests/sandbox/test_client_options.py | 7 +++++++ tests/sandbox/test_compatibility_guards.py | 1 + 2 files changed, 8 insertions(+) diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 8c71dc4028..a2fd477f44 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -4,10 +4,12 @@ from typing import Literal import pytest +from sail.app import App from agents.extensions.sandbox.cloudflare import CloudflareSandboxClientOptions from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions from agents.extensions.sandbox.e2b import E2BSandboxClientOptions +from agents.extensions.sandbox.sailbox import SailboxSandboxClientOptions from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.sandboxes import DockerSandboxClientOptions, UnixLocalSandboxClientOptions from agents.sandbox.session import BaseSandboxClientOptions @@ -69,6 +71,11 @@ def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() -> E2BSandboxClientOptions(sandbox_type="e2b", template="base"), DaytonaSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), CloudflareSandboxClientOptions(worker_url="https://example.com"), + SailboxSandboxClientOptions( + app=App(id="app_test", name="agents", created_at=1), + app_name=None, + exposed_ports=(8080,), + ), ], ) def test_sandbox_client_options_roundtrip_preserves_concrete_type( diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index ca04361909..fce81155ed 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -763,6 +763,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "exec_endpoint", "worker_address", "status", + "image", "image_build_timeout", "memory_mib", "cpu", From 0cebae9d346b3b4e2bde4c9b947b3965c096801a Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:09:56 -0400 Subject: [PATCH 10/22] Honor Sailbox write user identity --- .../extensions/sandbox/sailbox/sandbox.py | 51 ++++++++++++++++++- tests/extensions/sandbox/test_sailbox.py | 45 ++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index c9493a2519..e01fe352d7 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -486,8 +486,9 @@ async def write( *, user: str | User | None = None, ) -> None: + workspace_path: Path | None = None if user is not None: - await self._check_write_with_exec(path, user=user) + workspace_path = await self._check_write_with_exec(path, user=user) payload = data.read() if isinstance(payload, str): @@ -495,6 +496,11 @@ async def write( if not isinstance(payload, bytes | bytearray): raise WorkspaceWriteTypeError(path=Path(path), actual_type=type(payload).__name__) + if user is not None: + assert workspace_path is not None + await self._write_payload_as_user(workspace_path, bytes(payload), user=user) + return + workspace_path = await self._validate_path_access(path, for_write=True) try: await _call_sailbox( @@ -505,6 +511,49 @@ async def write( except Exception as exc: raise WorkspaceArchiveWriteError(path=workspace_path, cause=exc) from exc + async def _write_payload_as_user( + self, + workspace_path: Path, + payload: bytes, + *, + user: str | User, + ) -> None: + temp_path = f"/tmp/openai-agents-write-{self.state.session_id.hex}-{uuid.uuid4().hex}" + try: + await _call_sailbox(self.sailbox.write, temp_path, payload) + # Sailbox's file API does not accept a user. Stage the bytes in /tmp, + # then copy into place from an exec running as the requested user so + # ownership and permission behavior match user-scoped writes. + result = await self.exec( + "sh", + "-lc", + 'mkdir -p "$(dirname "$2")" && cat "$1" > "$2"', + "sh", + temp_path, + sandbox_path_str(workspace_path), + shell=False, + user=user, + ) + if not result.ok(): + raise WorkspaceArchiveWriteError( + path=workspace_path, + context={ + "reason": "write_as_user_nonzero_exit", + "exit_code": result.exit_code, + "stdout": result.stdout.decode("utf-8", errors="replace"), + "stderr": result.stderr.decode("utf-8", errors="replace"), + }, + ) + except WorkspaceArchiveWriteError: + raise + except Exception as exc: + raise WorkspaceArchiveWriteError(path=workspace_path, cause=exc) from exc + finally: + try: + await self.exec("rm", "-f", temp_path, shell=False) + except Exception: + pass + async def running(self) -> bool: return self._sailbox is not None and self.state.status == "running" diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index aa5b18907a..6e5a1ded2c 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1213,6 +1213,51 @@ def test_write_accepts_text_payload(monkeypatch: pytest.MonkeyPatch) -> None: assert sailbox.files["/workspace/notes.txt"] == b"hello" +def test_write_with_user_stages_then_writes_through_user_exec( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _FakeSailbox() + session = _session(sailbox) + + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"), user="app")) + + temp_paths = [path for path in sailbox.files if path.startswith("/tmp/openai-agents-write-")] + assert len(temp_paths) == 1 + assert sailbox.files[temp_paths[0]] == b"hello" + assert "/workspace/notes.txt" not in sailbox.files + assert len(sailbox.exec_commands) == 3 + assert "sudo -u app --" in sailbox.exec_commands[0][0] + assert "sudo -u app --" in sailbox.exec_commands[1][0] + assert "cat \"$1\" > \"$2\"" in sailbox.exec_commands[1][0] + assert temp_paths[0] in sailbox.exec_commands[1][0] + assert "/workspace/notes.txt" in sailbox.exec_commands[1][0] + assert "sudo -u app --" not in sailbox.exec_commands[2][0] + assert temp_paths[0] in sailbox.exec_commands[2][0] + + +def test_write_with_user_nonzero_exec_maps_archive_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _ScriptedExecSailbox( + [ + _FakeExecResult(returncode=0), + _FakeExecResult(stdout="out", stderr="err", returncode=23), + _FakeExecResult(returncode=0), + ] + ) + session = _session(sailbox) + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"), user="app")) + + assert exc_info.value.context["reason"] == "write_as_user_nonzero_exit" + assert exc_info.value.context["exit_code"] == 23 + assert exc_info.value.context["stdout"] == "out" + assert exc_info.value.context["stderr"] == "err" + + def test_write_rejects_invalid_payload_type(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) session = _session(_FakeSailbox()) From 624e3fedfea2890111d1de2c6560609bdb869f88 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:15:51 -0400 Subject: [PATCH 11/22] Fix Sailbox user write without sudo --- .../extensions/sandbox/sailbox/sandbox.py | 76 ++++++++++++++----- tests/extensions/sandbox/test_sailbox.py | 13 ++-- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index e01fe352d7..ec1cd31ef6 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -486,22 +486,17 @@ async def write( *, user: str | User | None = None, ) -> None: - workspace_path: Path | None = None - if user is not None: - workspace_path = await self._check_write_with_exec(path, user=user) - payload = data.read() if isinstance(payload, str): payload = payload.encode("utf-8") if not isinstance(payload, bytes | bytearray): raise WorkspaceWriteTypeError(path=Path(path), actual_type=type(payload).__name__) + workspace_path = await self._validate_path_access(path, for_write=True) if user is not None: - assert workspace_path is not None await self._write_payload_as_user(workspace_path, bytes(payload), user=user) return - workspace_path = await self._validate_path_access(path, for_write=True) try: await _call_sailbox( self.sailbox.write, @@ -518,30 +513,69 @@ async def _write_payload_as_user( *, user: str | User, ) -> None: + user_name = user.name if isinstance(user, User) else user temp_path = f"/tmp/openai-agents-write-{self.state.session_id.hex}-{uuid.uuid4().hex}" + target_path = sandbox_path_str(workspace_path) + write_script = ( + 'tmp="$1"\n' + 'target="$2"\n' + 'if [ -e "$target" ]; then\n' + ' [ -f "$target" ] && [ -w "$target" ] || exit $?\n' + "else\n" + ' parent=$(dirname "$target")\n' + ' while [ ! -e "$parent" ]; do\n' + ' next=$(dirname "$parent")\n' + ' if [ "$next" = "$parent" ]; then\n' + " exit 1\n" + " fi\n" + ' parent="$next"\n' + " done\n" + ' [ -d "$parent" ] && [ -w "$parent" ] && [ -x "$parent" ] || exit $?\n' + "fi\n" + 'mkdir -p "$(dirname "$target")" && cat "$tmp" > "$target"\n' + ) try: await _call_sailbox(self.sailbox.write, temp_path, payload) # Sailbox's file API does not accept a user. Stage the bytes in /tmp, - # then copy into place from an exec running as the requested user so - # ownership and permission behavior match user-scoped writes. - result = await self.exec( - "sh", - "-lc", - 'mkdir -p "$(dirname "$2")" && cat "$1" > "$2"', - "sh", - temp_path, - sandbox_path_str(workspace_path), - shell=False, - user=user, + # then switch user inside the guest for the final copy. The base + # exec(user=...) wrapper uses sudo, which is not present in Sailbox + # base images, so this path uses runuser directly. + request = await _call_sailbox( + self.sailbox.exec, + " ".join( + [ + "runuser", + "-u", + shlex.quote(user_name), + "--", + "sh", + "-lc", + shlex.quote(write_script), + "sh", + shlex.quote(temp_path), + shlex.quote(target_path), + ] + ), ) - if not result.ok(): + result = await _call_sailbox(request.wait) + if result.returncode != 0: + stdout = ( + result.stdout + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8", errors="replace") + ) + stderr = ( + result.stderr + if isinstance(result.stderr, str) + else result.stderr.decode("utf-8", errors="replace") + ) raise WorkspaceArchiveWriteError( path=workspace_path, context={ "reason": "write_as_user_nonzero_exit", - "exit_code": result.exit_code, - "stdout": result.stdout.decode("utf-8", errors="replace"), - "stderr": result.stderr.decode("utf-8", errors="replace"), + "exit_code": result.returncode, + "stdout": stdout, + "stderr": stderr, }, ) except WorkspaceArchiveWriteError: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 6e5a1ded2c..2f843f9b87 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1226,14 +1226,12 @@ def test_write_with_user_stages_then_writes_through_user_exec( assert len(temp_paths) == 1 assert sailbox.files[temp_paths[0]] == b"hello" assert "/workspace/notes.txt" not in sailbox.files - assert len(sailbox.exec_commands) == 3 - assert "sudo -u app --" in sailbox.exec_commands[0][0] - assert "sudo -u app --" in sailbox.exec_commands[1][0] - assert "cat \"$1\" > \"$2\"" in sailbox.exec_commands[1][0] + assert len(sailbox.exec_commands) == 2 + assert sailbox.exec_commands[0][0].startswith("runuser -u app -- sh -lc") + assert "cat \"$tmp\" > \"$target\"" in sailbox.exec_commands[0][0] + assert temp_paths[0] in sailbox.exec_commands[0][0] + assert "/workspace/notes.txt" in sailbox.exec_commands[0][0] assert temp_paths[0] in sailbox.exec_commands[1][0] - assert "/workspace/notes.txt" in sailbox.exec_commands[1][0] - assert "sudo -u app --" not in sailbox.exec_commands[2][0] - assert temp_paths[0] in sailbox.exec_commands[2][0] def test_write_with_user_nonzero_exec_maps_archive_error( @@ -1242,7 +1240,6 @@ def test_write_with_user_nonzero_exec_maps_archive_error( monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) sailbox = _ScriptedExecSailbox( [ - _FakeExecResult(returncode=0), _FakeExecResult(stdout="out", stderr="err", returncode=23), _FakeExecResult(returncode=0), ] From 7818c48432f13124ff3481e3edfc47d50a3abb2f Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:17:24 -0400 Subject: [PATCH 12/22] Cover Sailbox user writes without sudo --- tests/extensions/sandbox/test_sailbox.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 2f843f9b87..f9d612b9f4 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -226,6 +226,13 @@ def write(self, path: str, data: bytes) -> None: raise RuntimeError("write failed") +class _NoSudoSailbox(_FakeSailbox): + def exec(self, command: str, *, timeout: int | None = None) -> Any: + if "sudo" in command: + raise RuntimeError("sudo: not found") + return super().exec(command, timeout=timeout) + + class _FailingListenerSailbox(_FakeSailbox): def listener(self, port: int) -> _FakeListener: _ = port @@ -1234,6 +1241,17 @@ def test_write_with_user_stages_then_writes_through_user_exec( assert temp_paths[0] in sailbox.exec_commands[1][0] +def test_write_with_user_does_not_require_sudo(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _NoSudoSailbox() + session = _session(sailbox) + + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"), user="app")) + + assert all("sudo" not in command for command, _ in sailbox.exec_commands) + assert sailbox.exec_commands[0][0].startswith("runuser -u app --") + + def test_write_with_user_nonzero_exec_maps_archive_error( monkeypatch: pytest.MonkeyPatch, ) -> None: From 6b9de01a4a73df52cbe7363aa0c545fb77a8401f Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:19:02 -0400 Subject: [PATCH 13/22] Cover Sailbox user write ownership --- tests/extensions/sandbox/test_sailbox.py | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index f9d612b9f4..95211103d1 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -3,6 +3,7 @@ import asyncio import io import json +import shlex import sys import tarfile import time @@ -233,6 +234,27 @@ def exec(self, command: str, *, timeout: int | None = None) -> Any: return super().exec(command, timeout=timeout) +class _OwnershipTrackingSailbox(_FakeSailbox): + def __init__(self) -> None: + super().__init__() + self.owners: dict[str, str] = {} + + def write(self, path: str, data: bytes) -> None: + super().write(path, data) + self.owners[path] = "root" + + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + if command.startswith("runuser "): + parts = shlex.split(command) + user = parts[2] + temp_path = parts[-2] + target_path = parts[-1] + self.files[target_path] = self.files[temp_path] + self.owners[target_path] = user + return _FakeExecRequest(_FakeExecResult(stdout="ok\n", returncode=0)) + + class _FailingListenerSailbox(_FakeSailbox): def listener(self, port: int) -> _FakeListener: _ = port @@ -1252,6 +1274,19 @@ def test_write_with_user_does_not_require_sudo(monkeypatch: pytest.MonkeyPatch) assert sailbox.exec_commands[0][0].startswith("runuser -u app --") +def test_write_with_user_creates_destination_as_requested_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _OwnershipTrackingSailbox() + session = _session(sailbox) + + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"), user="app")) + + assert sailbox.files["/workspace/notes.txt"] == b"hello" + assert sailbox.owners["/workspace/notes.txt"] == "app" + + def test_write_with_user_nonzero_exec_maps_archive_error( monkeypatch: pytest.MonkeyPatch, ) -> None: From 5de60b29c1966496d9ae5c022590fed9a30d5e89 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:23:13 -0400 Subject: [PATCH 14/22] Normalize Sailbox exec output --- .../extensions/sandbox/sailbox/sandbox.py | 16 ++++++++-- tests/extensions/sandbox/test_sailbox.py | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index ec1cd31ef6..5bd33ef751 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -100,6 +100,18 @@ def _sailbox_error_message(prefix: str, cause: BaseException) -> str: return prefix +def _sailbox_exec_output_bytes(value: object) -> bytes: + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, str): + return value.encode("utf-8", errors="replace") + if value is None: + return b"" + return str(value).encode("utf-8", errors="replace") + + class SailboxSandboxClientOptions(BaseSandboxClientOptions): """Client options for creating OpenAI Agents SDK sessions on Sailboxes.""" @@ -393,8 +405,8 @@ async def _exec_internal( ) from exc return ExecResult( - stdout=result.stdout.encode("utf-8", errors="replace"), - stderr=result.stderr.encode("utf-8", errors="replace"), + stdout=_sailbox_exec_output_bytes(result.stdout), + stderr=_sailbox_exec_output_bytes(result.stderr), exit_code=result.returncode, ) diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 95211103d1..cbd1323390 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -102,8 +102,8 @@ def test_sailbox_package_re_exports_backend_symbols() -> None: @dataclass class _FakeExecResult: - stdout: str = "" - stderr: str = "" + stdout: object = "" + stderr: object = "" returncode: int = 0 @@ -200,6 +200,12 @@ def exec(self, command: str, *, timeout: int | None = None) -> Any: return _FakeExecRequest(_FakeExecResult(stdout="out", stderr="err", returncode=7)) +class _BytesExecSailbox(_FakeSailbox): + def exec(self, command: str, *, timeout: int | None = None) -> Any: + self.exec_commands.append((command, timeout)) + return _FakeExecRequest(_FakeExecResult(stdout=b"\xffok\n", stderr=bytearray(b"\xfeerr\n"))) + + class _ScriptedExecSailbox(_FakeSailbox): def __init__(self, results: list[_FakeExecResult | BaseException]) -> None: super().__init__() @@ -1204,6 +1210,26 @@ def test_exec_nonzero_result_is_returned_to_caller() -> None: assert result.stderr == b"err" +def test_exec_accepts_bytes_stdout_and_stderr() -> None: + session = _session(_BytesExecSailbox()) + + result = asyncio.run(session.exec("printf ok")) + + assert result.exit_code == 0 + assert result.stdout == b"\xffok\n" + assert result.stderr == b"\xfeerr\n" + + +def test_exec_normalizes_none_stdout_and_non_string_stderr() -> None: + sailbox = _ScriptedExecSailbox([_FakeExecResult(stdout=None, stderr=123, returncode=0)]) + session = _session(sailbox) + + result = asyncio.run(session.exec("printf ok")) + + assert result.stdout == b"" + assert result.stderr == b"123" + + def test_exec_wait_failure_maps_transport_error() -> None: session = _session(_WaitFailingSailbox()) From 96ca917825eaef90b3fcfb3f2c9408378a47e3b9 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 16:29:59 -0400 Subject: [PATCH 15/22] Restore Sailbox image options from JSON --- .../extensions/sandbox/sailbox/sandbox.py | 84 +++++++++++-------- tests/extensions/sandbox/test_sailbox.py | 65 +++++++++++++- 2 files changed, 111 insertions(+), 38 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 5bd33ef751..77c2572b8d 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -112,6 +112,47 @@ def _sailbox_exec_output_bytes(value: object) -> bytes: return str(value).encode("utf-8", errors="replace") +def _serialize_sail_image(image: ImageDefinition | None) -> dict[str, str | None] | None: + if image is None: + return None + to_proto = getattr(image, "to_proto", None) + if not callable(to_proto): + return None + spec = to_proto() + serialize = getattr(spec, "SerializeToString", None) + if not callable(serialize): + return None + return { + "image_id": getattr(image, "_image_id", None), + "spec": base64.b64encode(serialize()).decode("ascii"), + } + + +def _deserialize_sail_image(image: object) -> object: + if isinstance(image, dict): + raw_spec = image.get("spec") + if not isinstance(raw_spec, str): + return None + raw_image_id = image.get("image_id") + image_id = raw_image_id if isinstance(raw_image_id, str) else None + try: + from sail.pb.image.v1 import image_pb2 + + spec = image_pb2.ImageSpec() + spec.ParseFromString(base64.b64decode(raw_spec)) + return ImageDefinition(spec, _image_id=image_id) + except Exception: + return None + if isinstance(image, str): + try: + from sail.pb.image.v1 import image_pb2 + + return ImageDefinition(image_pb2.ImageSpec(), _image_id=image) + except Exception: + return None + return image + + class SailboxSandboxClientOptions(BaseSandboxClientOptions): """Client options for creating OpenAI Agents SDK sessions on Sailboxes.""" @@ -185,10 +226,13 @@ def _deserialize_app(cls, app: object) -> object: return app @field_serializer("image", when_used="json") - def _serialize_image(self, image: ImageDefinition | None) -> str | None: - # ImageDefinition contains protobuf state. Options are not persisted by - # OpenAI Agents run state, but keep model_dump(mode="json") well-defined. - return getattr(image, "_image_id", None) if image is not None else None + def _serialize_image(self, image: ImageDefinition | None) -> dict[str, str | None] | None: + return _serialize_sail_image(image) + + @field_validator("image", mode="before") + @classmethod + def _deserialize_image(cls, image: object) -> object: + return _deserialize_sail_image(image) class SailboxSandboxSessionState(SandboxSessionState): @@ -210,40 +254,12 @@ class SailboxSandboxSessionState(SandboxSessionState): @field_serializer("image", when_used="json") def _serialize_image(self, image: ImageDefinition | None) -> dict[str, str | None] | None: - if image is None: - return None - to_proto = getattr(image, "to_proto", None) - if not callable(to_proto): - return None - spec = to_proto() - serialize = getattr(spec, "SerializeToString", None) - if not callable(serialize): - return None - return { - "image_id": getattr(image, "_image_id", None), - "spec": base64.b64encode(serialize()).decode("ascii"), - } + return _serialize_sail_image(image) @field_validator("image", mode="before") @classmethod def _deserialize_image(cls, image: object) -> object: - if isinstance(image, dict): - raw_spec = image.get("spec") - if not isinstance(raw_spec, str): - return None - raw_image_id = image.get("image_id") - image_id = raw_image_id if isinstance(raw_image_id, str) else None - try: - from sail.pb.image.v1 import image_pb2 - - spec = image_pb2.ImageSpec() - spec.ParseFromString(base64.b64decode(raw_spec)) - return ImageDefinition(spec, _image_id=image_id) - except Exception: - return None - if isinstance(image, str): - return None - return image + return _deserialize_sail_image(image) class SailboxSandboxSession(BaseSandboxSession): diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index cbd1323390..8980d4a80a 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -46,9 +46,29 @@ def find(*, name: str, mint_if_missing: bool = False) -> App: return App(id=f"app_{name}", name=name, created_at=0) +class _FakeImageSpec: + def __init__(self, payload: bytes = b"") -> None: + self.payload = payload + + def SerializeToString(self) -> bytes: + return self.payload + + def ParseFromString(self, payload: bytes) -> None: + self.payload = payload + + +@dataclass(frozen=True) +class ImageDefinition: + _spec: _FakeImageSpec + _image_id: str | None = None + + def to_proto(self) -> _FakeImageSpec: + return self._spec + + class Image: - debian_amd64 = object() - debian_arm64 = object() + debian_amd64 = ImageDefinition(_FakeImageSpec(b"debian-amd64")) + debian_arm64 = ImageDefinition(_FakeImageSpec(b"debian-arm64")) class _SdkSailbox: @@ -67,16 +87,25 @@ def _install_fake_sail_sdk() -> None: sail_module = types.ModuleType("sail") app_module = types.ModuleType("sail.app") image_module = types.ModuleType("sail.image") + sail_pb_module = types.ModuleType("sail.pb") + sail_pb_image_module = types.ModuleType("sail.pb.image") + sail_pb_image_v1_module = types.ModuleType("sail.pb.image.v1") + image_pb2_module = types.ModuleType("sail.pb.image.v1.image_pb2") sailbox_module = types.ModuleType("sail.sailbox") cast(Any, app_module).App = App cast(Any, image_module).Image = Image - cast(Any, image_module).ImageDefinition = object + cast(Any, image_module).ImageDefinition = ImageDefinition + cast(Any, image_pb2_module).ImageSpec = _FakeImageSpec cast(Any, sailbox_module).Sailbox = _SdkSailbox sys.modules.setdefault("sail", sail_module) sys.modules["sail.app"] = app_module sys.modules["sail.image"] = image_module + sys.modules["sail.pb"] = sail_pb_module + sys.modules["sail.pb.image"] = sail_pb_image_module + sys.modules["sail.pb.image.v1"] = sail_pb_image_v1_module + sys.modules["sail.pb.image.v1.image_pb2"] = image_pb2_module sys.modules["sail.sailbox"] = sailbox_module @@ -425,7 +454,7 @@ def test_options_json_dump_serializes_sdk_objects() -> None: assert dumped["type"] == "sailbox" assert dumped["app"] == {"id": "app_test", "name": "agents", "created_at": 1} - assert dumped["image"] is None + assert dumped["image"] == {"image_id": None, "spec": "ZGViaWFuLWFtZDY0"} assert dumped["exposed_ports"] == [8080] @@ -443,6 +472,34 @@ def test_options_json_roundtrip_preserves_app() -> None: assert parsed.app == App(id="app_test", name="agents", created_at=1) +def test_options_json_roundtrip_preserves_image_definition() -> None: + options = SailboxSandboxClientOptions( + image=ImageDefinition(_FakeImageSpec(b"custom-image"), _image_id="img_123"), + exposed_ports=(8080,), + ) + + parsed = BaseSandboxClientOptions.parse(options.model_dump(mode="json")) + + assert isinstance(parsed, SailboxSandboxClientOptions) + assert isinstance(parsed.image, ImageDefinition) + assert parsed.image._image_id == "img_123" + assert parsed.image.to_proto().SerializeToString() == b"custom-image" + assert parsed.exposed_ports == (8080,) + + +def test_options_json_parse_accepts_legacy_image_id_string() -> None: + parsed = BaseSandboxClientOptions.parse( + { + "type": "sailbox", + "image": "img_legacy", + } + ) + + assert isinstance(parsed, SailboxSandboxClientOptions) + assert isinstance(parsed.image, ImageDefinition) + assert parsed.image._image_id == "img_legacy" + + def test_options_json_parse_accepts_legacy_app_id_string() -> None: parsed = BaseSandboxClientOptions.parse( { From 019516f0bcc587cb529dc98167cc02fb95073674 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:03:08 -0400 Subject: [PATCH 16/22] Bypass sudo for Sailbox read user checks --- .../extensions/sandbox/sailbox/sandbox.py | 55 ++++++++++++++++++- tests/extensions/sandbox/test_sailbox.py | 34 ++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 77c2572b8d..618037b26a 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -491,10 +491,10 @@ async def read( *, user: str | User | None = None, ) -> io.IOBase: + error_path = Path(path) if user is not None: - await self._check_read_with_exec(path, user=user) + await self._check_read_as_user(path, error_path=error_path, user=user) - error_path = Path(path) workspace_path = await self._validate_path_access(path) try: data = await _call_sailbox( @@ -507,6 +507,57 @@ async def read( raise WorkspaceArchiveReadError(path=error_path, cause=exc) from exc return io.BytesIO(data) + async def _check_read_as_user( + self, + path: Path | str, + *, + error_path: Path, + user: str | User, + ) -> Path: + workspace_path = await self._validate_path_access(path) + user_name = user.name if isinstance(user, User) else user + path_arg = sandbox_path_str(workspace_path) + try: + request = await _call_sailbox( + self.sailbox.exec, + " ".join( + [ + "runuser", + "-u", + shlex.quote(user_name), + "--", + "sh", + "-lc", + shlex.quote('[ -r "$1" ]'), + "sh", + shlex.quote(path_arg), + ] + ), + ) + result = await _call_sailbox(request.wait) + except Exception as exc: + raise WorkspaceArchiveReadError(path=error_path, cause=exc) from exc + if result.returncode != 0: + stdout = ( + result.stdout + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8", errors="replace") + ) + stderr = ( + result.stderr + if isinstance(result.stderr, str) + else result.stderr.decode("utf-8", errors="replace") + ) + raise WorkspaceReadNotFoundError( + path=error_path, + context={ + "command": ["runuser", "-u", user_name, "--", "sh", "-lc", ""], + "stdout": stdout, + "stderr": stderr, + }, + ) + return workspace_path + async def write( self, path: Path | str, diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 8980d4a80a..7f86385328 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1315,6 +1315,40 @@ def test_read_generic_failure_maps_archive_error(monkeypatch: pytest.MonkeyPatch asyncio.run(session.read(Path("notes.txt"))) +def test_read_with_user_checks_access_without_sudo(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _NoSudoSailbox() + sailbox.files["/workspace/notes.txt"] = b"hello" + session = _session(sailbox) + + result = asyncio.run(session.read(Path("notes.txt"), user="app")) + + assert result.read() == b"hello" + assert all("sudo" not in command for command, _ in sailbox.exec_commands) + assert sailbox.exec_commands[0][0].startswith("runuser -u app --") + + +def test_read_with_user_denied_maps_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _ScriptedExecSailbox([_FakeExecResult(stdout="out", stderr="err", returncode=1)]) + session = _session(sailbox) + + with pytest.raises(WorkspaceReadNotFoundError) as exc_info: + asyncio.run(session.read(Path("notes.txt"), user="app")) + + assert exc_info.value.context["command"] == [ + "runuser", + "-u", + "app", + "--", + "sh", + "-lc", + "", + ] + assert exc_info.value.context["stdout"] == "out" + assert exc_info.value.context["stderr"] == "err" + + def test_write_accepts_text_payload(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) sailbox = _FakeSailbox() From 3bcba0f918e7b79dc49feecf82ecc918ac4a5e5c Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:21:55 -0400 Subject: [PATCH 17/22] Recheck Sailbox liveness before reporting running --- .../extensions/sandbox/sailbox/sandbox.py | 17 ++++- tests/extensions/sandbox/test_sailbox.py | 76 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 618037b26a..b5b57259cc 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -668,7 +668,22 @@ async def _write_payload_as_user( pass async def running(self) -> bool: - return self._sailbox is not None and self.state.status == "running" + if self._sailbox is None: + return False + sailbox_id = self.state.sailbox_id or self._sailbox.sailbox_id + if not sailbox_id: + return False + try: + info = await _call_sailbox(Sailbox.get, sailbox_id) + except LookupError: + self.state.status = "terminated" + object.__setattr__(self._sailbox, "status", "terminated") + return False + except Exception: + return False + self.state.status = info.status + object.__setattr__(self._sailbox, "status", info.status) + return info.status == "running" async def persist_workspace(self) -> io.IOBase: root = self.state.manifest.root diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 7f86385328..33bf8c98df 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -82,6 +82,11 @@ def connect(sailbox_id: str) -> _SdkSailbox: _ = sailbox_id raise NotImplementedError + @staticmethod + def get(sailbox_id: str) -> object: + _ = sailbox_id + raise NotImplementedError + def _install_fake_sail_sdk() -> None: sail_module = types.ModuleType("sail") @@ -1072,15 +1077,78 @@ def test_session_state_defaults_are_serializable() -> None: assert payload["disk_gib"] == 8 -def test_running_false_without_backend_or_when_paused() -> None: +def test_running_false_without_backend() -> None: state = _state(_FakeSailbox()) state.status = "paused" without_backend = SailboxSandboxSession.from_state(state, sailbox=None) - paused = SailboxSandboxSession.from_state(state, sailbox=_FakeSailbox()) - paused.state.status = "paused" assert asyncio.run(without_backend.running()) is False - assert asyncio.run(paused.running()) is False + + +def test_running_rechecks_backend_status(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + def fake_get(sailbox_id: str) -> object: + calls.append(sailbox_id) + return types.SimpleNamespace(status="running") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.get", + staticmethod(fake_get), + ) + sailbox = _FakeSailbox("sb-running") + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + assert asyncio.run(session.running()) is True + assert calls == ["sb-running"] + assert session.state.status == "running" + assert sailbox.status == "running" + + +def test_running_returns_false_when_backend_is_paused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.get", + staticmethod(lambda sailbox_id: types.SimpleNamespace(status="paused")), + ) + sailbox = _FakeSailbox("sb-paused") + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + session.state.status = "running" + sailbox.status = "running" + + assert asyncio.run(session.running()) is False + assert session.state.status == "paused" + assert sailbox.status == "paused" + + +def test_running_returns_false_when_backend_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(_sailbox_id: str) -> object: + raise LookupError("missing") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.get", + staticmethod(fake_get), + ) + sailbox = _FakeSailbox("sb-missing") + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + assert asyncio.run(session.running()) is False + assert session.state.status == "terminated" + assert sailbox.status == "terminated" + + +def test_running_returns_false_on_status_lookup_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(_sailbox_id: str) -> object: + raise RuntimeError("status unavailable") + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox.Sailbox.get", + staticmethod(fake_get), + ) + sailbox = _FakeSailbox("sb-unavailable") + session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + + assert asyncio.run(session.running()) is False + assert session.state.status == "running" def test_set_sailbox_updates_state_fields() -> None: From 6c1964349cf7ce51e571e43ba883ce6c372e4144 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:26:01 -0400 Subject: [PATCH 18/22] Terminate Sailbox resources on delete --- .../extensions/sandbox/sailbox/sandbox.py | 21 ++++++- tests/extensions/sandbox/test_sailbox.py | 57 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index b5b57259cc..7532eaf401 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -858,8 +858,25 @@ async def delete(self, session: SandboxSession) -> SandboxSession: inner = session._inner if not isinstance(inner, SailboxSandboxSession): raise TypeError("SailboxSandboxClient.delete expects a SailboxSandboxSession") - # The OpenAI Agents cleanup lifecycle calls session.shutdown() before - # delete(). Sailbox shutdown already pauses or terminates the backend. + + sailbox = inner._sailbox + if sailbox is None: + if not inner.state.sailbox_id: + return session + try: + sailbox = await _call_sailbox(_connect_sailbox, inner.state.sailbox_id) + except Exception: + return session + + try: + await _call_sailbox(sailbox.terminate) + except Exception: + return session + + inner._sailbox = sailbox + inner.state.status = "terminated" + inner.state.worker_address = "" + object.__setattr__(sailbox, "status", "terminated") return session async def resume(self, state: SandboxSessionState) -> SandboxSession: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 33bf8c98df..b4b539059a 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1648,6 +1648,63 @@ def test_shutdown_terminate_failure_propagates() -> None: asyncio.run(session.shutdown()) +def test_client_delete_terminates_live_sailbox() -> None: + sailbox = _FakeSailbox("sb-delete") + state = _state(sailbox) + state.pause_on_exit = True + inner = SailboxSandboxSession.from_state(state, sailbox=sailbox) + session = SandboxSession(inner) + + asyncio.run(SailboxSandboxClient().delete(session)) + + assert sailbox.terminated is True + assert sailbox.paused is False + assert inner.state.status == "terminated" + assert inner.state.worker_address == "" + + +def test_client_delete_terminates_after_pause_on_exit_shutdown() -> None: + sailbox = _FakeSailbox("sb-delete-paused") + state = _state(sailbox) + state.pause_on_exit = True + inner = SailboxSandboxSession.from_state(state, sailbox=sailbox) + session = SandboxSession(inner) + + asyncio.run(inner.shutdown()) + assert sailbox.paused is True + assert inner.state.status == "paused" + + asyncio.run(SailboxSandboxClient().delete(session)) + + assert sailbox.terminated is True + assert inner.state.status == "terminated" + + +def test_client_delete_reconnects_and_terminates_without_live_handle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sailbox = _FakeSailbox("sb-delete-reconnect") + state = _state(sailbox) + inner = SailboxSandboxSession.from_state(state, sailbox=None) + session = SandboxSession(inner) + reconnected: list[str] = [] + + def fake_connect(sailbox_id: str) -> _FakeSailbox: + reconnected.append(sailbox_id) + return sailbox + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", + fake_connect, + ) + + asyncio.run(SailboxSandboxClient().delete(session)) + + assert reconnected == ["sb-delete-reconnect"] + assert sailbox.terminated is True + assert inner.state.status == "terminated" + + def test_client_delete_rejects_non_sailbox_session() -> None: class _OtherInner(BaseSandboxSession): state: SandboxSessionState From 9175c7ed9b3516554e65cb10381cac20bfa90681 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:33:33 -0400 Subject: [PATCH 19/22] Normalize Sailbox command error output --- .../extensions/sandbox/sailbox/sandbox.py | 58 +++++-------------- tests/extensions/sandbox/test_sailbox.py | 55 ++++++++++++++++++ 2 files changed, 71 insertions(+), 42 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 7532eaf401..b95ab35567 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -112,6 +112,10 @@ def _sailbox_exec_output_bytes(value: object) -> bytes: return str(value).encode("utf-8", errors="replace") +def _sailbox_exec_output_text(value: object) -> str: + return _sailbox_exec_output_bytes(value).decode("utf-8", errors="replace") + + def _serialize_sail_image(image: ImageDefinition | None) -> dict[str, str | None] | None: if image is None: return None @@ -363,22 +367,12 @@ async def _prepare_backend_workspace(self) -> None: message=_sailbox_error_message("Sailbox workspace root preparation failed", exc), ) from exc if result.returncode != 0: - stdout = ( - result.stdout - if isinstance(result.stdout, str) - else result.stdout.decode("utf-8", errors="replace") - ) - stderr = ( - result.stderr - if isinstance(result.stderr, str) - else result.stderr.decode("utf-8", errors="replace") - ) raise WorkspaceStartError( path=self._workspace_root_path(), context={ "exit_code": result.returncode, - "stdout": stdout, - "stderr": stderr, + "stdout": _sailbox_exec_output_text(result.stdout), + "stderr": _sailbox_exec_output_text(result.stderr), }, ) @@ -538,22 +532,12 @@ async def _check_read_as_user( except Exception as exc: raise WorkspaceArchiveReadError(path=error_path, cause=exc) from exc if result.returncode != 0: - stdout = ( - result.stdout - if isinstance(result.stdout, str) - else result.stdout.decode("utf-8", errors="replace") - ) - stderr = ( - result.stderr - if isinstance(result.stderr, str) - else result.stderr.decode("utf-8", errors="replace") - ) raise WorkspaceReadNotFoundError( path=error_path, context={ "command": ["runuser", "-u", user_name, "--", "sh", "-lc", ""], - "stdout": stdout, - "stderr": stderr, + "stdout": _sailbox_exec_output_text(result.stdout), + "stderr": _sailbox_exec_output_text(result.stderr), }, ) return workspace_path @@ -638,23 +622,13 @@ async def _write_payload_as_user( ) result = await _call_sailbox(request.wait) if result.returncode != 0: - stdout = ( - result.stdout - if isinstance(result.stdout, str) - else result.stdout.decode("utf-8", errors="replace") - ) - stderr = ( - result.stderr - if isinstance(result.stderr, str) - else result.stderr.decode("utf-8", errors="replace") - ) raise WorkspaceArchiveWriteError( path=workspace_path, context={ "reason": "write_as_user_nonzero_exit", "exit_code": result.returncode, - "stdout": stdout, - "stderr": stderr, + "stdout": _sailbox_exec_output_text(result.stdout), + "stderr": _sailbox_exec_output_text(result.stderr), }, ) except WorkspaceArchiveWriteError: @@ -703,8 +677,8 @@ async def persist_workspace(self) -> io.IOBase: path=self._workspace_root_path(), context={ "exit_code": result.exit_code, - "stdout": result.stdout.decode("utf-8", errors="replace"), - "stderr": result.stderr.decode("utf-8", errors="replace"), + "stdout": _sailbox_exec_output_text(result.stdout), + "stderr": _sailbox_exec_output_text(result.stderr), }, ) data = await _call_sailbox(self.sailbox.read, archive_path) @@ -753,8 +727,8 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: path=self._workspace_root_path(), context={ "exit_code": mkdir.exit_code, - "stdout": mkdir.stdout.decode("utf-8", errors="replace"), - "stderr": mkdir.stderr.decode("utf-8", errors="replace"), + "stdout": _sailbox_exec_output_text(mkdir.stdout), + "stderr": _sailbox_exec_output_text(mkdir.stderr), }, ) result = await self.exec( @@ -770,8 +744,8 @@ async def hydrate_workspace(self, data: io.IOBase) -> None: path=self._workspace_root_path(), context={ "exit_code": result.exit_code, - "stdout": result.stdout.decode("utf-8", errors="replace"), - "stderr": result.stderr.decode("utf-8", errors="replace"), + "stdout": _sailbox_exec_output_text(result.stdout), + "stderr": _sailbox_exec_output_text(result.stderr), }, ) except WorkspaceArchiveWriteError: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index b4b539059a..ff9c0f9292 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -141,6 +141,11 @@ class _FakeExecResult: returncode: int = 0 +class _OpaqueOutput: + def __str__(self) -> str: + return "opaque-output" + + class _FakeExecRequest: def __init__(self, result: _FakeExecResult) -> None: self._result = result @@ -1250,6 +1255,20 @@ def test_prepare_backend_workspace_nonzero_raises_start_error() -> None: assert exc_info.value.context["stderr"] == "err" +def test_prepare_backend_workspace_normalizes_non_string_output() -> None: + sailbox = _ScriptedExecSailbox( + [_FakeExecResult(stdout=None, stderr=_OpaqueOutput(), returncode=7)] + ) + session = _session(sailbox) + + with pytest.raises(WorkspaceStartError) as exc_info: + asyncio.run(session._prepare_backend_workspace()) + + assert exc_info.value.context["exit_code"] == 7 + assert exc_info.value.context["stdout"] == "" + assert exc_info.value.context["stderr"] == "opaque-output" + + def test_prepare_backend_workspace_wait_failure_maps_start_error() -> None: session = _session(_WaitFailingSailbox()) @@ -1417,6 +1436,22 @@ def test_read_with_user_denied_maps_not_found(monkeypatch: pytest.MonkeyPatch) - assert exc_info.value.context["stderr"] == "err" +def test_read_with_user_denied_normalizes_non_string_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _ScriptedExecSailbox( + [_FakeExecResult(stdout=None, stderr=_OpaqueOutput(), returncode=1)] + ) + session = _session(sailbox) + + with pytest.raises(WorkspaceReadNotFoundError) as exc_info: + asyncio.run(session.read(Path("notes.txt"), user="app")) + + assert exc_info.value.context["stdout"] == "" + assert exc_info.value.context["stderr"] == "opaque-output" + + def test_write_accepts_text_payload(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) sailbox = _FakeSailbox() @@ -1493,6 +1528,26 @@ def test_write_with_user_nonzero_exec_maps_archive_error( assert exc_info.value.context["stderr"] == "err" +def test_write_with_user_nonzero_exec_normalizes_non_string_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) + sailbox = _ScriptedExecSailbox( + [ + _FakeExecResult(stdout=None, stderr=_OpaqueOutput(), returncode=23), + _FakeExecResult(returncode=0), + ] + ) + session = _session(sailbox) + + with pytest.raises(WorkspaceArchiveWriteError) as exc_info: + asyncio.run(session.write(Path("notes.txt"), io.BytesIO(b"hello"), user="app")) + + assert exc_info.value.context["reason"] == "write_as_user_nonzero_exit" + assert exc_info.value.context["stdout"] == "" + assert exc_info.value.context["stderr"] == "opaque-output" + + def test_write_rejects_invalid_payload_type(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(SailboxSandboxSession, "_validate_path_access", _validate_direct_path) session = _session(_FakeSailbox()) From b8e6f32ed6c85e8a60c7cf11b919cd99d0c441e9 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:36:10 -0400 Subject: [PATCH 20/22] Resume paused Sailbox after reconnect --- .../extensions/sandbox/sailbox/sandbox.py | 39 +++++++++++-------- tests/extensions/sandbox/test_sailbox.py | 20 ++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index b95ab35567..012945c40a 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -303,25 +303,30 @@ def _set_sailbox(self, sailbox: Sailbox) -> None: self.state.worker_address = sailbox.worker_address self.state.exec_endpoint = sailbox.exec_endpoint + async def _resume_sailbox(self, sailbox: Sailbox) -> None: + try: + resumed = await _call_sailbox(sailbox.resume) + except Exception as exc: + raise WorkspaceStartError( + path=self._workspace_root_path(), + context=_sailbox_error_context( + cause=exc, + extra={ + "reason": "resume_failed", + "sailbox_id": self.state.sailbox_id or sailbox.sailbox_id, + }, + ), + cause=exc, + message=_sailbox_error_message("Sailbox resume failed", exc), + ) from exc + if resumed is not None: + sailbox = cast(Sailbox, resumed) + self._set_sailbox(sailbox) + async def _ensure_backend_started(self) -> None: if self._sailbox is not None: if self._sailbox.status != "running": - try: - await _call_sailbox(self._sailbox.resume) - except Exception as exc: - raise WorkspaceStartError( - path=self._workspace_root_path(), - context=_sailbox_error_context( - cause=exc, - extra={ - "reason": "resume_failed", - "sailbox_id": self.state.sailbox_id, - }, - ), - cause=exc, - message=_sailbox_error_message("Sailbox resume failed", exc), - ) from exc - self._set_sailbox(self._sailbox) + await self._resume_sailbox(self._sailbox) return if not self.state.sailbox_id: @@ -346,6 +351,8 @@ async def _ensure_backend_started(self) -> None: message=_sailbox_error_message("Sailbox connect failed", exc), ) from exc self._set_sailbox(sailbox) + if sailbox.status != "running": + await self._resume_sailbox(sailbox) self._set_start_state_preserved(True) async def _prepare_backend_workspace(self) -> None: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index ff9c0f9292..8a4abd9871 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1225,6 +1225,26 @@ def test_ensure_backend_started_connects_existing_sailbox( assert session._workspace_state_preserved_on_start() is True +def test_ensure_backend_started_resumes_reconnected_paused_sailbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connected = _FakeSailbox("sb-connect-paused") + connected.status = "paused" + + monkeypatch.setattr( + "agents.extensions.sandbox.sailbox.sandbox._connect_sailbox", + lambda sailbox_id: connected, + ) + + session = SailboxSandboxSession.from_state(_state(_FakeSailbox("sb-connect-paused"))) + asyncio.run(session._ensure_backend_started()) + + assert session.sailbox is connected + assert connected.status == "running" + assert session.state.status == "running" + assert session._workspace_state_preserved_on_start() is True + + def test_ensure_backend_started_connect_failure_maps_start_error( monkeypatch: pytest.MonkeyPatch, ) -> None: From fb99393dd066a2b212e685b50295c5cd82d0dd48 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Mon, 25 May 2026 23:37:57 -0400 Subject: [PATCH 21/22] Gate Sailbox client option test imports --- tests/sandbox/test_client_options.py | 34 +++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index a2fd477f44..0cf5ff05c0 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -1,15 +1,14 @@ from __future__ import annotations import importlib +from collections.abc import Callable from typing import Literal import pytest -from sail.app import App from agents.extensions.sandbox.cloudflare import CloudflareSandboxClientOptions from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions from agents.extensions.sandbox.e2b import E2BSandboxClientOptions -from agents.extensions.sandbox.sailbox import SailboxSandboxClientOptions from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.sandboxes import DockerSandboxClientOptions, UnixLocalSandboxClientOptions from agents.sandbox.session import BaseSandboxClientOptions @@ -63,24 +62,33 @@ def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() -> } +def _sailbox_client_options() -> BaseSandboxClientOptions: + sailbox_module = pytest.importorskip("agents.extensions.sandbox.sailbox") + app_module = pytest.importorskip("sail.app") + return sailbox_module.SailboxSandboxClientOptions( + app=app_module.App(id="app_test", name="agents", created_at=1), + app_name=None, + exposed_ports=(8080,), + ) + + @pytest.mark.parametrize( - "options", + "options_factory", [ - DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE, exposed_ports=(8080,)), - UnixLocalSandboxClientOptions(exposed_ports=(8080,)), - E2BSandboxClientOptions(sandbox_type="e2b", template="base"), - DaytonaSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), - CloudflareSandboxClientOptions(worker_url="https://example.com"), - SailboxSandboxClientOptions( - app=App(id="app_test", name="agents", created_at=1), - app_name=None, - exposed_ports=(8080,), + lambda: DockerSandboxClientOptions( + image=DEFAULT_PYTHON_SANDBOX_IMAGE, exposed_ports=(8080,) ), + lambda: UnixLocalSandboxClientOptions(exposed_ports=(8080,)), + lambda: E2BSandboxClientOptions(sandbox_type="e2b", template="base"), + lambda: DaytonaSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), + lambda: CloudflareSandboxClientOptions(worker_url="https://example.com"), + _sailbox_client_options, ], ) def test_sandbox_client_options_roundtrip_preserves_concrete_type( - options: BaseSandboxClientOptions, + options_factory: Callable[[], BaseSandboxClientOptions], ) -> None: + options = options_factory() payload = options.model_dump(mode="json") restored = BaseSandboxClientOptions.parse(payload) From 75ac18f154407d72da7ae73adda463633c8c07d9 Mon Sep 17 00:00:00 2001 From: Nirvik Baruah Date: Tue, 26 May 2026 09:03:23 -0400 Subject: [PATCH 22/22] Mark resumed Sailbox sessions preserved --- src/agents/extensions/sandbox/sailbox/sandbox.py | 1 + tests/extensions/sandbox/test_sailbox.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/agents/extensions/sandbox/sailbox/sandbox.py b/src/agents/extensions/sandbox/sailbox/sandbox.py index 012945c40a..97b24670fa 100644 --- a/src/agents/extensions/sandbox/sailbox/sandbox.py +++ b/src/agents/extensions/sandbox/sailbox/sandbox.py @@ -327,6 +327,7 @@ async def _ensure_backend_started(self) -> None: if self._sailbox is not None: if self._sailbox.status != "running": await self._resume_sailbox(self._sailbox) + self._set_start_state_preserved(True) return if not self.state.sailbox_id: diff --git a/tests/extensions/sandbox/test_sailbox.py b/tests/extensions/sandbox/test_sailbox.py index 8a4abd9871..02e57b565b 100644 --- a/tests/extensions/sandbox/test_sailbox.py +++ b/tests/extensions/sandbox/test_sailbox.py @@ -1176,11 +1176,13 @@ def test_ensure_backend_started_resumes_paused_sailbox() -> None: sailbox = _FakeSailbox("sb-paused") sailbox.status = "paused" session = SailboxSandboxSession.from_state(_state(sailbox), sailbox=sailbox) + assert session._workspace_state_preserved_on_start() is False asyncio.run(session._ensure_backend_started()) assert sailbox.status == "running" assert session.state.status == "running" + assert session._workspace_state_preserved_on_start() is True def test_ensure_backend_started_resume_failure_maps_start_error() -> None: