diff --git a/docs/ref/extensions/sandbox/islo/mounts.md b/docs/ref/extensions/sandbox/islo/mounts.md new file mode 100644 index 0000000000..3eaf59973a --- /dev/null +++ b/docs/ref/extensions/sandbox/islo/mounts.md @@ -0,0 +1,3 @@ +# `Mounts` + +::: agents.extensions.sandbox.islo.mounts diff --git a/docs/ref/extensions/sandbox/islo/sandbox.md b/docs/ref/extensions/sandbox/islo/sandbox.md new file mode 100644 index 0000000000..53016cf5bf --- /dev/null +++ b/docs/ref/extensions/sandbox/islo/sandbox.md @@ -0,0 +1,3 @@ +# `Sandbox` + +::: agents.extensions.sandbox.islo.sandbox diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index bd21da63d3..5a00dd903f 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -94,6 +94,7 @@ For provider-specific setup notes and links for the checked-in extension example | `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) | | `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) | | `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) | +| `IsloSandboxClient` | `openai-agents[islo]` | [Islo runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/islo_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) | | `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) | @@ -112,11 +113,17 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac | `BlaxelSandboxClient` | Supports cloud bucket mounts with `BlaxelCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and `GCSMount`. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy` from `agents.extensions.sandbox.blaxel`. | | `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`. | +| `IsloSandboxClient` | Supports rclone-backed cloud storage mounts with `IsloCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. Exposed-port URL resolution is not currently exposed. | | `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. | | `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. | +`IsloSandboxClient` accepts a `base_url` value for the Islo control API and +defaults to `ISLO_BASE_URL` or `https://api.islo.dev`. Newer Islo SDK versions +may also accept `compute_url` for compute-plane traffic; leave it unset when +using an Islo SDK version that does not expose `AsyncIslo(compute_url=...)`. + The table below summarizes which remote storage entries each backend can mount directly.
@@ -129,6 +136,7 @@ The table below summarizes which remote storage entries each backend can mount d | `BlaxelSandboxClient` | ✓ | ✓ | ✓ | - | - | - | | `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | +| `IsloSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | ✓ | - | | `VercelSandboxClient` | - | - | - | - | - | - | diff --git a/examples/run_examples.py b/examples/run_examples.py index 54038b9f48..7ab87d2b9c 100644 --- a/examples/run_examples.py +++ b/examples/run_examples.py @@ -80,6 +80,7 @@ "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", + "examples/sandbox/extensions/islo_runner.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", "examples/sandbox/extensions/vercel_runner.py", "examples/sandbox/memory_s3.py", diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md index 837d9dfa28..7b0a7d5360 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, Blaxel, Vercel, or Islo. 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. @@ -163,6 +163,53 @@ Cloudflare sandboxes support native cloud bucket mounts through `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. +## Islo + +### Setup + +Install the repo extra: + +```bash +uv sync --extra islo +``` + +Export the required environment variables: + +```bash +export OPENAI_API_KEY=... +export ISLO_API_KEY=... +``` + +If you are using a non-default Islo control API endpoint, also export +`ISLO_BASE_URL` or pass `--base-url`. Newer Islo SDK versions may also support +`ISLO_COMPUTE_URL` or `--compute-url` for compute-plane traffic. + +### Run + +```bash +uv run python examples/sandbox/extensions/islo_runner.py --stream +``` + +Useful flags: + +- `--workspace-persistence tar` +- `--workspace-persistence snapshot` +- `--base-url ` +- `--compute-url ` (requires Islo SDK support) +- `--image ` +- `--vcpus 4` +- `--memory-mb 8192` +- `--disk-gb 20` +- `--snapshot-name ` +- `--pause-on-exit` + +The Islo example covers command execution, workspace materialization, and +stop/resume persistence. Islo cloud bucket mounts are available through +`IsloCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, `GCSMount`, +`AzureBlobMount`, and `BoxMount`. PTY and exposed-port URL resolution are +intentionally not demonstrated until the Islo SDK exposes typed APIs for those +features. + ## What to expect Each script asks the model to inspect a small workspace and summarize it. A diff --git a/examples/sandbox/extensions/islo_runner.py b/examples/sandbox/extensions/islo_runner.py new file mode 100644 index 0000000000..137aa59361 --- /dev/null +++ b/examples/sandbox/extensions/islo_runner.py @@ -0,0 +1,281 @@ +""" +Minimal Islo-backed sandbox example for manual validation. + +This example creates a tiny workspace, verifies stop/resume persistence, and lets +the agent inspect the workspace through one shell tool. +""" + +import argparse +import asyncio +import io +import os +import sys +import tempfile +from pathlib import Path +from typing import Literal + +from openai.types.responses import ResponseTextDeltaEvent + +from agents import ModelSettings, Runner +from agents.run import RunConfig +from agents.sandbox import LocalSnapshotSpec, 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 agents.extensions.sandbox import ( + IsloSandboxClient, + IsloSandboxClientOptions, + ) +except Exception as exc: # pragma: no cover - import path depends on optional extras + raise SystemExit( + "Islo sandbox examples require the optional repo extra.\n" + "Install it with: uv sync --extra islo" + ) from exc + + +DEFAULT_QUESTION = "Summarize this Islo sandbox workspace in 2 sentences." +SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt") +SNAPSHOT_CHECK_CONTENT = "islo snapshot round-trip ok\n" + + +def _build_manifest() -> Manifest: + return text_manifest( + { + "README.md": ( + "# Renewal Notes\n\n" + "This workspace contains a tiny account review packet for manual sandbox testing.\n" + ), + "customer.md": ( + "# Customer\n\n" + "- Name: Northwind Health.\n" + "- Renewal date: 2026-04-15.\n" + "- Risk: unresolved SSO setup.\n" + ), + "next_steps.md": ( + "# Next steps\n\n" + "1. Finish the SSO fix.\n" + "2. Confirm legal language before procurement review.\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 _options( + *, + base_url: str | None, + compute_url: str | None, + image: str | None, + vcpus: int | None, + memory_mb: int | None, + disk_gb: int | None, + snapshot_name: str | None, + pause_on_exit: bool, + workspace_persistence: Literal["tar", "snapshot"], +) -> IsloSandboxClientOptions: + return IsloSandboxClientOptions( + base_url=base_url, + compute_url=compute_url, + image=image, + vcpus=vcpus, + memory_mb=memory_mb, + disk_gb=disk_gb, + snapshot_name=snapshot_name, + pause_on_exit=pause_on_exit, + workspace_persistence=workspace_persistence, + ) + + +async def _verify_stop_resume( + *, + base_url: str | None, + compute_url: str | None, + image: str | None, + vcpus: int | None, + memory_mb: int | None, + disk_gb: int | None, + snapshot_name: str | None, + pause_on_exit: bool, + workspace_persistence: Literal["tar", "snapshot"], +) -> None: + client = IsloSandboxClient(base_url=base_url, compute_url=compute_url) + with tempfile.TemporaryDirectory(prefix="islo-snapshot-example-") as snapshot_dir: + sandbox = await client.create( + manifest=_build_manifest(), + snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)), + options=_options( + base_url=base_url, + compute_url=compute_url, + image=image, + vcpus=vcpus, + memory_mb=memory_mb, + disk_gb=disk_gb, + snapshot_name=snapshot_name, + pause_on_exit=pause_on_exit, + workspace_persistence=workspace_persistence, + ), + ) + + try: + await sandbox.start() + await sandbox.write( + SNAPSHOT_CHECK_PATH, + io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")), + ) + await sandbox.stop() + finally: + await sandbox.shutdown() + + resumed_sandbox = await client.resume(sandbox.state) + try: + await resumed_sandbox.start() + restored = await resumed_sandbox.read(SNAPSHOT_CHECK_PATH) + restored_text = restored.read() + if isinstance(restored_text, bytes): + restored_text = restored_text.decode("utf-8") + if restored_text != SNAPSHOT_CHECK_CONTENT: + raise RuntimeError( + "Snapshot resume verification failed: " + f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}" + ) + finally: + await resumed_sandbox.shutdown() + + print(f"snapshot round-trip ok (islo, {workspace_persistence})") + + +async def main( + *, + model: str, + question: str, + base_url: str | None, + compute_url: str | None, + image: str | None, + vcpus: int | None, + memory_mb: int | None, + disk_gb: int | None, + snapshot_name: str | None, + pause_on_exit: bool, + workspace_persistence: Literal["tar", "snapshot"], + stream: bool, +) -> None: + _require_env("OPENAI_API_KEY") + _require_env("ISLO_API_KEY") + + await _verify_stop_resume( + base_url=base_url, + compute_url=compute_url, + image=image, + vcpus=vcpus, + memory_mb=memory_mb, + disk_gb=disk_gb, + snapshot_name=snapshot_name, + pause_on_exit=pause_on_exit, + workspace_persistence=workspace_persistence, + ) + + agent = SandboxAgent( + name="Islo 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=_build_manifest(), + capabilities=[WorkspaceShellCapability()], + model_settings=ModelSettings(tool_choice="required"), + ) + + run_config = RunConfig( + sandbox=SandboxRunConfig( + client=IsloSandboxClient(base_url=base_url, compute_url=compute_url), + options=_options( + base_url=base_url, + compute_url=compute_url, + image=image, + vcpus=vcpus, + memory_mb=memory_mb, + disk_gb=disk_gb, + snapshot_name=snapshot_name, + pause_on_exit=pause_on_exit, + workspace_persistence=workspace_persistence, + ), + ), + workflow_name="Islo 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("--base-url", default=None, help="Optional Islo API base URL.") + parser.add_argument("--compute-url", default=None, help="Optional Islo compute API base URL.") + parser.add_argument("--image", default=None, help="Optional Islo sandbox image.") + parser.add_argument("--vcpus", type=int, default=None, help="Optional Islo vCPU count.") + parser.add_argument("--memory-mb", type=int, default=None, help="Optional Islo memory in MB.") + parser.add_argument("--disk-gb", type=int, default=None, help="Optional Islo disk in GB.") + parser.add_argument( + "--snapshot-name", + default=None, + help="Optional Islo snapshot name to use as the sandbox base filesystem.", + ) + parser.add_argument( + "--pause-on-exit", + action="store_true", + default=False, + help="Pause the sandbox on shutdown instead of deleting it.", + ) + parser.add_argument( + "--workspace-persistence", + default="tar", + choices=["tar", "snapshot"], + help="Workspace persistence mode for the Islo sandbox.", + ) + 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, + base_url=args.base_url, + compute_url=args.compute_url, + image=args.image, + vcpus=args.vcpus, + memory_mb=args.memory_mb, + disk_gb=args.disk_gb, + snapshot_name=args.snapshot_name, + pause_on_exit=args.pause_on_exit, + workspace_persistence=args.workspace_persistence, + stream=args.stream, + ) + ) diff --git a/mkdocs.yml b/mkdocs.yml index c38e747653..766066d31c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -194,6 +194,9 @@ plugins: - DaprSession: ref/extensions/memory/dapr_session.md - EncryptedSession: ref/extensions/memory/encrypt_session.md - AdvancedSQLiteSession: ref/extensions/memory/advanced_sqlite_session.md + - Sandbox extensions: + - Islo sandbox: ref/extensions/sandbox/islo/sandbox.md + - Islo mounts: ref/extensions/sandbox/islo/mounts.md - locale: ja name: 日本語 build: true diff --git a/pyproject.toml b/pyproject.toml index ca0a145094..6ea269d035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ daytona = ["daytona>=0.155.0"] cloudflare = ["aiohttp>=3.12,<4"] e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"] modal = ["modal==1.4.3"] +islo = ["islo>=0.3.3,<0.4"] runloop = ["runloop_api_client>=1.16.0,<2.0.0"] vercel = ["vercel>=0.5.6,<0.6"] s3 = ["boto3>=1.34"] @@ -148,6 +149,10 @@ ignore_missing_imports = true module = ["e2b", "e2b.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["islo", "islo.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["daytona", "daytona.*"] ignore_missing_imports = true diff --git a/src/agents/extensions/sandbox/__init__.py b/src/agents/extensions/sandbox/__init__.py index d7b082ba1f..80043c1725 100644 --- a/src/agents/extensions/sandbox/__init__.py +++ b/src/agents/extensions/sandbox/__init__.py @@ -74,6 +74,21 @@ except Exception: # pragma: no cover _HAS_CLOUDFLARE = False +try: + from .islo import ( + DEFAULT_ISLO_WORKSPACE_ROOT as DEFAULT_ISLO_WORKSPACE_ROOT, + IsloCloudBucketMountStrategy as IsloCloudBucketMountStrategy, + IsloSandboxClient as IsloSandboxClient, + IsloSandboxClientOptions as IsloSandboxClientOptions, + IsloSandboxSession as IsloSandboxSession, + IsloSandboxSessionState as IsloSandboxSessionState, + IsloSandboxTimeouts as IsloSandboxTimeouts, + ) + + _HAS_ISLO = True +except Exception: # pragma: no cover + _HAS_ISLO = False + try: from .runloop import ( DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT as DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT, @@ -177,6 +192,19 @@ ] ) +if _HAS_ISLO: + __all__.extend( + [ + "DEFAULT_ISLO_WORKSPACE_ROOT", + "IsloCloudBucketMountStrategy", + "IsloSandboxClient", + "IsloSandboxClientOptions", + "IsloSandboxSession", + "IsloSandboxSessionState", + "IsloSandboxTimeouts", + ] + ) + if _HAS_VERCEL: __all__.extend( [ diff --git a/src/agents/extensions/sandbox/islo/__init__.py b/src/agents/extensions/sandbox/islo/__init__.py new file mode 100644 index 0000000000..36283b9876 --- /dev/null +++ b/src/agents/extensions/sandbox/islo/__init__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from .mounts import IsloCloudBucketMountStrategy +from .sandbox import ( + DEFAULT_ISLO_WORKSPACE_ROOT, + IsloSandboxClient, + IsloSandboxClientOptions, + IsloSandboxSession, + IsloSandboxSessionState, + IsloSandboxTimeouts, +) + +__all__ = [ + "DEFAULT_ISLO_WORKSPACE_ROOT", + "IsloCloudBucketMountStrategy", + "IsloSandboxClient", + "IsloSandboxClientOptions", + "IsloSandboxSession", + "IsloSandboxSessionState", + "IsloSandboxTimeouts", +] diff --git a/src/agents/extensions/sandbox/islo/mounts.py b/src/agents/extensions/sandbox/islo/mounts.py new file mode 100644 index 0000000000..909d0627f8 --- /dev/null +++ b/src/agents/extensions/sandbox/islo/mounts.py @@ -0,0 +1,213 @@ +"""Mount strategy for Islo sandboxes. + +Provides ``IsloCloudBucketMountStrategy``, a wrapper around the generic +:class:`InContainerMountStrategy` that ensures ``rclone`` is installed inside +the sandbox before delegating to :class:`RcloneMountPattern`. + +Supports S3, R2, GCS, Azure Blob, and Box mounts through a single code path. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Literal + +from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase +from ....sandbox.entries.mounts.patterns import RcloneMountPattern +from ....sandbox.errors import MountConfigError +from ....sandbox.materialization import MaterializedFile +from ....sandbox.session.base_sandbox_session import BaseSandboxSession + +logger = logging.getLogger(__name__) + +_INSTALL_RETRIES = 3 + + +async def _has_command(session: BaseSandboxSession, cmd: str) -> bool: + check = await session.exec( + "sh", + "-lc", + f"command -v {cmd} >/dev/null 2>&1 || test -x /usr/local/bin/{cmd}", + shell=False, + ) + return check.ok() + + +async def _pkg_install( + session: BaseSandboxSession, + package: str, + *, + what: str, +) -> None: + if await _has_command(session, "apt-get"): + install_cmd = ( + f"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {package}" + ) + elif await _has_command(session, "apk"): + install_cmd = f"apk add --no-cache {package}" + else: + raise MountConfigError( + message=( + f"{what} is not installed and cannot be auto-installed " + f"(no supported package manager found). Preinstall {package} in your Islo image." + ), + context={"package": package}, + ) + + for attempt in range(_INSTALL_RETRIES): + result = await session.exec("sh", "-lc", install_cmd, shell=False, timeout=180, user="root") + if result.ok(): + return + logger.warning( + "%s install attempt %d/%d failed (exit %d)", + package, + attempt + 1, + _INSTALL_RETRIES, + result.exit_code, + ) + + raise MountConfigError( + message=f"failed to install {package} after {_INSTALL_RETRIES} attempts", + context={"package": package, "exit_code": result.exit_code}, + ) + + +async def _ensure_fuse_support(session: BaseSandboxSession) -> None: + dev_fuse = await session.exec("sh", "-lc", "test -c /dev/fuse", shell=False) + if not dev_fuse.ok(): + raise MountConfigError( + message="/dev/fuse not available in this Islo sandbox", + context={"missing": "/dev/fuse"}, + ) + kmod = await session.exec("sh", "-lc", "grep -qw fuse /proc/filesystems", shell=False) + if not kmod.ok(): + raise MountConfigError( + message="FUSE kernel module not loaded in this Islo sandbox", + context={"missing": "fuse in /proc/filesystems"}, + ) + + if await _has_command(session, "fusermount3") or await _has_command(session, "fusermount"): + return + + logger.info("fusermount not found; installing fuse3") + await _pkg_install(session, "fuse3", what="fusermount") + + if not ( + await _has_command(session, "fusermount3") or await _has_command(session, "fusermount") + ): + raise MountConfigError( + message="fuse3 was installed but fusermount is still not available", + context={"package": "fuse3"}, + ) + + +async def _ensure_rclone(session: BaseSandboxSession) -> None: + if await _has_command(session, "rclone"): + return + + logger.info("rclone not found in Islo sandbox; installing via apt") + await _pkg_install(session, "rclone", what="rclone") + + if not await _has_command(session, "rclone"): + raise MountConfigError( + message="rclone was installed but is still not available on PATH", + context={"package": "rclone"}, + ) + + +def _assert_islo_session(session: BaseSandboxSession) -> None: + from .sandbox import IsloSandboxSession + + if not isinstance(session, IsloSandboxSession): + raise MountConfigError( + message="islo cloud bucket mounts require an IsloSandboxSession", + context={"session_type": type(session).__name__}, + ) + + +class IsloCloudBucketMountStrategy(MountStrategyBase): + """Mount rclone-backed cloud storage in Islo sandboxes. + + Wraps :class:`InContainerMountStrategy` with automatic ``rclone`` + provisioning. Use with any rclone-backed provider mount (``S3Mount``, + ``R2Mount``, ``GCSMount``, ``AzureBlobMount``, ``BoxMount``) and let the + generic framework handle config generation and mount execution. + + Usage:: + + from agents.extensions.sandbox.islo import IsloCloudBucketMountStrategy + from agents.sandbox.entries import S3Mount + + mount = S3Mount( + bucket="my-bucket", + access_key_id="...", + secret_access_key="...", + mount_path=Path("/mnt/bucket"), + mount_strategy=IsloCloudBucketMountStrategy(), + ) + """ + + type: Literal["islo_cloud_bucket"] = "islo_cloud_bucket" + pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse") + + def _delegate(self) -> InContainerMountStrategy: + return InContainerMountStrategy(pattern=self.pattern) + + def validate_mount(self, mount: Mount) -> None: + self._delegate().validate_mount(mount) + + async def activate( + self, + mount: Mount, + session: BaseSandboxSession, + dest: Path, + base_dir: Path, + ) -> list[MaterializedFile]: + _assert_islo_session(session) + if self.pattern.mode == "fuse": + await _ensure_fuse_support(session) + await _ensure_rclone(session) + return await self._delegate().activate(mount, session, dest, base_dir) + + async def deactivate( + self, + mount: Mount, + session: BaseSandboxSession, + dest: Path, + base_dir: Path, + ) -> None: + _assert_islo_session(session) + await self._delegate().deactivate(mount, session, dest, base_dir) + + async def teardown_for_snapshot( + self, + mount: Mount, + session: BaseSandboxSession, + path: Path, + ) -> None: + _assert_islo_session(session) + await self._delegate().teardown_for_snapshot(mount, session, path) + + async def restore_after_snapshot( + self, + mount: Mount, + session: BaseSandboxSession, + path: Path, + ) -> None: + _assert_islo_session(session) + if self.pattern.mode == "fuse": + await _ensure_fuse_support(session) + await _ensure_rclone(session) + await self._delegate().restore_after_snapshot(mount, session, path) + + def build_docker_volume_driver_config( + self, + mount: Mount, + ) -> tuple[str, dict[str, str], bool] | None: + return None + + +__all__ = [ + "IsloCloudBucketMountStrategy", +] diff --git a/src/agents/extensions/sandbox/islo/sandbox.py b/src/agents/extensions/sandbox/islo/sandbox.py new file mode 100644 index 0000000000..d1f51d5907 --- /dev/null +++ b/src/agents/extensions/sandbox/islo/sandbox.py @@ -0,0 +1,1215 @@ +""" +Islo sandbox (https://islo.dev) implementation. + +This module provides an Islo-backed sandbox client/session implementation backed by +the Islo Python SDK. + +The ``islo`` dependency is optional, so package-level exports should guard imports of this +module. Within this module, Islo SDK imports are lazy so users without the extra can still +import the package. +""" + +from __future__ import annotations + +import asyncio +import inspect +import io +import json +import logging +import os +import shlex +import uuid +from pathlib import Path +from typing import Any, Literal, NoReturn, cast + +import httpx +from pydantic import BaseModel, Field + +from ....sandbox.errors import ( + ConfigurationError, + ErrorCode, + ExecTimeoutError, + ExecTransportError, + WorkspaceArchiveReadError, + WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, + WorkspaceStartError, + WorkspaceWriteTypeError, +) +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.mount_lifecycle import with_ephemeral_mounts_removed +from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript +from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions +from ....sandbox.session.tar_workspace import shell_tar_exclude_args +from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot +from ....sandbox.types import ExecResult, User +from ....sandbox.util.retry import ( + TRANSIENT_HTTP_STATUS_CODES, + exception_chain_contains_type, + exception_chain_has_status_code, + retry_async, +) +from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes +from ....sandbox.workspace_paths import coerce_posix_path, posix_path_as_path, sandbox_path_str + +WorkspacePersistenceMode = Literal["tar", "snapshot"] + +DEFAULT_ISLO_WORKSPACE_ROOT = "/workspace" +_DEFAULT_ISLO_BASE_URL = "https://api.islo.dev" +_DEFAULT_ISLO_COMPUTE_URL = "https://ca.compute.islo.dev" +_ENV_ISLO_API_KEY = "ISLO_API_KEY" +_ENV_ISLO_BASE_URL = "ISLO_BASE_URL" +_ENV_ISLO_COMPUTE_URL = "ISLO_COMPUTE_URL" +_WORKSPACE_PERSISTENCE_TAR: WorkspacePersistenceMode = "tar" +_WORKSPACE_PERSISTENCE_SNAPSHOT: WorkspacePersistenceMode = "snapshot" +_ISLO_SNAPSHOT_MAGIC = b"ISLO_SANDBOX_SNAPSHOT_V1\n" + +logger = logging.getLogger(__name__) + + +def _import_islo_sdk() -> type[Any]: + """Lazily import the Islo async client, raising a clear error if missing.""" + + try: + from islo import AsyncIslo + + return cast(type[Any], AsyncIslo) + except ImportError as e: + raise ImportError( + "IsloSandboxClient requires the optional `islo` dependency.\n" + "Install the Islo extra before using this sandbox backend." + ) from e + + +def _import_islo_exec_helper() -> Any: + try: + from islo.custom.exec import exec_and_wait + + return exec_and_wait + except ImportError as e: + raise ImportError( + "IsloSandboxClient requires the optional `islo` dependency.\n" + "Install the Islo extra before using this sandbox backend." + ) from e + + +def _import_islo_files_internals() -> Any: + try: + from islo.custom.files import _async_get_client_internals + + return _async_get_client_internals + except ImportError as e: + raise ImportError( + "IsloSandboxClient requires the optional `islo` dependency.\n" + "Install the Islo extra before using this sandbox backend." + ) from e + + +def _import_islo_api_error() -> type[BaseException] | None: + try: + from islo.core.api_error import ApiError + + return cast(type[BaseException], ApiError) + except Exception: + return None + + +def _islo_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 _islo_transport_error( + *, + command: tuple[str | Path, ...], + cause: BaseException, +) -> ExecTransportError: + detail = _islo_provider_error_detail(cause) + context: dict[str, object] = {"backend": "islo"} + 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 + message = "Islo exec failed" + if detail: + message = f"{message}: {detail}" + return ExecTransportError(command=command, context=context, cause=cause, message=message) + + +def _islo_workspace_error_context(resp: httpx.Response) -> dict[str, object]: + context: dict[str, object] = { + "backend": "islo", + "http_status": resp.status_code, + } + try: + payload = resp.json() + except Exception: + payload = None + if isinstance(payload, dict): + detail = payload.get("detail") or payload.get("error") or payload.get("message") + if isinstance(detail, str) and detail: + context["provider_error"] = detail + elif resp.text: + context["provider_error"] = resp.text[:1000] + return context + + +def _is_not_found_error(error: BaseException) -> bool: + status = getattr(error, "status_code", None) or getattr(error, "status", None) + return status == 404 or exception_chain_has_status_code(error, {404}) + + +def _is_name_conflict_error(error: BaseException) -> bool: + detail = _islo_provider_error_detail(error) or "" + return "already exists" in detail.lower() + + +def _is_timeout_error(error: BaseException) -> bool: + api_error = _import_islo_api_error() + timeout_types: list[type[BaseException]] = [ + asyncio.TimeoutError, + TimeoutError, + httpx.TimeoutException, + ] + if api_error is not None: + timeout_types.append(api_error) + if isinstance(error, tuple(timeout_types)): + status = getattr(error, "status_code", None) + return status is None or status in {408, 504} + return exception_chain_contains_type(error, tuple(timeout_types)) + + +def _raise_islo_exec_error( + error: BaseException, + *, + command: tuple[str | Path, ...], + timeout: float | None, +) -> NoReturn: + if _is_timeout_error(error): + raise ExecTimeoutError(command=command, timeout_s=timeout, cause=error) from error + raise _islo_transport_error(command=command, cause=error) from error + + +def _encode_islo_snapshot_ref(*, snapshot_name: str) -> bytes: + body = json.dumps( + {"snapshot_name": snapshot_name}, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + return _ISLO_SNAPSHOT_MAGIC + body + + +def _decode_islo_snapshot_ref(raw: bytes) -> str | None: + if not raw.startswith(_ISLO_SNAPSHOT_MAGIC): + return None + body = raw[len(_ISLO_SNAPSHOT_MAGIC) :] + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + snapshot_name = payload.get("snapshot_name") if isinstance(payload, dict) else None + return snapshot_name if isinstance(snapshot_name, str) and snapshot_name else None + + +def _resolve_islo_base_url(base_url: str | None) -> str: + return (base_url or os.environ.get(_ENV_ISLO_BASE_URL) or _DEFAULT_ISLO_BASE_URL).rstrip("/") + + +def _resolve_islo_compute_url(compute_url: str | None) -> str | None: + resolved = compute_url or os.environ.get(_ENV_ISLO_COMPUTE_URL) + return resolved.rstrip("/") if resolved else None + + +def _resolve_islo_api_key(api_key: str | None) -> str | None: + return api_key or os.environ.get(_ENV_ISLO_API_KEY) + + +def _minimal_init_payload() -> dict[str, str]: + return {"type": "minimal"} + + +def _minimal_init_kwargs(create_sandbox: Any) -> dict[str, object]: + try: + parameter_names = set(inspect.signature(create_sandbox).parameters) + except (TypeError, ValueError): + parameter_names = set() + if "init" in parameter_names: + return {"init": _minimal_init_payload()} + if "init_capabilities" in parameter_names: + return {"init_capabilities": []} + raise ConfigurationError( + message="Islo SDK create_sandbox does not support explicit init configuration", + error_code=ErrorCode.SANDBOX_CONFIG_INVALID, + op="start", + context={"backend": "islo"}, + ) + + +class IsloSandboxTimeouts(BaseModel): + """Timeout configuration for Islo sandbox operations.""" + + model_config = {"frozen": True} + + exec_timeout_unbounded_s: float = Field(default=24 * 60 * 60, ge=1) + keepalive_s: float = Field(default=10, ge=1) + cleanup_s: float = Field(default=30, ge=1) + file_upload_s: float = Field(default=1800, ge=1) + file_download_s: float = Field(default=1800, ge=1) + workspace_archive_s: float = Field(default=300, ge=1) + snapshot_s: float = Field(default=300, ge=1) + + +class IsloSandboxClientOptions(BaseSandboxClientOptions): + """Client options for the Islo sandbox.""" + + type: Literal["islo"] = "islo" + base_url: str | None = None + compute_url: str | None = None + name: str | None = None + image: str | None = None + vcpus: int | None = None + memory_mb: int | None = None + disk_gb: int | None = None + snapshot_name: str | None = None + env: dict[str, str] | None = None + workdir: str | None = None + gateway_profile: str | None = None + cache_key: str | None = None + pause_on_exit: bool = False + timeouts: IsloSandboxTimeouts | dict[str, object] | None = None + workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR + + def __init__( + self, + base_url: str | None = None, + compute_url: str | None = None, + name: str | None = None, + image: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + disk_gb: int | None = None, + snapshot_name: str | None = None, + env: dict[str, str] | None = None, + workdir: str | None = None, + gateway_profile: str | None = None, + cache_key: str | None = None, + pause_on_exit: bool = False, + timeouts: IsloSandboxTimeouts | dict[str, object] | None = None, + workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR, + *, + type: Literal["islo"] = "islo", + ) -> None: + super().__init__( + type=type, + base_url=base_url, + compute_url=compute_url, + name=name, + image=image, + vcpus=vcpus, + memory_mb=memory_mb, + disk_gb=disk_gb, + snapshot_name=snapshot_name, + env=env, + workdir=workdir, + gateway_profile=gateway_profile, + cache_key=cache_key, + pause_on_exit=pause_on_exit, + timeouts=timeouts, + workspace_persistence=workspace_persistence, + ) + + +class IsloSandboxSessionState(SandboxSessionState): + """Serializable state for an Islo-backed session.""" + + type: Literal["islo"] = "islo" + sandbox_id: str + sandbox_name: str + base_url: str + compute_url: str | None = None + name: str | None = None + image: str | None = None + vcpus: int | None = None + memory_mb: int | None = None + disk_gb: int | None = None + snapshot_name: str | None = None + base_env: dict[str, str] = Field(default_factory=dict) + workdir: str | None = None + gateway_profile: str | None = None + cache_key: str | None = None + pause_on_exit: bool = False + timeouts: IsloSandboxTimeouts = Field(default_factory=IsloSandboxTimeouts) + workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR + + +class IsloSandboxSession(BaseSandboxSession): + """Islo-backed sandbox session implementation.""" + + state: IsloSandboxSessionState + _client: Any + + def __init__( + self, + *, + state: IsloSandboxSessionState, + client: Any, + ) -> None: + self.state = state + self._client = client + + @classmethod + def from_state( + cls, + state: IsloSandboxSessionState, + *, + client: Any, + ) -> IsloSandboxSession: + return cls(state=state, client=client) + + async def _resolved_envs(self) -> dict[str, str]: + manifest_envs = await self.state.manifest.environment.resolve() + return {**self.state.base_env, **manifest_envs} + + def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]: + return (RESOLVE_WORKSPACE_PATH_HELPER,) + + def _current_runtime_helper_cache_key(self) -> object | None: + return self.state.sandbox_name + + 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 _coerce_exec_timeout(self, timeout_s: float | None) -> float: + if timeout_s is None: + return float(self.state.timeouts.exec_timeout_unbounded_s) + if timeout_s <= 0: + return 0.001 + return float(timeout_s) + + async def _run_islo_command( + self, + command: list[str], + *, + timeout: float | None, + workdir: str | None, + envs: dict[str, str] | None, + user: str | None = None, + ) -> ExecResult: + exec_and_wait = _import_islo_exec_helper() + effective_timeout = self._coerce_exec_timeout(timeout) + try: + result = await exec_and_wait( + self._client, + self.state.sandbox_name, + command, + workdir=workdir, + env=envs or None, + user=user, + timeout=effective_timeout, + ) + except Exception as e: + _raise_islo_exec_error(e, command=tuple(command), timeout=timeout) + + if bool(getattr(result, "timed_out", False)): + raise ExecTimeoutError(command=tuple(command), timeout_s=timeout) + return ExecResult( + stdout=str(getattr(result, "stdout", "") or "").encode("utf-8", errors="replace"), + stderr=str(getattr(result, "stderr", "") or "").encode("utf-8", errors="replace"), + exit_code=int(getattr(result, "exit_code", 0) or 0), + ) + + async def _prepare_backend_workspace(self) -> None: + root = sandbox_path_str(self.state.manifest.root) + try: + result = await self._run_islo_command( + ["mkdir", "-p", "--", root], + timeout=self.state.timeouts.keepalive_s, + workdir="/", + envs=await self._resolved_envs(), + ) + except Exception as e: + raise WorkspaceStartError( + path=posix_path_as_path(coerce_posix_path(root)), cause=e + ) from e + if not result.ok(): + raise WorkspaceStartError( + path=posix_path_as_path(coerce_posix_path(root)), + context={ + "backend": "islo", + "reason": "workspace_root_nonzero_exit", + "exit_code": result.exit_code, + "stdout": result.stdout.decode("utf-8", errors="replace"), + "stderr": result.stderr.decode("utf-8", errors="replace"), + }, + ) + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + command_list = [str(c) for c in command] + if not command_list: + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + user: str | None = None + if len(command_list) >= 4 and command_list[0] == "sudo": + if command_list[1] == "-u" and command_list[3] == "--": + user = command_list[2] + command_list = command_list[4:] + + return await self._run_islo_command( + command_list, + timeout=timeout, + workdir=self.state.manifest.root, + envs=await self._resolved_envs(), + user=user, + ) + + async def _http_internals(self) -> tuple[str, dict[str, str]]: + get_internals = _import_islo_files_internals() + base_url, headers = await get_internals(self._client) + return str(base_url).rstrip("/"), dict(headers) + + # The current Fern-generated Islo SDK lists file endpoints, but does not expose multipart + # upload bodies or streaming download bytes. Keep the raw HTTP path isolated here until the + # SDK grows typed file-transfer helpers. + async def _download_file_via_http(self, path: str, *, timeout: float) -> bytes: + base_url, headers = await self._http_internals() + async with httpx.AsyncClient() as http: + response = await http.get( + f"{base_url}/sandboxes/{self.state.sandbox_name}/files", + params={"path": path}, + headers=headers, + timeout=timeout, + ) + if response.status_code == 404: + raise WorkspaceReadNotFoundError(path=posix_path_as_path(coerce_posix_path(path))) + if response.status_code >= 400: + raise WorkspaceArchiveReadError( + path=posix_path_as_path(coerce_posix_path(path)), + context=_islo_workspace_error_context(response), + ) + return response.content + + async def _upload_file_via_http(self, path: str, payload: bytes, *, timeout: float) -> None: + base_url, headers = await self._http_internals() + filename = posix_path_as_path(coerce_posix_path(path)).name or "file" + async with httpx.AsyncClient() as http: + response = await http.post( + f"{base_url}/sandboxes/{self.state.sandbox_name}/files", + params={"path": path}, + headers=headers, + files={"file": (filename, payload)}, + timeout=timeout, + ) + if response.status_code >= 400: + raise WorkspaceArchiveWriteError( + path=posix_path_as_path(coerce_posix_path(path)), + context=_islo_workspace_error_context(response), + ) + + async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase: + error_path = posix_path_as_path(coerce_posix_path(path)) + if user is not None: + workspace_path = await self._check_read_with_exec(path, user=user) + else: + workspace_path = await self._validate_path_access(path) + try: + data = await self._download_file_via_http( + sandbox_path_str(workspace_path), + timeout=self.state.timeouts.file_download_s, + ) + return io.BytesIO(data) + except WorkspaceReadNotFoundError as e: + raise WorkspaceReadNotFoundError(path=error_path, cause=e) from e + except WorkspaceArchiveReadError: + raise + except Exception as e: + if _is_not_found_error(e): + raise WorkspaceReadNotFoundError(path=error_path, cause=e) from e + raise WorkspaceArchiveReadError(path=error_path, cause=e) from e + + async def write( + self, + path: Path | str, + data: io.IOBase, + *, + user: str | User | None = None, + ) -> None: + error_path = posix_path_as_path(coerce_posix_path(path)) + payload = data.read() + if isinstance(payload, str): + payload = payload.encode("utf-8") + if not isinstance(payload, bytes | bytearray): + raise WorkspaceWriteTypeError(path=error_path, actual_type=type(payload).__name__) + + if user is not None: + workspace_path = await self._check_write_with_exec(path, user=user) + await self._write_file_as_user(workspace_path, bytes(payload), user=user) + return + + workspace_path = await self._validate_path_access(path, for_write=True) + try: + await self._upload_file_via_http( + sandbox_path_str(workspace_path), + bytes(payload), + timeout=self.state.timeouts.file_upload_s, + ) + except WorkspaceArchiveWriteError: + raise + except Exception as e: + raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e + + async def _write_file_as_user( + self, + workspace_path: Path, + payload: bytes, + *, + user: str | User, + ) -> None: + temp_path = f"/tmp/openai-agents-islo-write-{self.state.session_id.hex}-{uuid.uuid4().hex}" + try: + await self._upload_file_via_http( + temp_path, + payload, + timeout=self.state.timeouts.file_upload_s, + ) + chmod = await self._run_islo_command( + ["chmod", "a+r", "--", temp_path], + timeout=self.state.timeouts.cleanup_s, + workdir="/", + envs=await self._resolved_envs(), + ) + if not chmod.ok(): + raise WorkspaceArchiveWriteError( + path=workspace_path, + context={ + "backend": "islo", + "reason": "temp_file_chmod_failed", + "exit_code": chmod.exit_code, + }, + ) + result = await self.exec( + "sh", + "-lc", + '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={ + "backend": "islo", + "reason": "user_write_failed", + "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 e: + raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e + finally: + try: + await self._run_islo_command( + ["rm", "-f", "--", temp_path], + timeout=self.state.timeouts.cleanup_s, + workdir="/", + envs=await self._resolved_envs(), + ) + except Exception: + pass + + async def running(self) -> bool: + try: + sandbox = await asyncio.wait_for( + self._client.sandboxes.get_sandbox(self.state.sandbox_name), + timeout=self.state.timeouts.keepalive_s, + ) + except Exception: + return False + return str(getattr(sandbox, "status", "")).lower() in {"running", "started"} + + def _tar_exclude_args(self) -> list[str]: + return shell_tar_exclude_args(self._persist_workspace_skip_relpaths()) + + @retry_async( + retry_if=lambda exc, self, tar_cmd, tar_path: ( + exception_chain_contains_type( + exc, (asyncio.TimeoutError, TimeoutError, httpx.TimeoutException) + ) + or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES) + ) + ) + async def _run_persist_workspace_command(self, tar_cmd: str, tar_path: str) -> bytes: + root = self._workspace_root_path() + try: + result = await self._run_islo_command( + ["sh", "-lc", tar_cmd], + timeout=self.state.timeouts.workspace_archive_s, + workdir="/", + envs=await self._resolved_envs(), + ) + if not result.ok(): + raise WorkspaceArchiveReadError( + path=root, + context={ + "backend": "islo", + "reason": "tar_failed", + "exit_code": result.exit_code, + "stdout": result.stdout.decode("utf-8", errors="replace"), + "stderr": result.stderr.decode("utf-8", errors="replace"), + }, + ) + return await self._download_file_via_http( + tar_path, + timeout=self.state.timeouts.file_download_s, + ) + except WorkspaceArchiveReadError: + raise + except Exception as e: + raise WorkspaceArchiveReadError(path=root, cause=e) from e + + async def persist_workspace(self) -> io.IOBase: + return await with_ephemeral_mounts_removed( + self, + self._persist_workspace_internal, + error_path=self._workspace_root_path(), + error_cls=WorkspaceArchiveReadError, + operation_error_context_key="snapshot_error_before_remount_corruption", + ) + + async def _persist_workspace_internal(self) -> io.IOBase: + if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT: + if ( + not self._native_snapshot_requires_tar_fallback() + and not self._persist_workspace_skip_relpaths() + ): + return await self._persist_workspace_via_snapshot() + + root = self._workspace_root_path() + tar_path = f"/tmp/openai-agents-islo-{self.state.session_id.hex}.tar" + excludes = " ".join(self._tar_exclude_args()) + tar_cmd = ( + f"tar {excludes} -C {shlex.quote(root.as_posix())} -cf {shlex.quote(tar_path)} ." + ).strip() + try: + raw = await self._run_persist_workspace_command(tar_cmd, tar_path) + return io.BytesIO(raw) + finally: + try: + await self._run_islo_command( + ["rm", "-f", "--", tar_path], + timeout=self.state.timeouts.cleanup_s, + workdir="/", + envs=await self._resolved_envs(), + ) + except Exception: + pass + + async def _persist_workspace_via_snapshot(self) -> io.IOBase: + root = self._workspace_root_path() + snapshot_name = f"openai-agents-{self.state.session_id.hex}" + try: + snapshot = await asyncio.wait_for( + self._client.snapshots.create_snapshot( + sandbox_name=self.state.sandbox_name, + name=snapshot_name, + ), + timeout=self.state.timeouts.snapshot_s, + ) + resolved_name = getattr(snapshot, "name", None) + if not isinstance(resolved_name, str) or not resolved_name: + raise WorkspaceArchiveReadError( + path=root, + context={ + "backend": "islo", + "reason": "native_snapshot_unexpected_return", + "type": type(snapshot).__name__, + }, + ) + return io.BytesIO(_encode_islo_snapshot_ref(snapshot_name=resolved_name)) + except WorkspaceArchiveReadError: + raise + except Exception as e: + raise WorkspaceArchiveReadError( + path=root, + context={"backend": "islo", "reason": "native_snapshot_failed"}, + cause=e, + ) from e + + async def hydrate_workspace(self, data: io.IOBase) -> None: + root = self._workspace_root_path() + tar_path = f"/tmp/openai-agents-islo-hydrate-{self.state.session_id.hex}.tar" + payload = data.read() + if isinstance(payload, str): + payload = payload.encode("utf-8") + if not isinstance(payload, bytes | bytearray): + raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__) + raw = bytes(payload) + + snapshot_name = _decode_islo_snapshot_ref(raw) + if snapshot_name is not None: + await self._replace_sandbox_from_snapshot(snapshot_name) + return + + try: + validate_tar_bytes(raw, allow_external_symlink_targets=False) + except UnsafeTarMemberError as e: + raise WorkspaceArchiveWriteError( + path=root, + context={ + "backend": "islo", + "reason": "unsafe_or_invalid_tar", + "member": e.member, + "detail": str(e), + }, + cause=e, + ) from e + + await with_ephemeral_mounts_removed( + self, + lambda: self._hydrate_workspace_internal(raw, tar_path), + error_path=root, + error_cls=WorkspaceArchiveWriteError, + operation_error_context_key="hydrate_error_before_remount_corruption", + ) + + async def _hydrate_workspace_internal(self, raw: bytes, tar_path: str) -> None: + root = self._workspace_root_path() + try: + await self._run_islo_command( + ["mkdir", "-p", "--", root.as_posix()], + timeout=self.state.timeouts.keepalive_s, + workdir="/", + envs=await self._resolved_envs(), + ) + await self._upload_file_via_http( + tar_path, + raw, + timeout=self.state.timeouts.file_upload_s, + ) + result = await self._run_islo_command( + ["tar", "-C", root.as_posix(), "-xf", tar_path], + timeout=self.state.timeouts.workspace_archive_s, + workdir="/", + envs=await self._resolved_envs(), + ) + if not result.ok(): + raise WorkspaceArchiveWriteError( + path=root, + context={ + "backend": "islo", + "reason": "tar_extract_failed", + "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 e: + raise WorkspaceArchiveWriteError(path=root, cause=e) from e + finally: + try: + await self._run_islo_command( + ["rm", "-f", "--", tar_path], + timeout=self.state.timeouts.cleanup_s, + workdir="/", + envs=await self._resolved_envs(), + ) + except Exception: + pass + + async def _replace_sandbox_from_snapshot(self, snapshot_name: str) -> None: + try: + await self._delete_backend() + except Exception: + pass + try: + try: + sandbox = await self._create_sandbox_from_state(snapshot_name=snapshot_name) + except Exception as e: + if not _is_name_conflict_error(e): + raise + logger.debug( + "islo sandbox name is still reserved during snapshot restore, " + "recreating with generated name: %s", + e, + ) + sandbox = await self._create_sandbox_from_state( + snapshot_name=snapshot_name, + include_name=False, + ) + except Exception as e: + raise WorkspaceArchiveWriteError( + path=self._workspace_root_path(), + context={ + "backend": "islo", + "reason": "native_snapshot_restore_failed", + "snapshot_name": snapshot_name, + }, + cause=e, + ) from e + self._apply_sandbox_response(sandbox) + self.state.workspace_root_ready = True + + async def _create_sandbox_from_state( + self, + *, + snapshot_name: str | None = None, + include_name: bool = True, + ) -> Any: + create_sandbox = self._client.sandboxes.create_sandbox + kwargs: dict[str, object] = _minimal_init_kwargs(create_sandbox) + for key, value in ( + ("name", (self.state.name or self.state.sandbox_name) if include_name else None), + ("image", self.state.image), + ("vcpus", self.state.vcpus), + ("memory_mb", self.state.memory_mb), + ("disk_gb", self.state.disk_gb), + ("snapshot_name", snapshot_name or self.state.snapshot_name), + ("env", dict(self.state.base_env) or None), + ("workdir", self.state.workdir), + ("gateway_profile", self.state.gateway_profile), + ("cache_key", self.state.cache_key), + ): + if value is not None: + kwargs[key] = value + return await create_sandbox(**kwargs) + + def _apply_sandbox_response(self, sandbox: Any) -> None: + sandbox_id = getattr(sandbox, "id", None) + sandbox_name = getattr(sandbox, "name", None) + if isinstance(sandbox_id, str) and sandbox_id: + self.state.sandbox_id = sandbox_id + if isinstance(sandbox_name, str) and sandbox_name: + self.state.sandbox_name = sandbox_name + self.state.name = sandbox_name + + async def _delete_backend(self) -> None: + await self._client.sandboxes.delete_sandbox(self.state.sandbox_name) + + async def _shutdown_backend(self) -> None: + try: + if self.state.pause_on_exit: + await self._client.sandboxes.pause_sandbox(self.state.sandbox_name) + else: + await self._delete_backend() + except Exception: + logger.debug("Failed to shut down Islo sandbox", exc_info=True) + + async def _after_shutdown(self) -> None: + await self._close_client() + + async def _after_start_failed(self) -> None: + await self._close_client() + + async def _close_client(self) -> None: + wrapper = getattr(self._client, "_client_wrapper", None) + http_client = getattr(wrapper, "httpx_client", None) + close = getattr(http_client, "aclose", None) + if callable(close): + try: + await close() + except Exception: + pass + + +class IsloSandboxClient(BaseSandboxClient[IsloSandboxClientOptions]): + """Islo sandbox client managing sandbox lifecycle via the Islo SDK.""" + + backend_id = "islo" + _instrumentation: Instrumentation + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + instrumentation: Instrumentation | None = None, + dependencies: Dependencies | None = None, + ) -> None: + super().__init__() + self._api_key = api_key + self._base_url = base_url + self._compute_url = compute_url + self._instrumentation = instrumentation or Instrumentation() + self._dependencies = dependencies + + def _new_client( + self, + *, + api_key: str | None, + base_url: str | None, + compute_url: str | None, + ) -> Any: + AsyncIslo = _import_islo_sdk() + kwargs: dict[str, object] = { + "api_key": _resolve_islo_api_key(api_key), + "base_url": _resolve_islo_base_url(base_url), + } + resolved_compute_url = _resolve_islo_compute_url(compute_url) + if resolved_compute_url is not None: + try: + parameter_names = set(inspect.signature(AsyncIslo).parameters) + except (TypeError, ValueError): + parameter_names = set() + if "compute_url" not in parameter_names: + raise ConfigurationError( + message=( + "Islo compute_url requires an Islo SDK version that supports " + "AsyncIslo(compute_url=...)." + ), + error_code=ErrorCode.SANDBOX_CONFIG_INVALID, + op="start", + context={"backend": self.backend_id}, + ) + kwargs["compute_url"] = resolved_compute_url + return AsyncIslo(**kwargs) + + def _coerce_timeouts( + self, value: IsloSandboxTimeouts | dict[str, object] | None + ) -> IsloSandboxTimeouts: + if isinstance(value, IsloSandboxTimeouts): + return value + if value is None: + return IsloSandboxTimeouts() + return IsloSandboxTimeouts.model_validate(value) + + def _resolve_manifest(self, manifest: Manifest | None) -> Manifest: + if manifest is None: + return Manifest(root=DEFAULT_ISLO_WORKSPACE_ROOT) + return manifest + + async def create( + self, + *, + snapshot: SnapshotSpec | SnapshotBase | None = None, + manifest: Manifest | None = None, + options: IsloSandboxClientOptions, + ) -> SandboxSession: + if options.workspace_persistence not in ( + _WORKSPACE_PERSISTENCE_TAR, + _WORKSPACE_PERSISTENCE_SNAPSHOT, + ): + raise ConfigurationError( + message=( + "IsloSandboxClientOptions.workspace_persistence must be one of " + f"{_WORKSPACE_PERSISTENCE_TAR!r} or {_WORKSPACE_PERSISTENCE_SNAPSHOT!r}" + ), + error_code=ErrorCode.SANDBOX_CONFIG_INVALID, + op="start", + context={"backend": self.backend_id}, + ) + + resolved_base_url = _resolve_islo_base_url(options.base_url or self._base_url) + resolved_compute_url = _resolve_islo_compute_url(options.compute_url or self._compute_url) + client = self._new_client( + api_key=self._api_key, + base_url=resolved_base_url, + compute_url=resolved_compute_url, + ) + timeouts = self._coerce_timeouts(options.timeouts) + create_sandbox = client.sandboxes.create_sandbox + create_kwargs: dict[str, object] = _minimal_init_kwargs(create_sandbox) + for key, value in ( + ("name", options.name), + ("image", options.image), + ("vcpus", options.vcpus), + ("memory_mb", options.memory_mb), + ("disk_gb", options.disk_gb), + ("snapshot_name", options.snapshot_name), + ("env", dict(options.env or {}) or None), + ("workdir", options.workdir), + ("gateway_profile", options.gateway_profile), + ("cache_key", options.cache_key), + ): + if value is not None: + create_kwargs[key] = value + + try: + sandbox = await create_sandbox(**create_kwargs) + except Exception: + await self._close_client(client) + raise + + sandbox_id = getattr(sandbox, "id", None) + sandbox_name = getattr(sandbox, "name", None) + if not isinstance(sandbox_id, str) or not sandbox_id: + await self._close_client(client) + raise ConfigurationError( + message="Islo create_sandbox returned an invalid id", + error_code=ErrorCode.SANDBOX_CONFIG_INVALID, + op="start", + context={"backend": self.backend_id}, + ) + if not isinstance(sandbox_name, str) or not sandbox_name: + await self._close_client(client) + raise ConfigurationError( + message="Islo create_sandbox returned an invalid name", + error_code=ErrorCode.SANDBOX_CONFIG_INVALID, + op="start", + context={"backend": self.backend_id}, + ) + + session_id = uuid.uuid4() + snapshot_instance = resolve_snapshot(snapshot, str(session_id)) + resolved_manifest = self._resolve_manifest(manifest) + state = IsloSandboxSessionState( + session_id=session_id, + manifest=resolved_manifest, + snapshot=snapshot_instance, + sandbox_id=sandbox_id, + sandbox_name=sandbox_name, + base_url=resolved_base_url, + compute_url=resolved_compute_url, + name=sandbox_name, + image=options.image, + vcpus=options.vcpus, + memory_mb=options.memory_mb, + disk_gb=options.disk_gb, + snapshot_name=options.snapshot_name, + base_env=dict(options.env or {}), + workdir=options.workdir, + gateway_profile=options.gateway_profile, + cache_key=options.cache_key, + pause_on_exit=options.pause_on_exit, + timeouts=timeouts, + workspace_persistence=options.workspace_persistence, + ) + inner = IsloSandboxSession.from_state(state, client=client) + return self._wrap_session(inner, instrumentation=self._instrumentation) + + async def delete(self, session: SandboxSession) -> SandboxSession: + inner = session._inner + if not isinstance(inner, IsloSandboxSession): + raise TypeError("IsloSandboxClient.delete expects an IsloSandboxSession") + try: + await inner.shutdown() + except Exception: + pass + return session + + async def _reconnected_sandbox_is_usable( + self, + state: IsloSandboxSessionState, + client: Any, + ) -> bool: + temp = IsloSandboxSession.from_state(state, client=client) + try: + await temp._run_islo_command( + ["true"], + timeout=state.timeouts.keepalive_s, + workdir="/", + envs=None, + ) + except Exception as e: + if _is_not_found_error(e): + logger.debug("islo sandbox metadata was stale, will recreate: %s", e) + return False + logger.debug("islo sandbox reconnect probe failed, keeping reconnect path: %s", e) + return True + + async def resume( + self, + state: SandboxSessionState, + ) -> SandboxSession: + if not isinstance(state, IsloSandboxSessionState): + raise TypeError("IsloSandboxClient.resume expects an IsloSandboxSessionState") + + client = self._new_client( + api_key=self._api_key, + base_url=state.base_url or self._base_url, + compute_url=state.compute_url or self._compute_url, + ) + reconnected = False + try: + sandbox = await client.sandboxes.get_sandbox(state.sandbox_name) + status = str(getattr(sandbox, "status", "")).lower() + if status in {"paused", "stopped"}: + sandbox = await client.sandboxes.resume_sandbox(state.sandbox_name) + status = str(getattr(sandbox, "status", "")).lower() + if status in {"running", "started"}: + reconnected = await self._reconnected_sandbox_is_usable(state, client) + else: + raise RuntimeError( + f"islo sandbox is not ready to resume: status={status or ''}" + ) + except Exception as e: + if not _is_not_found_error(e): + await self._close_client(client) + raise + logger.debug("islo sandbox metadata was stale, will recreate: %s", e) + + if not reconnected: + try: + temp = IsloSandboxSession.from_state(state, client=client) + try: + sandbox = await temp._create_sandbox_from_state() + except Exception as e: + if not _is_name_conflict_error(e): + raise + logger.debug( + "islo sandbox name is still reserved, recreating with generated name: %s", + e, + ) + sandbox = await temp._create_sandbox_from_state(include_name=False) + temp._apply_sandbox_response(sandbox) + state.workspace_root_ready = False + except Exception: + await self._close_client(client) + raise + + inner = IsloSandboxSession.from_state(state, client=client) + inner._set_start_state_preserved(reconnected, system=reconnected) + return self._wrap_session(inner, instrumentation=self._instrumentation) + + def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState: + return IsloSandboxSessionState.model_validate(payload) + + async def close(self) -> None: + return None + + async def __aenter__(self) -> IsloSandboxClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def _close_client(self, client: Any) -> None: + wrapper = getattr(client, "_client_wrapper", None) + http_client = getattr(wrapper, "httpx_client", None) + close = getattr(http_client, "aclose", None) + if callable(close): + try: + await close() + except Exception: + pass + + +__all__ = [ + "DEFAULT_ISLO_WORKSPACE_ROOT", + "IsloSandboxClient", + "IsloSandboxClientOptions", + "IsloSandboxSession", + "IsloSandboxSessionState", + "IsloSandboxTimeouts", +] diff --git a/tests/extensions/sandbox/test_islo.py b/tests/extensions/sandbox/test_islo.py new file mode 100644 index 0000000000..474c908ded --- /dev/null +++ b/tests/extensions/sandbox/test_islo.py @@ -0,0 +1,1445 @@ +from __future__ import annotations + +import importlib +import io +import json +import sys +import tarfile +import types +import uuid +from pathlib import Path +from typing import Any, cast + +import pytest + +from agents.sandbox import Manifest +from agents.sandbox.entries import RcloneMountPattern, S3Mount +from agents.sandbox.errors import ( + ConfigurationError, + ExecTimeoutError, + MountConfigError, + WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, +) +from agents.sandbox.manifest import Environment +from agents.sandbox.session.sandbox_client import BaseSandboxClientOptions +from agents.sandbox.session.sandbox_session_state import SandboxSessionState +from agents.sandbox.snapshot import NoopSnapshot + + +class _FakeApiError(Exception): + def __init__(self, *, status_code: int | None = None, body: object = None) -> None: + self.status_code = status_code + self.body = body + super().__init__(f"status_code={status_code}, body={body}") + + +class _FakeExecResult: + def __init__( + self, + *, + stdout: str = "", + stderr: str = "", + exit_code: int = 0, + timed_out: bool = False, + ) -> None: + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + self.timed_out = timed_out + + def ok(self) -> bool: + return self.exit_code == 0 and not self.timed_out + + +class _FakeSandboxResponse: + def __init__( + self, + *, + sandbox_id: str, + name: str, + status: str = "running", + ) -> None: + self.id = sandbox_id + self.name = name + self.status = status + + +class _FakeSnapshotResponse: + def __init__(self, *, name: str) -> None: + self.name = name + self.id = f"snapshot-{name}" + self.status = "ready" + + +class _FakeHttpxClient: + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + +class _FakeClientWrapper: + def __init__(self, *, compute_url: str, headers: dict[str, str]) -> None: + self._compute_url = compute_url + self._headers = headers + self.httpx_client = _FakeHttpxClient() + + def get_compute_url(self) -> str: + return self._compute_url + + async def async_get_headers(self) -> dict[str, str]: + return dict(self._headers) + + +class _FakeSandboxesClient: + sandboxes: dict[str, _FakeSandboxResponse] = {} + create_calls: list[dict[str, object]] = [] + get_calls: list[str] = [] + delete_calls: list[str] = [] + pause_calls: list[str] = [] + resume_calls: list[str] = [] + create_count = 0 + + @classmethod + def reset(cls) -> None: + cls.sandboxes = {} + cls.create_calls = [] + cls.get_calls = [] + cls.delete_calls = [] + cls.pause_calls = [] + cls.resume_calls = [] + cls.create_count = 0 + + async def create_sandbox( + self, + *, + name: str | None = None, + image: str | None = None, + vcpus: int | None = None, + memory_mb: int | None = None, + disk_gb: int | None = None, + snapshot_name: str | None = None, + env: dict[str, str] | None = None, + workdir: str | None = None, + gateway_profile: str | None = None, + cache_key: str | None = None, + init: dict[str, str] | None = None, + ) -> _FakeSandboxResponse: + kwargs: dict[str, object] = { + "name": name, + "image": image, + "vcpus": vcpus, + "memory_mb": memory_mb, + "disk_gb": disk_gb, + "snapshot_name": snapshot_name, + "env": env, + "workdir": workdir, + "gateway_profile": gateway_profile, + "cache_key": cache_key, + "init": init, + } + kwargs = {key: value for key, value in kwargs.items() if value is not None} + type(self).create_count += 1 + type(self).create_calls.append(dict(kwargs)) + name = name or f"islo-{type(self).create_count}" + sandbox = _FakeSandboxResponse( + sandbox_id=f"sb-{type(self).create_count}", + name=name, + ) + type(self).sandboxes[name] = sandbox + return sandbox + + async def get_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + type(self).get_calls.append(sandbox_name) + sandbox = type(self).sandboxes.get(sandbox_name) + if sandbox is None: + raise _FakeApiError(status_code=404, body="missing") + return sandbox + + async def delete_sandbox(self, sandbox_name: str) -> None: + type(self).delete_calls.append(sandbox_name) + type(self).sandboxes.pop(sandbox_name, None) + + async def pause_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + type(self).pause_calls.append(sandbox_name) + sandbox = await self.get_sandbox(sandbox_name) + sandbox.status = "paused" + return sandbox + + async def resume_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + type(self).resume_calls.append(sandbox_name) + sandbox = await self.get_sandbox(sandbox_name) + sandbox.status = "running" + return sandbox + + +class _FakeSnapshotsClient: + create_calls: list[dict[str, object]] = [] + + @classmethod + def reset(cls) -> None: + cls.create_calls = [] + + async def create_snapshot( + self, + *, + sandbox_name: str, + name: str | None = None, + ) -> _FakeSnapshotResponse: + type(self).create_calls.append({"sandbox_name": sandbox_name, "name": name}) + name = name or "snapshot" + return _FakeSnapshotResponse(name=name) + + +class _FakeAsyncIslo: + instances: list[_FakeAsyncIslo] = [] + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + self.api_key = api_key + self.base_url = base_url or "https://api.islo.dev" + self.compute_url = compute_url or "https://ca.compute.islo.dev" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + self._client_wrapper = _FakeClientWrapper(compute_url=self.compute_url, headers=headers) + self.sandboxes = _FakeSandboxesClient() + self.snapshots = _FakeSnapshotsClient() + self.exec_calls: list[dict[str, object]] = [] + self.exec_results: list[_FakeExecResult | BaseException] = [] + type(self).instances.append(self) + + @classmethod + def reset(cls) -> None: + cls.instances = [] + _FakeSandboxesClient.reset() + _FakeSnapshotsClient.reset() + + +async def _fake_exec_and_wait( + client: _FakeAsyncIslo, + sandbox_name: str, + command: list[str], + **kwargs: object, +) -> _FakeExecResult: + client.exec_calls.append({"sandbox_name": sandbox_name, "command": list(command), **kwargs}) + if client.exec_results: + result = client.exec_results.pop(0) + if isinstance(result, BaseException): + raise result + return result + return _FakeExecResult(stdout="ok\n") + + +def _command_str(command: list[str], user: object | None = None) -> str: + command_text = " ".join(command) + if user is None: + return command_text + return f"sudo -u {user} -- {command_text}" + + +async def _fake_get_client_internals(client: _FakeAsyncIslo) -> tuple[str, dict[str, str]]: + return ( + client._client_wrapper.get_compute_url(), + await client._client_wrapper.async_get_headers(), + ) + + +class _FakeHttpResponse: + def __init__( + self, + *, + status_code: int = 200, + content: bytes = b"", + json_body: dict[str, object] | None = None, + ) -> None: + self.status_code = status_code + self.content = content + self._json_body = json_body + self.text = ( + json.dumps(json_body) + if json_body is not None + else content.decode("utf-8", errors="replace") + ) + + def json(self) -> object: + if self._json_body is not None: + return self._json_body + return json.loads(self.text) + + +class _FakeAsyncHttpClient: + get_responses: list[_FakeHttpResponse] = [] + post_responses: list[_FakeHttpResponse] = [] + calls: list[dict[str, object]] = [] + + @classmethod + def reset(cls) -> None: + cls.get_responses = [] + cls.post_responses = [] + cls.calls = [] + + async def __aenter__(self) -> _FakeAsyncHttpClient: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def get(self, url: str, **kwargs: object) -> _FakeHttpResponse: + type(self).calls.append({"method": "GET", "url": url, **kwargs}) + if type(self).get_responses: + return type(self).get_responses.pop(0) + return _FakeHttpResponse(content=_valid_tar_bytes()) + + async def post(self, url: str, **kwargs: object) -> _FakeHttpResponse: + type(self).calls.append({"method": "POST", "url": url, **kwargs}) + if type(self).post_responses: + return type(self).post_responses.pop(0) + return _FakeHttpResponse() + + +def _valid_tar_bytes() -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + info = tarfile.TarInfo(name="hello.txt") + data = b"hello" + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _unsafe_tar_bytes() -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + info = tarfile.TarInfo(name="../escape.txt") + data = b"bad" + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _load_islo_module(monkeypatch: pytest.MonkeyPatch) -> Any: + _FakeAsyncIslo.reset() + _FakeAsyncHttpClient.reset() + + fake_islo = types.ModuleType("islo") + cast(Any, fake_islo).AsyncIslo = _FakeAsyncIslo + fake_custom = types.ModuleType("islo.custom") + fake_exec = types.ModuleType("islo.custom.exec") + cast(Any, fake_exec).exec_and_wait = _fake_exec_and_wait + fake_files = types.ModuleType("islo.custom.files") + cast(Any, fake_files)._async_get_client_internals = _fake_get_client_internals + fake_core = types.ModuleType("islo.core") + fake_api_error = types.ModuleType("islo.core.api_error") + cast(Any, fake_api_error).ApiError = _FakeApiError + + monkeypatch.setitem(sys.modules, "islo", fake_islo) + monkeypatch.setitem(sys.modules, "islo.custom", fake_custom) + monkeypatch.setitem(sys.modules, "islo.custom.exec", fake_exec) + monkeypatch.setitem(sys.modules, "islo.custom.files", fake_files) + monkeypatch.setitem(sys.modules, "islo.core", fake_core) + monkeypatch.setitem(sys.modules, "islo.core.api_error", fake_api_error) + + sys.modules.pop("agents.extensions.sandbox.islo.sandbox", None) + sys.modules.pop("agents.extensions.sandbox.islo", None) + sys.modules.pop("agents.extensions.sandbox", None) + module = importlib.import_module("agents.extensions.sandbox.islo.sandbox") + monkeypatch.setattr(module.httpx, "AsyncClient", _FakeAsyncHttpClient) + return module + + +def _make_state(islo_module: Any, **overrides: object) -> Any: + data: dict[str, object] = { + "session_id": uuid.uuid4(), + "manifest": Manifest(root="/workspace"), + "snapshot": NoopSnapshot(id="snapshot"), + "sandbox_id": "sb-1", + "sandbox_name": "islo-1", + "base_url": "https://api.islo.dev", + "compute_url": "https://ca.compute.islo.dev", + } + data.update(overrides) + return islo_module.IsloSandboxSessionState(**data) + + +class _FakeIsloMountSession: + def __init__( + self, + islo_module: Any, + *, + command_results: dict[str, list[_FakeExecResult]] | None = None, + ) -> None: + self.state = _make_state(islo_module) + self.exec_calls: list[str] = [] + self.mkdir_calls: list[Path] = [] + self.write_calls: list[tuple[Path, bytes]] = [] + self.skip_paths: list[Path] = [] + self._command_results = { + command: list(results) for command, results in (command_results or {}).items() + } + + async def exec( + self, + *command: str, + shell: bool = False, + timeout: float | None = None, + user: str | None = None, + ) -> _FakeExecResult: + _ = (shell, timeout) + command_text = _command_str(list(command), user) + self.exec_calls.append(command_text) + results = self._command_results.get(command_text) + if results: + return results.pop(0) + return _FakeExecResult() + + async def mkdir( + self, + path: Path | str, + *, + parents: bool = False, + user: str | None = None, + ) -> None: + _ = (parents, user) + self.mkdir_calls.append(Path(path)) + + async def write( + self, + path: Path | str, + data: io.IOBase, + *, + user: str | None = None, + ) -> None: + _ = user + payload = data.read() + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.write_calls.append((Path(path), bytes(payload))) + + def register_persist_workspace_skip_path(self, path: Path | str) -> None: + self.skip_paths.append(Path(path)) + + def normalize_path(self, path: Path | str) -> Path: + return Path(path) + + async def _exec_checked_nonzero(self, *command: str | Path) -> _FakeExecResult: + result = await self.exec(*(str(part) for part in command), shell=False) + if not result.ok(): + raise RuntimeError(f"command failed: {command!r}") + return result + + +def _successful_mount_command_results() -> dict[str, list[_FakeExecResult]]: + return { + "sh -lc test -c /dev/fuse": [_FakeExecResult()], + "sh -lc grep -qw fuse /proc/filesystems": [_FakeExecResult()], + "sh -lc command -v fusermount3 >/dev/null 2>&1 || test -x /usr/local/bin/fusermount3": [ + _FakeExecResult() + ], + "sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone": [ + _FakeExecResult() + ], + } + + +def _make_recorded_islo_mount_session( + monkeypatch: pytest.MonkeyPatch, + islo_module: Any, + *, + command_results: dict[str, list[_FakeExecResult]] | None = None, +) -> tuple[Any, _FakeIsloMountSession]: + session = islo_module.IsloSandboxSession.from_state( + _make_state(islo_module), + client=_FakeAsyncIslo(), + ) + recorder = _FakeIsloMountSession(islo_module, command_results=command_results) + monkeypatch.setattr(session, "exec", recorder.exec) + monkeypatch.setattr(session, "mkdir", recorder.mkdir) + monkeypatch.setattr(session, "write", recorder.write) + monkeypatch.setattr(session, "_exec_checked_nonzero", recorder._exec_checked_nonzero) + monkeypatch.setattr( + session, + "register_persist_workspace_skip_path", + recorder.register_persist_workspace_skip_path, + ) + monkeypatch.setattr(session, "normalize_path", recorder.normalize_path) + return session, recorder + + +def test_islo_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatch) -> None: + islo_module = _load_islo_module(monkeypatch) + package_module = importlib.import_module("agents.extensions.sandbox.islo") + parent_module = importlib.import_module("agents.extensions.sandbox") + + assert package_module.IsloSandboxClient is islo_module.IsloSandboxClient + assert parent_module.IsloSandboxClient is islo_module.IsloSandboxClient + assert "IsloSandboxClient" in parent_module.__all__ + + +def test_islo_options_round_trip_through_base_registry(monkeypatch: pytest.MonkeyPatch) -> None: + islo_module = _load_islo_module(monkeypatch) + options = islo_module.IsloSandboxClientOptions( + base_url="https://api.test", + compute_url="https://compute.test", + name="agents-test", + image="ubuntu:24.04", + vcpus=4, + memory_mb=8192, + disk_gb=20, + snapshot_name="base-snapshot", + env={"ONLY_OPTION": "1"}, + workdir="repo", + gateway_profile="locked-down", + cache_key="cache-key", + pause_on_exit=True, + workspace_persistence="snapshot", + ) + + payload = options.model_dump(mode="json") + restored = BaseSandboxClientOptions.parse(payload) + + assert "api_key" not in payload + assert "exposed_ports" not in payload + assert (type(restored).__module__, type(restored).__qualname__) == ( + islo_module.IsloSandboxClientOptions.__module__, + islo_module.IsloSandboxClientOptions.__qualname__, + ) + assert restored.model_dump(mode="json") == payload + + +def test_islo_session_state_round_trip_through_base_registry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state( + islo_module, + compute_url="https://compute.test", + name="agents-test", + image="ubuntu:24.04", + vcpus=4, + memory_mb=8192, + disk_gb=20, + snapshot_name="base-snapshot", + base_env={"ONLY_OPTION": "1"}, + workdir="repo", + gateway_profile="locked-down", + cache_key="cache-key", + pause_on_exit=True, + workspace_persistence="snapshot", + ) + + payload = state.model_dump(mode="json") + restored = SandboxSessionState.parse(payload) + + assert "api_key" not in payload + assert type(restored) is islo_module.IsloSandboxSessionState + assert restored.model_dump(mode="json") == payload + + +@pytest.mark.asyncio +async def test_create_forwards_options_and_omits_api_key_from_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + + client = islo_module.IsloSandboxClient(api_key="client-key") + session = await client.create( + options=islo_module.IsloSandboxClientOptions( + base_url="https://api.test", + compute_url="https://compute.test", + name="agents-test", + image="ubuntu:24.04", + vcpus=4, + memory_mb=8192, + disk_gb=20, + snapshot_name="base-snapshot", + env={"ONLY_OPTION": "1"}, + workdir="repo", + gateway_profile="locked-down", + cache_key="cache-key", + pause_on_exit=True, + ) + ) + + assert _FakeAsyncIslo.instances[-1].api_key == "client-key" + assert _FakeAsyncIslo.instances[-1].base_url == "https://api.test" + assert _FakeAsyncIslo.instances[-1].compute_url == "https://compute.test" + assert _FakeSandboxesClient.create_calls == [ + { + "init": {"type": "minimal"}, + "name": "agents-test", + "image": "ubuntu:24.04", + "vcpus": 4, + "memory_mb": 8192, + "disk_gb": 20, + "snapshot_name": "base-snapshot", + "env": {"ONLY_OPTION": "1"}, + "workdir": "repo", + "gateway_profile": "locked-down", + "cache_key": "cache-key", + } + ] + assert session.state.manifest.root == islo_module.DEFAULT_ISLO_WORKSPACE_ROOT + assert "api_key" not in session.state.model_dump(mode="json") + assert session.state.base_url == "https://api.test" + assert session.state.compute_url == "https://compute.test" + assert session.state.exposed_ports == () + assert session.state.pause_on_exit is True + + +@pytest.mark.asyncio +async def test_create_omits_default_compute_url_for_published_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + + class _PublishedAsyncIslo(_FakeAsyncIslo): + def __init__(self, *, api_key: str | None = None, base_url: str | None = None) -> None: + super().__init__(api_key=api_key, base_url=base_url) + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _PublishedAsyncIslo) + + session = await islo_module.IsloSandboxClient(api_key="client-key").create( + options=islo_module.IsloSandboxClientOptions(name="published") + ) + + assert _FakeAsyncIslo.instances[-1].base_url == "https://api.islo.dev" + assert session.state.compute_url is None + + +@pytest.mark.asyncio +async def test_create_rejects_compute_url_when_sdk_does_not_support_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + + class _PublishedAsyncIslo(_FakeAsyncIslo): + def __init__(self, *, api_key: str | None = None, base_url: str | None = None) -> None: + super().__init__(api_key=api_key, base_url=base_url) + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _PublishedAsyncIslo) + + with pytest.raises(ConfigurationError, match="compute_url requires"): + await islo_module.IsloSandboxClient(api_key="client-key").create( + options=islo_module.IsloSandboxClientOptions( + name="published", + compute_url="https://compute.test", + ) + ) + + +@pytest.mark.asyncio +async def test_create_uses_legacy_init_capabilities_when_init_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _LegacySandboxesClient: + async def create_sandbox( + self, + *, + name: str | None = None, + init_capabilities: list[str] | None = None, + ) -> _FakeSandboxResponse: + create_calls.append({"name": name, "init_capabilities": init_capabilities}) + return _FakeSandboxResponse(sandbox_id="sb-legacy", name=name or "legacy") + + class _LegacyAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _LegacySandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _LegacyAsyncIslo) + + client = islo_module.IsloSandboxClient(api_key="client-key") + await client.create( + options=islo_module.IsloSandboxClientOptions( + name="legacy", + ) + ) + + assert create_calls == [{"name": "legacy", "init_capabilities": []}] + + +@pytest.mark.asyncio +async def test_create_errors_when_explicit_init_is_not_supported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + + class _UnsupportedSandboxesClient: + async def create_sandbox(self, *, name: str | None = None) -> _FakeSandboxResponse: + return _FakeSandboxResponse(sandbox_id="sb-unsupported", name=name or "unsupported") + + class _UnsupportedAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _UnsupportedSandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _UnsupportedAsyncIslo) + + client = islo_module.IsloSandboxClient(api_key="client-key") + with pytest.raises(ConfigurationError, match="explicit init"): + await client.create(options=islo_module.IsloSandboxClientOptions(name="unsupported")) + + +@pytest.mark.asyncio +async def test_exec_merges_manifest_env_and_maps_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state( + islo_module, + manifest=Manifest( + root="/workspace", + environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}), + ), + base_env={"SHARED": "option", "ONLY_OPTION": "1"}, + ) + client = _FakeAsyncIslo() + client.exec_results.append(_FakeExecResult(stdout="hello", stderr="", exit_code=0)) + session = islo_module.IsloSandboxSession.from_state(state, client=client) + + result = await session.exec("echo", "hello", shell=False, user="islo") + + assert result.stdout == b"hello" + assert client.exec_calls[-1]["command"] == ["echo", "hello"] + assert client.exec_calls[-1]["user"] == "islo" + assert client.exec_calls[-1]["workdir"] == "/workspace" + assert client.exec_calls[-1]["env"] == { + "SHARED": "manifest", + "ONLY_OPTION": "1", + "ONLY_MANIFEST": "1", + } + + client.exec_results.append(_FakeExecResult(timed_out=True, exit_code=-1)) + with pytest.raises(ExecTimeoutError): + await session.exec("sleep", "999", shell=False, timeout=0.01) + + +@pytest.mark.asyncio +async def test_read_and_write_use_islo_file_http(monkeypatch: pytest.MonkeyPatch) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module) + client = _FakeAsyncIslo( + api_key="key", + base_url="https://api.test", + compute_url="https://compute.test", + ) + session = islo_module.IsloSandboxSession.from_state(state, client=client) + _FakeAsyncHttpClient.get_responses.append(_FakeHttpResponse(content=b"file contents")) + + read_result = await session.read("notes.txt") + await session.write("notes.txt", io.BytesIO(b"updated")) + + assert read_result.read() == b"file contents" + assert _FakeAsyncHttpClient.calls[0]["method"] == "GET" + assert _FakeAsyncHttpClient.calls[0]["url"] == "https://compute.test/sandboxes/islo-1/files" + assert _FakeAsyncHttpClient.calls[0]["params"] == {"path": "/workspace/notes.txt"} + assert _FakeAsyncHttpClient.calls[1]["method"] == "POST" + assert _FakeAsyncHttpClient.calls[1]["url"] == "https://compute.test/sandboxes/islo-1/files" + assert _FakeAsyncHttpClient.calls[1]["params"] == {"path": "/workspace/notes.txt"} + + +@pytest.mark.asyncio +async def test_write_with_user_copies_uploaded_payload_as_requested_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module) + client = _FakeAsyncIslo(api_key="key", base_url="https://api.test") + session = islo_module.IsloSandboxSession.from_state(state, client=client) + + await session.write("owned.txt", io.BytesIO(b"owned"), user="islo") + + assert _FakeAsyncHttpClient.calls[0]["method"] == "POST" + upload_params = cast(dict[str, str], _FakeAsyncHttpClient.calls[0]["params"]) + temp_path = upload_params["path"] + assert temp_path.startswith("/tmp/openai-agents-islo-write-") + user_write_call = next( + call + for call in client.exec_calls + if call.get("user") == "islo" and cast(list[str], call["command"])[2] == 'cat "$1" > "$2"' + ) + assert user_write_call["command"] == [ + "sh", + "-lc", + 'cat "$1" > "$2"', + "sh", + temp_path, + "/workspace/owned.txt", + ] + + +@pytest.mark.asyncio +async def test_read_maps_404_to_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module) + client = _FakeAsyncIslo() + session = islo_module.IsloSandboxSession.from_state(state, client=client) + _FakeAsyncHttpClient.get_responses.append( + _FakeHttpResponse(status_code=404, json_body={"detail": "missing"}) + ) + + with pytest.raises(WorkspaceReadNotFoundError): + await session.read("missing.txt") + + +@pytest.mark.asyncio +async def test_tar_persist_uses_excludes_and_downloads_archive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module) + client = _FakeAsyncIslo() + session = islo_module.IsloSandboxSession.from_state(state, client=client) + session.register_persist_workspace_skip_path("runtime.tmp") + _FakeAsyncHttpClient.get_responses.append(_FakeHttpResponse(content=_valid_tar_bytes())) + + archive = await session.persist_workspace() + + assert archive.read() == _valid_tar_bytes() + tar_command = cast(list[str], client.exec_calls[0]["command"])[2] + assert "--exclude=runtime.tmp" in tar_command + assert "-C /workspace" in tar_command + assert _FakeAsyncHttpClient.calls[0]["method"] == "GET" + + +@pytest.mark.asyncio +async def test_hydrate_rejects_unsafe_tar_before_upload(monkeypatch: pytest.MonkeyPatch) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module) + client = _FakeAsyncIslo() + session = islo_module.IsloSandboxSession.from_state(state, client=client) + + with pytest.raises(WorkspaceArchiveWriteError): + await session.hydrate_workspace(io.BytesIO(_unsafe_tar_bytes())) + + assert _FakeAsyncHttpClient.calls == [] + + +@pytest.mark.asyncio +async def test_resume_reconnects_paused_and_recreates_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + client = islo_module.IsloSandboxClient() + + _FakeSandboxesClient.sandboxes["paused"] = _FakeSandboxResponse( + sandbox_id="sb-paused", + name="paused", + status="paused", + ) + paused_state = _make_state(islo_module, sandbox_id="sb-paused", sandbox_name="paused") + paused = await client.resume(paused_state) + + assert _FakeSandboxesClient.resume_calls == ["paused"] + assert paused._inner._workspace_state_preserved_on_start() is True # noqa: SLF001 + + missing_state = _make_state(islo_module, sandbox_id="sb-missing", sandbox_name="missing") + missing = await client.resume(missing_state) + + assert _FakeSandboxesClient.create_calls[-1]["name"] == "missing" + assert _FakeSandboxesClient.create_calls[-1]["init"] == {"type": "minimal"} + assert missing.state.sandbox_id != "sb-missing" + assert missing._inner._workspace_state_preserved_on_start() is False # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_resume_surfaces_get_sandbox_provider_errors_without_recreate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _FailingGetSandboxesClient: + async def get_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + raise _FakeApiError(status_code=500, body=f"failed to get {sandbox_name}") + + async def create_sandbox(self, **kwargs: object) -> _FakeSandboxResponse: + create_calls.append(kwargs) + return _FakeSandboxResponse(sandbox_id="sb-recreated", name="recreated") + + class _FailingGetAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _FailingGetSandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _FailingGetAsyncIslo) + state = _make_state(islo_module, sandbox_id="sb-existing", sandbox_name="existing") + + with pytest.raises(_FakeApiError, match="status_code=500"): + await islo_module.IsloSandboxClient().resume(state) + + assert create_calls == [] + assert _FakeAsyncIslo.instances[-1]._client_wrapper.httpx_client.closed is True + + +@pytest.mark.asyncio +async def test_resume_surfaces_resume_sandbox_provider_errors_without_recreate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _FailingResumeSandboxesClient: + async def get_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + return _FakeSandboxResponse( + sandbox_id="sb-paused", + name=sandbox_name, + status="paused", + ) + + async def resume_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + raise _FakeApiError(status_code=503, body=f"failed to resume {sandbox_name}") + + async def create_sandbox(self, **kwargs: object) -> _FakeSandboxResponse: + create_calls.append(kwargs) + return _FakeSandboxResponse(sandbox_id="sb-recreated", name="recreated") + + class _FailingResumeAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _FailingResumeSandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _FailingResumeAsyncIslo) + state = _make_state(islo_module, sandbox_id="sb-paused", sandbox_name="paused") + + with pytest.raises(_FakeApiError, match="status_code=503"): + await islo_module.IsloSandboxClient().resume(state) + + assert create_calls == [] + assert _FakeAsyncIslo.instances[-1]._client_wrapper.httpx_client.closed is True + + +@pytest.mark.asyncio +async def test_resume_surfaces_transitional_status_without_recreate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _StartingSandboxesClient: + async def get_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + return _FakeSandboxResponse( + sandbox_id="sb-starting", + name=sandbox_name, + status="starting", + ) + + async def create_sandbox( + self, + *, + init: dict[str, str] | None = None, + **kwargs: object, + ) -> _FakeSandboxResponse: + kwargs["init"] = init + create_calls.append(kwargs) + return _FakeSandboxResponse(sandbox_id="sb-recreated", name="recreated") + + class _StartingAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _StartingSandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _StartingAsyncIslo) + state = _make_state(islo_module, sandbox_id="sb-starting", sandbox_name="starting") + + with pytest.raises(RuntimeError, match="status=starting"): + await islo_module.IsloSandboxClient().resume(state) + + assert create_calls == [] + assert _FakeAsyncIslo.instances[-1]._client_wrapper.httpx_client.closed is True + + +@pytest.mark.asyncio +async def test_resume_recreates_stale_running_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + + class _StaleAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + self.exec_results.append(_FakeApiError(status_code=404, body="VM not found")) + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _StaleAsyncIslo) + _FakeSandboxesClient.sandboxes["stale"] = _FakeSandboxResponse( + sandbox_id="sb-stale", + name="stale", + status="running", + ) + stale_state = _make_state( + islo_module, + sandbox_id="sb-stale", + sandbox_name="stale", + name="stale", + workspace_root_ready=True, + ) + + resumed = await islo_module.IsloSandboxClient().resume(stale_state) + + assert _FakeSandboxesClient.create_calls[-1]["name"] == "stale" + assert resumed.state.sandbox_id != "sb-stale" + assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_resume_recreates_with_generated_name_when_old_name_is_reserved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _ReservedNameSandboxesClient: + async def get_sandbox(self, sandbox_name: str) -> _FakeSandboxResponse: + return _FakeSandboxResponse( + sandbox_id="sb-stale", + name=sandbox_name, + status="running", + ) + + async def create_sandbox( + self, + *, + name: str | None = None, + init: dict[str, str] | None = None, + ) -> _FakeSandboxResponse: + create_calls.append({"name": name, "init": init}) + if name == "stale": + raise _FakeApiError( + status_code=400, + body={"message": "Sandbox 'stale' already exists for tenant"}, + ) + return _FakeSandboxResponse(sandbox_id="sb-recreated", name=name or "generated") + + class _StaleReservedNameAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _ReservedNameSandboxesClient() + self.exec_results.append(_FakeApiError(status_code=404, body="VM not found")) + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _StaleReservedNameAsyncIslo) + stale_state = _make_state( + islo_module, + sandbox_id="sb-stale", + sandbox_name="stale", + name="stale", + workspace_root_ready=True, + ) + + resumed = await islo_module.IsloSandboxClient().resume(stale_state) + + assert create_calls == [ + {"name": "stale", "init": {"type": "minimal"}}, + {"name": None, "init": {"type": "minimal"}}, + ] + assert resumed.state.sandbox_id == "sb-recreated" + assert resumed.state.sandbox_name == "generated" + assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_snapshot_mode_persists_reference_and_hydrates_by_recreate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + state = _make_state(islo_module, workspace_persistence="snapshot") + _FakeSandboxesClient.sandboxes["islo-1"] = _FakeSandboxResponse( + sandbox_id="sb-1", + name="islo-1", + ) + client = _FakeAsyncIslo() + session = islo_module.IsloSandboxSession.from_state(state, client=client) + + snapshot_ref = await session.persist_workspace() + payload = snapshot_ref.read() + assert payload.startswith(b"ISLO_SANDBOX_SNAPSHOT_V1\n") + assert _FakeSnapshotsClient.create_calls[0]["sandbox_name"] == "islo-1" + + await session.hydrate_workspace(io.BytesIO(payload)) + + assert _FakeSandboxesClient.delete_calls == ["islo-1"] + restored_snapshot_name = cast(str, _FakeSandboxesClient.create_calls[-1]["snapshot_name"]) + assert restored_snapshot_name.startswith("openai-agents-") + assert _FakeSandboxesClient.create_calls[-1]["init"] == {"type": "minimal"} + + +@pytest.mark.asyncio +async def test_snapshot_restore_recreates_with_generated_name_when_old_name_is_reserved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + islo_module = _load_islo_module(monkeypatch) + create_calls: list[dict[str, object]] = [] + + class _ReservedNameSandboxesClient: + async def delete_sandbox(self, sandbox_name: str) -> None: + assert sandbox_name == "stale" + + async def create_sandbox( + self, + *, + name: str | None = None, + snapshot_name: str | None = None, + init: dict[str, str] | None = None, + ) -> _FakeSandboxResponse: + create_calls.append({"name": name, "snapshot_name": snapshot_name, "init": init}) + if name == "stale": + raise _FakeApiError( + status_code=400, + body={"message": "Sandbox 'stale' already exists for tenant"}, + ) + return _FakeSandboxResponse(sandbox_id="sb-recreated", name=name or "generated") + + class _ReservedNameAsyncIslo(_FakeAsyncIslo): + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + compute_url: str | None = None, + ) -> None: + super().__init__(api_key=api_key, base_url=base_url, compute_url=compute_url) + cast(Any, self).sandboxes = _ReservedNameSandboxesClient() + + monkeypatch.setattr(islo_module, "_import_islo_sdk", lambda: _ReservedNameAsyncIslo) + state = _make_state( + islo_module, + sandbox_id="sb-stale", + sandbox_name="stale", + name="stale", + workspace_persistence="snapshot", + ) + session = islo_module.IsloSandboxSession.from_state(state, client=_ReservedNameAsyncIslo()) + snapshot_ref = b'ISLO_SANDBOX_SNAPSHOT_V1\n{"snapshot_name":"snap-1"}' + + await session.hydrate_workspace(io.BytesIO(snapshot_ref)) + + assert create_calls == [ + {"name": "stale", "snapshot_name": "snap-1", "init": {"type": "minimal"}}, + {"name": None, "snapshot_name": "snap-1", "init": {"type": "minimal"}}, + ] + assert session.state.sandbox_id == "sb-recreated" + assert session.state.sandbox_name == "generated" + + +# --------------------------------------------------------------------------- +# IsloCloudBucketMountStrategy tests +# --------------------------------------------------------------------------- + + +def test_islo_cloud_bucket_mount_strategy_re_exported(monkeypatch: pytest.MonkeyPatch) -> None: + _load_islo_module(monkeypatch) + from agents.extensions.sandbox import IsloCloudBucketMountStrategy as TopLevelExport + from agents.extensions.sandbox.islo import IsloCloudBucketMountStrategy + + assert IsloCloudBucketMountStrategy is TopLevelExport + sandbox_extensions = __import__("agents.extensions.sandbox", fromlist=["__all__"]) + assert "IsloCloudBucketMountStrategy" in sandbox_extensions.__all__ + + +def test_islo_cloud_bucket_mount_strategy_type_field() -> None: + from agents.extensions.sandbox.islo.mounts import IsloCloudBucketMountStrategy + + strategy = IsloCloudBucketMountStrategy() + assert strategy.type == "islo_cloud_bucket" + + +def test_islo_cloud_bucket_mount_strategy_round_trips_through_registry() -> None: + from agents.extensions.sandbox.islo.mounts import IsloCloudBucketMountStrategy + from agents.sandbox.entries.mounts.base import MountStrategyBase + + strategy = IsloCloudBucketMountStrategy() + payload = strategy.model_dump(mode="json") + + restored = MountStrategyBase.parse(payload) + + assert payload["type"] == "islo_cloud_bucket" + assert type(restored) is IsloCloudBucketMountStrategy + assert restored.model_dump(mode="json") == payload + + +def test_islo_cloud_bucket_mount_strategy_rejects_wrong_session() -> None: + import importlib + + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + + class _NotIsloSession: + pass + + with pytest.raises(MountConfigError, match="IsloSandboxSession"): + mounts_module._assert_islo_session(_NotIsloSession()) + + +def test_islo_cloud_bucket_mount_strategy_accepts_islo_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + state = _make_state(islo_module) + session = islo_module.IsloSandboxSession.from_state(state, client=_FakeAsyncIslo()) + + mounts_module._assert_islo_session(session) + + +@pytest.mark.asyncio +async def test_islo_ensure_rclone_installs_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + rclone_check = "sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone" + apt_check = "sh -lc command -v apt-get >/dev/null 2>&1 || test -x /usr/local/bin/apt-get" + install = ( + "sudo -u root -- sh -lc apt-get update -qq && " + "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rclone" + ) + session = _FakeIsloMountSession( + islo_module, + command_results={ + rclone_check: [_FakeExecResult(exit_code=1), _FakeExecResult()], + apt_check: [_FakeExecResult()], + install: [_FakeExecResult()], + }, + ) + + await mounts_module._ensure_rclone(session) + + assert session.exec_calls == [rclone_check, apt_check, install, rclone_check] + + +@pytest.mark.asyncio +async def test_islo_ensure_rclone_errors_without_package_manager( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + session = _FakeIsloMountSession( + islo_module, + command_results={ + "sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone": [ + _FakeExecResult(exit_code=1) + ], + "sh -lc command -v apt-get >/dev/null 2>&1 || test -x /usr/local/bin/apt-get": [ + _FakeExecResult(exit_code=1) + ], + "sh -lc command -v apk >/dev/null 2>&1 || test -x /usr/local/bin/apk": [ + _FakeExecResult(exit_code=1) + ], + }, + ) + + with pytest.raises(MountConfigError, match="no supported package manager"): + await mounts_module._ensure_rclone(session) + + +@pytest.mark.asyncio +async def test_islo_ensure_rclone_errors_after_failed_install( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + install = ( + "sudo -u root -- sh -lc apt-get update -qq && " + "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rclone" + ) + session = _FakeIsloMountSession( + islo_module, + command_results={ + "sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone": [ + _FakeExecResult(exit_code=1) + ], + "sh -lc command -v apt-get >/dev/null 2>&1 || test -x /usr/local/bin/apt-get": [ + _FakeExecResult() + ], + install: [ + _FakeExecResult(exit_code=100), + _FakeExecResult(exit_code=100), + _FakeExecResult(exit_code=100), + ], + }, + ) + + with pytest.raises(MountConfigError, match="failed to install rclone"): + await mounts_module._ensure_rclone(session) + + assert session.exec_calls.count(install) == 3 + + +@pytest.mark.asyncio +async def test_islo_ensure_fuse_support_errors_when_dev_fuse_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + session = _FakeIsloMountSession( + islo_module, + command_results={"sh -lc test -c /dev/fuse": [_FakeExecResult(exit_code=1)]}, + ) + + with pytest.raises(MountConfigError, match="/dev/fuse"): + await mounts_module._ensure_fuse_support(session) + + +@pytest.mark.asyncio +async def test_islo_ensure_fuse_support_installs_fuse3_when_fusermount_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + fusermount3_check = ( + "sh -lc command -v fusermount3 >/dev/null 2>&1 || test -x /usr/local/bin/fusermount3" + ) + fuse_install = ( + "sudo -u root -- sh -lc apt-get update -qq && " + "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fuse3" + ) + session = _FakeIsloMountSession( + islo_module, + command_results={ + "sh -lc test -c /dev/fuse": [_FakeExecResult()], + "sh -lc grep -qw fuse /proc/filesystems": [_FakeExecResult()], + fusermount3_check: [_FakeExecResult(exit_code=1), _FakeExecResult()], + "sh -lc command -v fusermount >/dev/null 2>&1 || test -x /usr/local/bin/fusermount": [ + _FakeExecResult(exit_code=1) + ], + "sh -lc command -v apt-get >/dev/null 2>&1 || test -x /usr/local/bin/apt-get": [ + _FakeExecResult() + ], + fuse_install: [_FakeExecResult()], + }, + ) + + await mounts_module._ensure_fuse_support(session) + + assert fuse_install in session.exec_calls + + +@pytest.mark.asyncio +async def test_islo_cloud_bucket_mount_strategy_activate_delegates_to_rclone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + strategy = mounts_module.IsloCloudBucketMountStrategy() + mount = S3Mount( + bucket="test-bucket", + prefix="fixtures", + access_key_id="access-key", + secret_access_key="secret-key", + mount_strategy=strategy, + ) + session, recorder = _make_recorded_islo_mount_session( + monkeypatch, + islo_module, + command_results=_successful_mount_command_results(), + ) + + result = await strategy.activate(mount, session, Path("data"), Path("/workspace")) + + session_id = session.state.session_id.hex + config_dir = Path(f".sandbox-rclone-config/{session_id}") + assert result == [] + assert Path("/workspace/data") in recorder.mkdir_calls + assert config_dir in recorder.mkdir_calls + assert config_dir in recorder.skip_paths + assert recorder.write_calls[0][0] == config_dir / f"sandbox_s3_{session_id}.conf" + assert "secret_access_key = secret-key" in recorder.write_calls[0][1].decode("utf-8") + assert any( + call.startswith(f"rclone mount sandbox_s3_{session_id}:test-bucket/fixtures ") + for call in recorder.exec_calls + ) + + +@pytest.mark.asyncio +async def test_islo_cloud_bucket_mount_strategy_restore_after_snapshot_replays_mount( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib + + islo_module = _load_islo_module(monkeypatch) + mounts_module = importlib.import_module("agents.extensions.sandbox.islo.mounts") + strategy = mounts_module.IsloCloudBucketMountStrategy(pattern=RcloneMountPattern(mode="fuse")) + mount = S3Mount( + bucket="test-bucket", + mount_strategy=strategy, + ) + session, recorder = _make_recorded_islo_mount_session( + monkeypatch, + islo_module, + command_results=_successful_mount_command_results(), + ) + + await strategy.restore_after_snapshot(mount, session, Path("/workspace/restored")) + + session_id = session.state.session_id.hex + assert "sh -lc test -c /dev/fuse" in recorder.exec_calls + assert "sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone" in ( + recorder.exec_calls + ) + assert any( + call.startswith(f"rclone mount sandbox_s3_{session_id}:test-bucket /workspace/restored") + for call in recorder.exec_calls + ) diff --git a/tests/sandbox/test_client_options.py b/tests/sandbox/test_client_options.py index 8c71dc4028..57ca84a337 100644 --- a/tests/sandbox/test_client_options.py +++ b/tests/sandbox/test_client_options.py @@ -8,6 +8,7 @@ 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.islo import IsloSandboxClientOptions from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE from agents.sandbox.sandboxes import DockerSandboxClientOptions, UnixLocalSandboxClientOptions from agents.sandbox.session import BaseSandboxClientOptions @@ -69,6 +70,7 @@ 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"), + IsloSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE), ], ) 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 5a11e5bf77..cef57abfee 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -297,6 +297,18 @@ def test_core_sandbox_public_export_surface_is_stable() -> None: "CloudflareSandboxSessionState", }, ), + ( + "agents.extensions.sandbox.islo", + { + "DEFAULT_ISLO_WORKSPACE_ROOT", + "IsloCloudBucketMountStrategy", + "IsloSandboxClient", + "IsloSandboxClientOptions", + "IsloSandboxSession", + "IsloSandboxSessionState", + "IsloSandboxTimeouts", + }, + ), ( "agents.extensions.sandbox.runloop", { @@ -473,6 +485,27 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( "exposed_port_url_ttl_s", ), ), + ( + "agents.extensions.sandbox.islo", + "IsloSandboxClientOptions", + ( + "base_url", + "compute_url", + "name", + "image", + "vcpus", + "memory_mb", + "disk_gb", + "snapshot_name", + "env", + "workdir", + "gateway_profile", + "cache_key", + "pause_on_exit", + "timeouts", + "workspace_persistence", + ), + ), ( "agents.extensions.sandbox.runloop", "RunloopSandboxClientOptions", @@ -666,6 +699,37 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "exposed_port_url_ttl_s", ), ), + ( + "agents.extensions.sandbox.islo", + "IsloSandboxSessionState", + ( + "type", + "session_id", + "snapshot", + "manifest", + "exposed_ports", + "snapshot_fingerprint", + "snapshot_fingerprint_version", + "workspace_root_ready", + "sandbox_id", + "sandbox_name", + "base_url", + "compute_url", + "name", + "image", + "vcpus", + "memory_mb", + "disk_gb", + "snapshot_name", + "base_env", + "workdir", + "gateway_profile", + "cache_key", + "pause_on_exit", + "timeouts", + "workspace_persistence", + ), + ), ( "agents.extensions.sandbox.blaxel", "BlaxelSandboxSessionState", @@ -784,6 +848,7 @@ def test_sandbox_session_state_field_order_is_stable( "cloudflare", ), ("agents.extensions.sandbox.daytona", "DaytonaSandboxClientOptions", (), "daytona"), + ("agents.extensions.sandbox.islo", "IsloSandboxClientOptions", (), "islo"), ("agents.extensions.sandbox.runloop", "RunloopSandboxClientOptions", (), "runloop"), ("agents.extensions.sandbox.vercel", "VercelSandboxClientOptions", (), "vercel"), ], @@ -836,6 +901,16 @@ def test_optional_sandbox_client_options_json_round_trip_preserves_type( "DaytonaSandboxSessionState", {"sandbox_id": "sandbox-123"}, ), + ( + "agents.extensions.sandbox.islo", + "IsloSandboxSessionState", + { + "sandbox_id": "sandbox-123", + "sandbox_name": "sandbox-name", + "base_url": "https://api.islo.dev", + "compute_url": "https://ca.compute.islo.dev", + }, + ), ( "agents.extensions.sandbox.blaxel", "BlaxelSandboxSessionState", @@ -901,6 +976,8 @@ def test_core_discriminator_type_strings_are_stable() -> None: ("agents.sandbox.sandboxes.unix_local", "UnixLocalSandboxSessionState", "unix_local"), ("agents.sandbox.sandboxes.docker", "DockerSandboxClientOptions", "docker"), ("agents.sandbox.sandboxes.docker", "DockerSandboxSessionState", "docker"), + ("agents.extensions.sandbox.islo", "IsloSandboxClientOptions", "islo"), + ("agents.extensions.sandbox.islo", "IsloSandboxSessionState", "islo"), ], ) def test_optional_sandbox_discriminator_type_strings_are_stable( @@ -959,6 +1036,11 @@ def test_mount_strategy_type_strings_round_trip_through_registry( "RunloopCloudBucketMountStrategy", "runloop_cloud_bucket", ), + ( + "agents.extensions.sandbox.islo", + "IsloCloudBucketMountStrategy", + "islo_cloud_bucket", + ), ], ) def test_optional_mount_strategy_type_strings_round_trip_through_registry( diff --git a/tests/test_run_examples_script.py b/tests/test_run_examples_script.py index 09794c4569..9c15b0900a 100644 --- a/tests/test_run_examples_script.py +++ b/tests/test_run_examples_script.py @@ -12,6 +12,7 @@ def test_default_auto_skip_excludes_prerequisite_bound_examples() -> None: "examples/sandbox/docker/mounts/s3_files_mount_read_write.py", "examples/sandbox/docker/mounts/s3_mount_read_write.py", "examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py", + "examples/sandbox/extensions/islo_runner.py", "examples/sandbox/extensions/temporal/temporal_sandbox_agent.py", "examples/sandbox/extensions/vercel_runner.py", "examples/sandbox/memory_s3.py", diff --git a/uv.lock b/uv.lock index 9ba071bc13..5b044e5afe 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -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 = "2026-06-07T13:33:55.004753Z" exclude-newer-span = "P7D" [[package]] @@ -1523,6 +1523,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/7f/9e41fd793827af8cbe812fff625d62b3b47603d62145b718307ef4e381eb/inline_snapshot-0.27.2-py3-none-any.whl", hash = "sha256:7c11f78ad560669bccd38d6d3aa3ef33d6a8618d53bd959019dca3a452272b7e", size = 68004, upload-time = "2025-08-11T07:49:53.904Z" }, ] +[[package]] +name = "islo" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/1f/4712c92296e05b0cfa5c0ab608034a4dc99cbb4c884a6230688fb38652b8/islo-0.3.3.tar.gz", hash = "sha256:88bad13210b8826c0cc1bbb5de179c801df9e07d4035a5f9f03f5d8110029725", size = 79337, upload-time = "2026-06-04T13:36:44.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/7f/2d46aa36d732c48a25024a336ebb7b2ca822a94a27d6450a29e3ff027bd2/islo-0.3.3-py3-none-any.whl", hash = "sha256:cc6c1a7d0e27e5dc0268a25974db48543724ba548c7069aa8ff47d2b66a831a1", size = 141504, upload-time = "2026-06-04T13:36:42.707Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -2463,6 +2477,9 @@ e2b = [ encrypt = [ { name = "cryptography" }, ] +islo = [ + { name = "islo" }, +] litellm = [ { name = "litellm" }, ] @@ -2555,6 +2572,7 @@ requires-dist = [ { name = "graphviz", marker = "extra == 'viz'", specifier = ">=0.17" }, { name = "griffelib", specifier = ">=2,<3" }, { name = "grpcio", marker = "extra == 'dapr'", specifier = ">=1.60.0" }, + { name = "islo", marker = "extra == 'islo'", specifier = ">=0.3.3,<0.4" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.83.0" }, { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<2" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, @@ -2575,7 +2593,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", "islo", "runloop", "vercel", "s3", "temporal"] [package.metadata.requires-dev] dev = [