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 = [