Files
2026-07-13 12:39:17 +08:00

3642 lines
139 KiB
Python

from __future__ import annotations
import asyncio
import io
import json
import tarfile
import time
import uuid
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import ValidationError
from agents.sandbox import Manifest, SandboxPathGrant
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
from agents.sandbox.errors import (
ExecTimeoutError,
ExecTransportError,
ExposedPortUnavailableError,
InvalidManifestPathError,
WorkspaceArchiveReadError,
WorkspaceArchiveWriteError,
WorkspaceReadNotFoundError,
WorkspaceWriteTypeError,
)
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExposedPortEndpoint
from agents.sandbox.util.tar_utils import validate_tar_bytes
from tests._fake_workspace_paths import resolve_fake_workspace_path
# ---------------------------------------------------------------------------
# Package re-export test
# ---------------------------------------------------------------------------
def test_blaxel_package_re_exports_backend_symbols() -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelSandboxClient
package_module = __import__(
"agents.extensions.sandbox.blaxel", fromlist=["BlaxelSandboxClient"]
)
assert package_module.BlaxelSandboxClient is BlaxelSandboxClient
# ---------------------------------------------------------------------------
# Fakes that replicate the Blaxel SDK surface used by the sandbox backend.
# ---------------------------------------------------------------------------
class _FakeExecResult:
def __init__(
self,
*,
exit_code: int = 0,
output: str = "",
stderr: str = "",
pid: str = "",
) -> None:
self.exit_code = exit_code
self.stdout = output
self.stderr = stderr
self.logs = output
self.pid = pid
def _fake_helper_exec_result(command: str, *, symlinks: dict[str, str]) -> _FakeExecResult | None:
resolved = resolve_fake_workspace_path(
command,
symlinks=symlinks,
home_dir="/workspace",
)
if resolved is not None:
return _FakeExecResult(
exit_code=resolved.exit_code,
output=resolved.stdout,
stderr=resolved.stderr,
)
if "INSTALL_RUNTIME_HELPER_V1" in command or command.startswith(
"test -x /tmp/openai-agents/bin/resolve-workspace-path-"
):
return _FakeExecResult()
return None
class _FakeProcess:
def __init__(self) -> None:
self.exec_calls: list[tuple[dict[str, Any], dict[str, object]]] = []
self.next_result = _FakeExecResult()
self._results_queue: list[_FakeExecResult] = []
self.delay: float = 0.0
self.symlinks: dict[str, str] = {}
async def exec(self, config: dict[str, Any], **kwargs: object) -> _FakeExecResult:
self.exec_calls.append((config, dict(kwargs)))
helper_result = _fake_helper_exec_result(
str(config.get("command", "")),
symlinks=self.symlinks,
)
if helper_result is not None:
return helper_result
if self.delay > 0:
await asyncio.sleep(self.delay)
if self._results_queue:
return self._results_queue.pop(0)
result = self.next_result
self.next_result = _FakeExecResult()
return result
class _FakeFs:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.dirs: list[str] = []
self.mkdir_calls: list[str] = []
self.read_error: Exception | None = None
self.write_error: Exception | None = None
self.mkdir_error: Exception | None = None
self.return_str: bool = False
self.read_binary_calls: list[str] = []
self.write_binary_calls: list[tuple[str, bytes]] = []
async def mkdir(self, path: str, permissions: str = "0755") -> None:
self.mkdir_calls.append(path)
if self.mkdir_error is not None:
raise self.mkdir_error
self.dirs.append(path)
async def read_binary(self, path: str) -> bytes | str:
self.read_binary_calls.append(path)
if self.read_error is not None:
raise self.read_error
if path not in self.files:
raise FileNotFoundError(f"not found: {path}")
data = self.files[path]
if self.return_str:
return data.decode("utf-8")
return data
async def write_binary(self, path: str, data: bytes) -> None:
self.write_binary_calls.append((path, data))
if self.write_error is not None:
raise self.write_error
self.files[path] = data
async def ls(self, path: str) -> list[str]:
# Return files whose paths start with the given directory.
matches = [p for p in self.files if p.startswith(path.rstrip("/") + "/") or p == path]
return matches if matches else [path]
class _FakePreviewToken:
def __init__(self, value: str = "fake-token-abc123") -> None:
self.value = value
class _FakePreviewTokens:
def __init__(self) -> None:
self.create_calls: list[Any] = []
self.next_token = _FakePreviewToken()
self.error: Exception | None = None
async def create(self, expires_at: Any) -> _FakePreviewToken:
self.create_calls.append(expires_at)
if self.error is not None:
raise self.error
return self.next_token
class _FakePreview:
def __init__(self, url: str = "https://preview.example.com:443/") -> None:
self.url = url
self.tokens = _FakePreviewTokens()
class _FakePreviews:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.next_preview = _FakePreview()
self.error: Exception | None = None
async def create_if_not_exists(self, config: dict[str, Any]) -> _FakePreview:
self.calls.append(config)
if self.error is not None:
raise self.error
return self.next_preview
class _FakeMetadata:
def __init__(self, name: str = "test-sandbox", url: str = "https://test.bl.run") -> None:
self.name = name
self.url = url
class _FakeSandboxModel:
def __init__(self, name: str = "test-sandbox", url: str = "https://test.bl.run") -> None:
self.metadata = _FakeMetadata(name=name, url=url)
class _FakeDrives:
"""Fake drives API for testing Blaxel Drive mounts."""
def __init__(self) -> None:
self.mount_calls: list[tuple[str, str, str]] = []
self.unmount_calls: list[str] = []
self.mount_error: Exception | None = None
self.unmount_error: Exception | None = None
async def mount(self, drive_name: str, mount_path: str, drive_path: str) -> None:
self.mount_calls.append((drive_name, mount_path, drive_path))
if self.mount_error is not None:
raise self.mount_error
async def unmount(self, mount_path: str) -> None:
self.unmount_calls.append(mount_path)
if self.unmount_error is not None:
raise self.unmount_error
class _FakeSandboxInstance:
"""Mimics ``blaxel.core.sandbox.SandboxInstance``."""
def __init__(self, name: str = "test-sandbox", url: str = "https://test.bl.run") -> None:
self.process = _FakeProcess()
self.fs = _FakeFs()
self.previews = _FakePreviews()
self.sandbox = _FakeSandboxModel(name=name, url=url)
self.drives = _FakeDrives()
self._deleted = False
async def delete(self) -> None:
self._deleted = True
# Class-level stubs used by the client.
_instances: dict[str, _FakeSandboxInstance] = {}
_create_error: Exception | None = None
@classmethod
async def create_if_not_exists(cls, config: dict[str, Any]) -> _FakeSandboxInstance:
if cls._create_error is not None:
raise cls._create_error
name = config.get("name", "default")
inst = cls(name=name)
cls._instances[name] = inst
return inst
@classmethod
async def get(cls, name: str) -> _FakeSandboxInstance:
if name in cls._instances:
return cls._instances[name]
raise RuntimeError(f"sandbox {name} not found")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_fake_instances() -> None:
_FakeSandboxInstance._instances.clear()
_FakeSandboxInstance._create_error = None
@pytest.fixture()
def fake_sandbox() -> _FakeSandboxInstance:
return _FakeSandboxInstance(name="test-sandbox")
def _make_state(
sandbox_name: str = "test-sandbox",
root: str = "/workspace",
pause_on_exit: bool = False,
sandbox_url: str | None = "https://test.bl.run",
extra_path_grants: tuple[SandboxPathGrant, ...] = (),
) -> Any:
from agents.extensions.sandbox.blaxel.sandbox import (
BlaxelSandboxSessionState,
BlaxelTimeouts,
)
return BlaxelSandboxSessionState(
session_id=uuid.uuid4(),
manifest=Manifest(root=root, extra_path_grants=extra_path_grants),
snapshot=NoopSnapshot(id="test-snapshot"),
sandbox_name=sandbox_name,
pause_on_exit=pause_on_exit,
timeouts=BlaxelTimeouts(),
sandbox_url=sandbox_url,
)
def _make_session(
fake: _FakeSandboxInstance,
state: Any | None = None,
token: str | None = "test-token",
) -> Any:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelSandboxSession
if state is None:
state = _make_state()
return BlaxelSandboxSession.from_state(state, sandbox=fake, token=token)
# ---------------------------------------------------------------------------
# Session tests
# ---------------------------------------------------------------------------
class TestBlaxelSandboxSession:
@pytest.mark.asyncio
async def test_exec_success(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process.next_result = _FakeExecResult(exit_code=0, output="hello world")
result = await session._exec_internal("echo", "hello")
assert result.exit_code == 0
assert result.stdout == b"hello world"
assert len(fake_sandbox.process.exec_calls) == 1
@pytest.mark.asyncio
async def test_exec_success_preserves_split_stderr(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process.next_result = _FakeExecResult(
exit_code=0,
output="hello world",
stderr="warning",
)
result = await session._exec_internal("echo", "hello")
assert result.exit_code == 0
assert result.stdout == b"hello world"
assert result.stderr == b"warning"
@pytest.mark.asyncio
async def test_exec_nonzero(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process.next_result = _FakeExecResult(
exit_code=1, output="", stderr="error msg"
)
result = await session._exec_internal("false")
assert result.exit_code == 1
assert result.stderr == b"error msg"
@pytest.mark.asyncio
async def test_exec_transport_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
async def _raise(*args: object, **kw: object) -> None:
raise ConnectionError("transport error")
fake_sandbox.process.exec = _raise # type: ignore[assignment]
with pytest.raises(ExecTransportError) as exc_info:
await session._exec_internal("echo", "hello")
assert str(exc_info.value) == "Blaxel exec failed: ConnectionError: transport error"
assert exc_info.value.context["backend"] == "blaxel"
assert exc_info.value.context["provider_error"] == "ConnectionError: transport error"
@pytest.mark.asyncio
async def test_mkdir(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
await session.mkdir("subdir")
assert len(fake_sandbox.fs.mkdir_calls) == 1
assert "/workspace/subdir" in fake_sandbox.fs.mkdir_calls[0]
@pytest.mark.asyncio
async def test_read(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.files["/workspace/test.txt"] = b"file content"
result = await session.read("test.txt")
assert result.read() == b"file content"
@pytest.mark.asyncio
async def test_read_not_found(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
with pytest.raises(WorkspaceReadNotFoundError):
await session.read("nonexistent.txt")
@pytest.mark.asyncio
async def test_read_rejects_workspace_symlink_to_ungranted_path(
self,
fake_sandbox: _FakeSandboxInstance,
) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process.symlinks["/workspace/link"] = "/private"
with pytest.raises(InvalidManifestPathError) as exc_info:
await session.read("link/secret.txt")
assert fake_sandbox.fs.read_binary_calls == []
assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
assert exc_info.value.context == {
"rel": "link/secret.txt",
"reason": "escape_root",
"resolved_path": "workspace escape: /private/secret.txt",
}
@pytest.mark.asyncio
async def test_write(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
await session.write("output.txt", io.BytesIO(b"written data"))
assert fake_sandbox.fs.files["/workspace/output.txt"] == b"written data"
@pytest.mark.asyncio
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
self,
fake_sandbox: _FakeSandboxInstance,
) -> None:
state = _make_state(
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),)
)
session = _make_session(fake_sandbox, state=state)
fake_sandbox.process.symlinks["/workspace/link"] = "/tmp/protected"
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.write("link/out.txt", io.BytesIO(b"blocked"))
assert fake_sandbox.fs.write_binary_calls == []
assert str(exc_info.value) == "failed to write archive for path: /workspace/link/out.txt"
assert exc_info.value.context == {
"path": "/workspace/link/out.txt",
"reason": "read_only_extra_path_grant",
"grant_path": "/tmp/protected",
"resolved_path": "/tmp/protected/out.txt",
}
@pytest.mark.asyncio
async def test_mkdir_rejects_workspace_symlink_to_read_only_extra_path_grant(
self,
fake_sandbox: _FakeSandboxInstance,
) -> None:
state = _make_state(
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),)
)
session = _make_session(fake_sandbox, state=state)
fake_sandbox.process.symlinks["/workspace/link"] = "/tmp/protected"
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.mkdir("link/newdir")
assert fake_sandbox.fs.mkdir_calls == []
assert str(exc_info.value) == "failed to write archive for path: /workspace/link/newdir"
assert exc_info.value.context == {
"path": "/workspace/link/newdir",
"reason": "read_only_extra_path_grant",
"grant_path": "/tmp/protected",
"resolved_path": "/tmp/protected/newdir",
}
@pytest.mark.asyncio
async def test_running(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
assert await session.running() is True
@pytest.mark.asyncio
async def test_running_when_down(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
async def _raise(*args: object, **kw: object) -> None:
raise ConnectionError("offline")
fake_sandbox.fs.ls = _raise # type: ignore[assignment]
assert await session.running() is False
@pytest.mark.asyncio
async def test_shutdown_deletes(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
await session.shutdown()
assert fake_sandbox._deleted is True
@pytest.mark.asyncio
async def test_shutdown_pause_on_exit(self, fake_sandbox: _FakeSandboxInstance) -> None:
state = _make_state(pause_on_exit=True)
session = _make_session(fake_sandbox, state=state)
await session.shutdown()
assert fake_sandbox._deleted is False
@pytest.mark.asyncio
async def test_normalize_path_relative(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
result = session.normalize_path("subdir/file.txt")
assert result.as_posix() == "/workspace/subdir/file.txt"
@pytest.mark.asyncio
async def test_normalize_path_escape_blocked(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
with pytest.raises(InvalidManifestPathError):
session.normalize_path("../../etc/passwd")
@pytest.mark.asyncio
async def test_normalize_path_absolute_blocked(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
with pytest.raises(InvalidManifestPathError):
session.normalize_path("/etc/passwd")
@pytest.mark.asyncio
async def test_mkdir_root_is_noop(self, fake_sandbox: _FakeSandboxInstance) -> None:
state = _make_state(root="/")
session = _make_session(fake_sandbox, state=state)
await session.mkdir("/")
# No fs.mkdir call should have been made.
assert len(fake_sandbox.fs.mkdir_calls) == 0
@pytest.mark.asyncio
async def test_mkdir_failure(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.mkdir_error = ConnectionError("fs down")
with pytest.raises(WorkspaceArchiveWriteError):
await session.mkdir("faildir")
@pytest.mark.asyncio
async def test_read_returns_str(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.files["/workspace/text.txt"] = b"string content"
fake_sandbox.fs.return_str = True
result = await session.read("text.txt")
assert result.read() == b"string content"
@pytest.mark.asyncio
async def test_read_status_404_via_args_dict(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
# Simulate Blaxel ResponseError with status in args[0] dict.
err = Exception({"status": 404, "message": "not found"})
fake_sandbox.fs.read_error = err
with pytest.raises(WorkspaceReadNotFoundError):
await session.read("missing.txt")
@pytest.mark.asyncio
async def test_read_generic_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.read_error = RuntimeError("unexpected")
with pytest.raises(WorkspaceArchiveReadError):
await session.read("broken.txt")
@pytest.mark.asyncio
async def test_read_status_attr_on_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
# Error with .status attribute set (e.g. Blaxel ResponseError).
session = _make_session(fake_sandbox)
err = RuntimeError("file missing")
err.status = 404 # type: ignore[attr-defined]
fake_sandbox.fs.read_error = err
with pytest.raises(WorkspaceReadNotFoundError):
await session.read("gone.txt")
@pytest.mark.asyncio
async def test_read_not_found_via_error_string(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.read_error = RuntimeError("No such file or directory")
with pytest.raises(WorkspaceReadNotFoundError):
await session.read("missing.txt")
@pytest.mark.asyncio
async def test_write_str_payload(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
await session.write("text.txt", io.StringIO("hello text"))
assert fake_sandbox.fs.files["/workspace/text.txt"] == b"hello text"
@pytest.mark.asyncio
async def test_write_invalid_payload_type(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
class _BadIO(io.IOBase):
def read(self) -> int:
return 42
with pytest.raises(WorkspaceWriteTypeError):
await session.write("bad.txt", _BadIO())
@pytest.mark.asyncio
async def test_write_fs_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.fs.write_error = ConnectionError("fs write failed")
with pytest.raises(WorkspaceArchiveWriteError):
await session.write("fail.txt", io.BytesIO(b"data"))
@pytest.mark.asyncio
async def test_exec_timeout(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process.delay = 10.0
with pytest.raises(ExecTimeoutError):
await session._exec_internal("sleep", "100", timeout=0.01)
@pytest.mark.asyncio
async def test_exec_timeout_reports_default_timeout(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
state = _make_state()
state.timeouts = BlaxelTimeouts(exec_timeout_s=1)
session = _make_session(fake_sandbox, state=state)
fake_sandbox.process.delay = 10.0
with pytest.raises(ExecTimeoutError) as exc_info:
await session._exec_internal("sleep", "100")
assert exc_info.value.timeout_s == 1.0
@pytest.mark.asyncio
async def test_stop_calls_pty_terminate(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
terminated = []
original = session.pty_terminate_all
async def _track() -> None:
terminated.append(True)
await original()
session.pty_terminate_all = _track
await session.stop()
assert len(terminated) == 1
@pytest.mark.asyncio
async def test_shutdown_delete_raises(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
async def _raise() -> None:
raise RuntimeError("delete failed")
fake_sandbox.delete = _raise # type: ignore[method-assign]
# Should not raise; error is suppressed.
await session.shutdown()
@pytest.mark.asyncio
async def test_sandbox_name_property(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
assert session.sandbox_name == "test-sandbox"
@pytest.mark.asyncio
async def test_exposed_port_invalid_url(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.previews.next_preview = _FakePreview(url="")
with pytest.raises(ExposedPortUnavailableError):
await session._resolve_exposed_port(8080)
@pytest.mark.asyncio
async def test_exposed_port_bad_url_parse(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
# URL without a hostname.
fake_sandbox.previews.next_preview = _FakePreview(url="https://")
with pytest.raises(ExposedPortUnavailableError):
await session._resolve_exposed_port(8080)
@pytest.mark.asyncio
async def test_exposed_port_http_scheme(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.previews.next_preview = _FakePreview(url="http://preview.example.com/")
endpoint = await session._resolve_exposed_port(80)
assert endpoint.tls is False
assert endpoint.port == 80
@pytest.mark.asyncio
async def test_exposed_port(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
endpoint = await session._resolve_exposed_port(3000)
assert isinstance(endpoint, ExposedPortEndpoint)
assert endpoint.host == "preview.example.com"
assert endpoint.tls is True
@pytest.mark.asyncio
async def test_exposed_port_any_port_without_predeclaration(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Blaxel previews can be created for any port on demand."""
session = _make_session(fake_sandbox)
# Call the public resolve_exposed_port (which checks _assert_exposed_port_configured).
# No exposed_ports were declared, but it should still work.
endpoint = await session.resolve_exposed_port(9999)
assert isinstance(endpoint, ExposedPortEndpoint)
assert endpoint.host == "preview.example.com"
@pytest.mark.asyncio
async def test_exposed_port_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.previews.error = RuntimeError("backend down")
with pytest.raises(ExposedPortUnavailableError):
await session._resolve_exposed_port(3000)
@pytest.mark.asyncio
async def test_exposed_port_public_preview(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Public preview should not include a token query string."""
session = _make_session(fake_sandbox)
endpoint = await session._resolve_exposed_port(8080)
assert endpoint.query == ""
# Verify the preview was created with public=True.
assert fake_sandbox.previews.calls[-1]["spec"]["public"] is True
@pytest.mark.asyncio
async def test_exposed_port_private_preview(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Private preview should create a token and set the query string."""
state = _make_state()
object.__setattr__(state, "exposed_port_public", False)
session = _make_session(fake_sandbox, state=state)
preview = _FakePreview(url="https://preview.example.com:443/")
preview.tokens.next_token = _FakePreviewToken(value="my-secret-token")
fake_sandbox.previews.next_preview = preview
endpoint = await session._resolve_exposed_port(8080)
# Verify the preview was created with public=False.
assert fake_sandbox.previews.calls[-1]["spec"]["public"] is False
# Verify token was created and attached as query.
assert len(preview.tokens.create_calls) == 1
assert endpoint.query == "bl_preview_token=my-secret-token"
assert "bl_preview_token=my-secret-token" in endpoint.url_for("http")
@pytest.mark.asyncio
async def test_exposed_port_private_token_error(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Token creation failure should raise ExposedPortUnavailableError."""
state = _make_state()
object.__setattr__(state, "exposed_port_public", False)
session = _make_session(fake_sandbox, state=state)
preview = _FakePreview(url="https://preview.example.com:443/")
preview.tokens.error = RuntimeError("token service down")
fake_sandbox.previews.next_preview = preview
with pytest.raises(ExposedPortUnavailableError):
await session._resolve_exposed_port(8080)
@pytest.mark.asyncio
async def test_supports_pty_with_url_and_token(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox, token="tok")
# Depends on aiohttp availability in test env.
try:
import aiohttp # noqa: F401
assert session.supports_pty() is True
except ImportError:
assert session.supports_pty() is False
@pytest.mark.asyncio
async def test_supports_pty_without_token(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox, token=None)
assert session.supports_pty() is False
@pytest.mark.asyncio
async def test_supports_pty_without_url(self, fake_sandbox: _FakeSandboxInstance) -> None:
state = _make_state(sandbox_url=None)
session = _make_session(fake_sandbox, state=state, token="tok")
assert session.supports_pty() is False
# ---------------------------------------------------------------------------
# Client tests
# ---------------------------------------------------------------------------
class TestBlaxelSandboxClient:
@pytest.mark.asyncio
async def test_create(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(name="my-sandbox")
session = await client.create(options=options)
assert session is not None
@pytest.mark.asyncio
async def test_create_with_image(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(
name="img-sandbox",
image="blaxel/py-app:latest",
memory=4096,
region="us-pdx-1",
)
session = await client.create(options=options)
assert session is not None
@pytest.mark.asyncio
async def test_delete(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(name="del-sandbox")
session = await client.create(options=options)
result = await client.delete(session)
assert result is session
@pytest.mark.asyncio
async def test_resume_reconnects(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
# Pre-populate the instance so get() finds it.
existing = _FakeSandboxInstance(name="resume-sandbox")
_FakeSandboxInstance._instances["resume-sandbox"] = existing
client = mod.BlaxelSandboxClient(token="test-token")
state = _make_state(sandbox_name="resume-sandbox", pause_on_exit=True)
session = await client.resume(state)
assert session is not None
@pytest.mark.asyncio
async def test_resume_creates_new(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
state = _make_state(sandbox_name="new-sandbox", pause_on_exit=False)
session = await client.resume(state)
assert session is not None
@pytest.mark.asyncio
async def test_deserialize_session_state(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
payload: dict[str, object] = {
"session_id": str(uuid.uuid4()),
"manifest": {"root": "/workspace"},
"snapshot": {"type": "noop", "id": "test-snap"},
"sandbox_name": "test",
}
state = client.deserialize_session_state(payload)
assert isinstance(state, mod.BlaxelSandboxSessionState)
assert state.sandbox_name == "test"
@pytest.mark.asyncio
async def test_context_manager(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
async with mod.BlaxelSandboxClient(token="test-token") as client:
assert client is not None
# ---------------------------------------------------------------------------
# Helper tests
# ---------------------------------------------------------------------------
class TestHelpers:
def test_build_create_config_minimal(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_create_config
config = _build_create_config(name="test")
assert config["name"] == "test"
def test_build_create_config_full(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_create_config
config = _build_create_config(
name="full",
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
memory=4096,
region="us-west",
env_vars={"KEY": "VAL"},
labels={"env": "test"},
ttl="24h",
)
assert config["image"] == DEFAULT_PYTHON_SANDBOX_IMAGE
assert config["memory"] == 4096
assert config["region"] == "us-west"
assert config["labels"] == {"env": "test"}
assert config["ttl"] == "24h"
assert "ports" not in config
assert config["envs"] == [{"name": "KEY", "value": "VAL"}]
def test_get_sandbox_url(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _get_sandbox_url
fake = _FakeSandboxInstance(url="https://sandbox.bl.run")
assert _get_sandbox_url(fake) == "https://sandbox.bl.run"
def test_get_sandbox_url_missing(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _get_sandbox_url
class _Bare:
pass
assert _get_sandbox_url(_Bare()) is None
def test_build_ws_url(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_ws_url
url = _build_ws_url(
sandbox_url="https://test.bl.run",
token="tok123",
session_id="sess-1",
cwd="/workspace",
)
assert url.startswith("wss://test.bl.run/terminal/ws?")
assert "token=tok123" in url
assert "sessionId=sess-1" in url
assert "workingDir=/workspace" in url
def test_extract_preview_url(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url
assert _extract_preview_url(_FakePreview("https://p.bl.run")) == "https://p.bl.run"
def test_extract_preview_url_nested(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url
class _Nested:
url = None
class status:
url = "https://nested.bl.run"
assert _extract_preview_url(_Nested()) == "https://nested.bl.run"
def test_extract_preview_url_direct_endpoint(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url
class _Direct:
url = None
spec = None
status = None
endpoint = "https://direct.bl.run"
assert _extract_preview_url(_Direct()) == "https://direct.bl.run"
def test_extract_preview_url_inner_preview(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url
class _Inner:
url = "https://inner.bl.run"
class _Outer:
url = None
spec = None
status = None
endpoint = None
preview = _Inner()
assert _extract_preview_url(_Outer()) == "https://inner.bl.run"
def test_extract_preview_url_returns_none(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _extract_preview_url
class _Empty:
pass
assert _extract_preview_url(_Empty()) is None
def test_get_sandbox_url_direct_url(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _get_sandbox_url
class _DirectUrl:
sandbox = None
url = "https://direct.bl.run"
assert _get_sandbox_url(_DirectUrl()) == "https://direct.bl.run"
def test_get_sandbox_url_empty_string(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _get_sandbox_url
class _EmptyUrl:
sandbox = None
url = ""
assert _get_sandbox_url(_EmptyUrl()) is None
def test_build_ws_url_http_scheme(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_ws_url
url = _build_ws_url(
sandbox_url="http://test.bl.run",
token="tok",
session_id="s1",
cwd="/w",
)
assert url.startswith("ws://test.bl.run/")
def test_build_create_config_with_ports(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_create_config
config = _build_create_config(
name="test",
ports=({"target": 3000, "protocol": "HTTP"},),
)
assert len(config["ports"]) == 1
assert config["ports"][0]["target"] == 3000
def test_build_create_config_region_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_create_config
monkeypatch.setenv("BL_REGION", "eu-ams-1")
config = _build_create_config(name="test")
assert config["region"] == "eu-ams-1"
def test_build_create_config_default_region(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _build_create_config
monkeypatch.delenv("BL_REGION", raising=False)
config = _build_create_config(name="test")
assert config["region"] == "us-pdx-1"
# ---------------------------------------------------------------------------
# Import guard tests
# ---------------------------------------------------------------------------
class TestImportGuards:
def test_import_blaxel_sdk_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
def _fail() -> None:
raise ImportError("no blaxel")
monkeypatch.setattr(mod, "_import_blaxel_sdk", _fail)
with pytest.raises(ImportError, match="no blaxel"):
mod._import_blaxel_sdk()
def test_import_aiohttp_missing(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_aiohttp
with patch.dict("sys.modules", {"aiohttp": None}):
with pytest.raises(ImportError, match="aiohttp"):
_import_aiohttp()
def test_has_aiohttp_false(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _has_aiohttp
with patch.dict("sys.modules", {"aiohttp": None}):
assert _has_aiohttp() is False
def test_has_aiohttp_true(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _has_aiohttp
# aiohttp should be available in the test environment.
try:
import aiohttp # noqa: F401
assert _has_aiohttp() is True
except ImportError:
pytest.skip("aiohttp not available")
# ---------------------------------------------------------------------------
# Tar validation tests
# ---------------------------------------------------------------------------
def _make_tar(members: dict[str, bytes | None] | None = None) -> bytes:
"""Build a tar archive in memory. Pass None as value for directories."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
for name, content in (members or {}).items():
if content is None:
info = tarfile.TarInfo(name=name)
info.type = tarfile.DIRTYPE
tar.addfile(info)
else:
info = tarfile.TarInfo(name=name)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
return buf.getvalue()
def _make_tar_with_symlink_and_file(*, symlink_name: str, target: str, file_name: str) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
link = tarfile.TarInfo(name=symlink_name)
link.type = tarfile.SYMTYPE
link.linkname = target
tar.addfile(link)
contents = b"nested"
file_info = tarfile.TarInfo(name=file_name)
file_info.size = len(contents)
tar.addfile(file_info, io.BytesIO(contents))
return buf.getvalue()
class TestValidateTarBytes:
def _validate(self, raw: bytes) -> None:
validate_tar_bytes(raw)
def test_valid_tar(self) -> None:
raw = _make_tar({"hello.txt": b"content", "subdir/": None})
self._validate(raw)
def test_absolute_path_rejected(self) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name="/etc/passwd")
info.size = 4
tar.addfile(info, io.BytesIO(b"root"))
with pytest.raises(ValueError, match="absolute path"):
self._validate(buf.getvalue())
def test_parent_traversal_rejected(self) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name="../escape.txt")
info.size = 4
tar.addfile(info, io.BytesIO(b"data"))
with pytest.raises(ValueError, match="parent traversal"):
self._validate(buf.getvalue())
def test_tar_member_under_archive_symlink_rejected(self) -> None:
raw = _make_tar_with_symlink_and_file(
symlink_name="link.txt",
target="/etc/passwd",
file_name="link.txt/nested.txt",
)
with pytest.raises(ValueError, match="descends through symlink"):
self._validate(raw)
def test_corrupt_tar_rejected(self) -> None:
with pytest.raises(ValueError, match="invalid tar"):
self._validate(b"not a tar file at all")
def test_dot_entries_skipped(self) -> None:
raw = _make_tar({"./": None, "file.txt": b"ok"})
self._validate(raw)
# ---------------------------------------------------------------------------
# Workspace persistence tests
# ---------------------------------------------------------------------------
class TestWorkspacePersistence:
@pytest.mark.asyncio
async def test_persist_workspace(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
# Queue up results: mkdir for start, tar command success.
tar_data = _make_tar({"file.txt": b"hello"})
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=0, output=""), # tar command
_FakeExecResult(exit_code=0, output=""), # rm cleanup
]
# Pre-populate the tar file so read_binary finds it.
tar_path = f"/tmp/bl-persist-{session.state.session_id.hex}.tar"
fake_sandbox.fs.files[tar_path] = tar_data
result = await session.persist_workspace()
assert result.read() == tar_data
@pytest.mark.asyncio
async def test_persist_workspace_tar_fails(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=1, output="tar: error"), # tar command fails
_FakeExecResult(exit_code=0, output=""), # rm cleanup
]
with pytest.raises(WorkspaceArchiveReadError) as exc_info:
await session.persist_workspace()
assert exc_info.value.context["reason"] == "tar_failed"
@pytest.mark.asyncio
async def test_persist_workspace_read_fails(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=0, output=""), # tar succeeds
_FakeExecResult(exit_code=0, output=""), # rm cleanup
]
# No tar file in fs, so read_binary will raise FileNotFoundError.
with pytest.raises(WorkspaceArchiveReadError):
await session.persist_workspace()
@pytest.mark.asyncio
async def test_persist_workspace_read_returns_str(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"a.txt": b"data"})
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=0, output=""),
_FakeExecResult(exit_code=0, output=""),
]
tar_path = f"/tmp/bl-persist-{session.state.session_id.hex}.tar"
fake_sandbox.fs.files[tar_path] = tar_data
fake_sandbox.fs.return_str = True
# This will encode the string back to bytes.
result = await session.persist_workspace()
assert len(result.read()) > 0
# ---------------------------------------------------------------------------
# Workspace hydration tests
# ---------------------------------------------------------------------------
class TestWorkspaceHydration:
@pytest.mark.asyncio
async def test_hydrate_workspace(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=0, output=""), # tar extract
_FakeExecResult(exit_code=0, output=""), # rm cleanup
]
await session.hydrate_workspace(io.BytesIO(tar_data))
@pytest.mark.asyncio
async def test_hydrate_invalid_tar(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.hydrate_workspace(io.BytesIO(b"not a tar"))
assert exc_info.value.context["reason"] == "unsafe_or_invalid_tar"
@pytest.mark.asyncio
async def test_hydrate_tar_with_symlink(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
raw = _make_tar_with_symlink_and_file(
symlink_name="link.txt",
target="/etc/shadow",
file_name="link.txt/nested.txt",
)
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.hydrate_workspace(io.BytesIO(raw))
assert "unsafe_or_invalid_tar" in str(exc_info.value.context)
@pytest.mark.asyncio
async def test_hydrate_extract_fails(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=1, output="tar: extract error"), # extract fails
_FakeExecResult(exit_code=0, output=""), # rm cleanup
]
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.hydrate_workspace(io.BytesIO(tar_data))
assert exc_info.value.context["reason"] == "tar_extract_failed"
@pytest.mark.asyncio
async def test_hydrate_str_payload_encoded(self, fake_sandbox: _FakeSandboxInstance) -> None:
# A str payload gets encoded to bytes, then fails tar validation.
session = _make_session(fake_sandbox)
class _StrIO(io.IOBase):
def read(self) -> str:
return "not a valid tar"
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
await session.hydrate_workspace(_StrIO())
assert exc_info.value.context["reason"] == "unsafe_or_invalid_tar"
@pytest.mark.asyncio
async def test_hydrate_invalid_payload_type(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
class _IntIO(io.IOBase):
def read(self) -> int:
return 42
with pytest.raises(WorkspaceWriteTypeError):
await session.hydrate_workspace(_IntIO())
@pytest.mark.asyncio
async def test_hydrate_write_binary_fails(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
fake_sandbox.fs.write_error = ConnectionError("upload failed")
with pytest.raises(WorkspaceArchiveWriteError):
await session.hydrate_workspace(io.BytesIO(tar_data))
# ---------------------------------------------------------------------------
# Additional client tests
# ---------------------------------------------------------------------------
class TestBlaxelSandboxClientExtra:
@pytest.mark.asyncio
async def test_delete_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(name="test")
session = await client.create(options=options)
# Replace the inner session with a non-Blaxel type.
session._inner = "not a BlaxelSandboxSession" # type: ignore[assignment]
with pytest.raises(TypeError, match="BlaxelSandboxClient.delete"):
await client.delete(session)
@pytest.mark.asyncio
async def test_resume_wrong_state_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
from tests.utils.factories import TestSessionState
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
# Pass a non-Blaxel SandboxSessionState subclass.
state = TestSessionState(
session_id=uuid.uuid4(),
manifest=Manifest(root="/workspace"),
snapshot=NoopSnapshot(id="test"),
)
with pytest.raises(TypeError, match="BlaxelSandboxClient.resume"):
await client.resume(state)
@pytest.mark.asyncio
async def test_resume_pause_on_exit_get_fails_falls_back(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
# No instances exist, so get() will fail and fall back to create.
client = mod.BlaxelSandboxClient(token="test-token")
state = _make_state(sandbox_name="missing-sandbox", pause_on_exit=True)
session = await client.resume(state)
assert session is not None
@pytest.mark.asyncio
async def test_create_with_timeouts_dict(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(
name="timeout-test",
timeouts={"exec_timeout_s": 60, "cleanup_s": 10},
)
session = await client.create(options=options)
assert session is not None
@pytest.mark.asyncio
async def test_create_without_manifest(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(name="no-manifest")
session = await client.create(manifest=None, options=options)
assert session is not None
@pytest.mark.asyncio
async def test_create_with_all_options(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(
name="full-opts",
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
memory=8192,
region="eu-ams-1",
ports=({"target": 3000, "protocol": "HTTP"},),
env_vars={"FOO": "bar"},
labels={"team": "test"},
ttl="1h",
pause_on_exit=True,
timeouts=mod.BlaxelTimeouts(exec_timeout_s=120),
)
session = await client.create(options=options)
assert session is not None
@pytest.mark.asyncio
async def test_client_token_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
monkeypatch.setenv("BL_API_KEY", "env-token")
client = mod.BlaxelSandboxClient()
assert client._token == "env-token"
@pytest.mark.asyncio
async def test_close_is_noop(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
await client.close() # Should not raise.
# ---------------------------------------------------------------------------
# Timeouts model tests
# ---------------------------------------------------------------------------
class TestBlaxelTimeouts:
def test_defaults(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
t = BlaxelTimeouts()
assert t.exec_timeout_s == 300.0
assert t.cleanup_s == 30.0
assert t.file_upload_s == 1800.0
assert t.file_download_s == 1800.0
assert t.workspace_tar_s == 300.0
assert t.fast_op_s == 30.0
def test_custom_values(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
t = BlaxelTimeouts(exec_timeout_s=60, cleanup_s=10, fast_op_s=5)
assert t.exec_timeout_s == 60
assert t.cleanup_s == 10
assert t.fast_op_s == 5
def test_frozen(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
t = BlaxelTimeouts()
with pytest.raises(ValidationError):
t.exec_timeout_s = 999
def test_validation_ge_1(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
with pytest.raises(ValidationError):
BlaxelTimeouts(exec_timeout_s=0)
# ---------------------------------------------------------------------------
# Session state tests
# ---------------------------------------------------------------------------
class TestBlaxelSandboxSessionState:
def test_defaults(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelSandboxSessionState
state = BlaxelSandboxSessionState(
session_id=uuid.uuid4(),
manifest=Manifest(root="/workspace"),
snapshot=NoopSnapshot(id="test"),
sandbox_name="test",
)
assert state.image is None
assert state.memory is None
assert state.region is None
assert state.base_env_vars == {}
assert state.labels == {}
assert state.ttl is None
assert state.pause_on_exit is False
assert state.sandbox_url is None
def test_serialization_roundtrip(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import (
BlaxelSandboxSessionState,
BlaxelTimeouts,
)
state = BlaxelSandboxSessionState(
session_id=uuid.uuid4(),
manifest=Manifest(root="/workspace"),
snapshot=NoopSnapshot(id="test"),
sandbox_name="test-rt",
image=DEFAULT_PYTHON_SANDBOX_IMAGE,
memory=4096,
region="us-pdx-1",
base_env_vars={"K": "V"},
labels={"env": "test"},
ttl="24h",
pause_on_exit=True,
timeouts=BlaxelTimeouts(exec_timeout_s=60),
sandbox_url="https://test.bl.run",
)
payload = state.model_dump()
restored = BlaxelSandboxSessionState.model_validate(payload)
assert restored.sandbox_name == "test-rt"
assert restored.image == DEFAULT_PYTHON_SANDBOX_IMAGE
assert restored.memory == 4096
assert restored.timeouts.exec_timeout_s == 60
# ---------------------------------------------------------------------------
# Client options tests
# ---------------------------------------------------------------------------
class TestBlaxelSandboxClientOptions:
def test_defaults(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelSandboxClientOptions
opts = BlaxelSandboxClientOptions()
assert opts.image is None
assert opts.memory is None
assert opts.region is None
assert opts.ports is None
assert opts.env_vars is None
assert opts.labels is None
assert opts.ttl is None
assert opts.name is None
assert opts.pause_on_exit is False
assert opts.timeouts is None
assert opts.exposed_port_public is True
def test_frozen(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import BlaxelSandboxClientOptions
opts = BlaxelSandboxClientOptions(name="test")
with pytest.raises(FrozenInstanceError):
opts.name = "changed" # type: ignore[misc]
# ---------------------------------------------------------------------------
# Tar exclude args tests
# ---------------------------------------------------------------------------
class TestTarExcludeArgs:
@pytest.mark.asyncio
async def test_exclude_args_empty(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
args = session._tar_exclude_args()
# With default manifest (no skip paths), should be empty.
assert isinstance(args, list)
@pytest.mark.asyncio
async def test_resolved_envs(self, fake_sandbox: _FakeSandboxInstance) -> None:
state = _make_state()
state.base_env_vars = {"BASE_KEY": "base_val"}
session = _make_session(fake_sandbox, state=state)
envs = await session._resolved_envs()
assert envs["BASE_KEY"] == "base_val"
# ---------------------------------------------------------------------------
# Start lifecycle test
# ---------------------------------------------------------------------------
class TestStartLifecycle:
@pytest.mark.asyncio
async def test_start_mkdir_failure_suppressed(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
async def _raise(*args: object, **kw: object) -> None:
raise ConnectionError("mkdir failed")
fake_sandbox.process.exec = _raise # type: ignore[assignment]
# start() should suppress the mkdir error and call super().start().
# super().start() will try to materialize the manifest, which may
# also call process.exec. We just verify it does not raise from the
# initial mkdir.
try:
await session.start()
except Exception:
# May fail in super().start() but not from the mkdir.
pass
# ---------------------------------------------------------------------------
# PTY fake helpers
# ---------------------------------------------------------------------------
class _FakeWSMessage:
def __init__(self, msg_type: Any, data: str | bytes) -> None:
self.type = msg_type
self.data = data
class _FakeWS:
"""Fake WebSocket that yields predefined messages then closes."""
def __init__(self, messages: list[_FakeWSMessage] | None = None) -> None:
self._messages = messages or []
self._sent: list[str] = []
self._closed = False
async def send_str(self, data: str) -> None:
self._sent.append(data)
async def close(self) -> None:
self._closed = True
def __aiter__(self) -> _FakeWS:
self._iter_index = 0
return self
async def __anext__(self) -> _FakeWSMessage:
if self._iter_index >= len(self._messages):
await asyncio.sleep(3600)
raise StopAsyncIteration
msg = self._messages[self._iter_index]
self._iter_index += 1
return msg
class _FakeHTTPSession:
def __init__(self, ws: _FakeWS | None = None) -> None:
self._ws = ws or _FakeWS()
self._closed = False
async def ws_connect(self, url: str) -> _FakeWS:
return self._ws
async def close(self) -> None:
self._closed = True
class _FakeAiohttp:
"""Minimal aiohttp mock module."""
class WSMsgType:
TEXT = 1
BINARY = 2
ERROR = 256
CLOSE = 257
CLOSING = 258
def __init__(self, ws: _FakeWS | None = None) -> None:
self._ws = ws
def ClientSession(self) -> _FakeHTTPSession:
return _FakeHTTPSession(self._ws)
# ---------------------------------------------------------------------------
# PTY tests
# ---------------------------------------------------------------------------
class TestPtyExec:
@pytest.mark.asyncio
async def test_pty_exec_start_success(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
output_msg = json.dumps({"type": "output", "data": "hello from pty"})
ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)])
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "hello", yield_time_s=0.5)
assert update.output is not None
assert b"hello from pty" in update.output
# process_id may be None if the reader finishes before finalize (entry.done=True).
@pytest.mark.asyncio
async def test_pty_exec_start_timeout(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class _SlowAiohttp:
WSMsgType = _FakeAiohttp.WSMsgType
def ClientSession(self) -> Any:
class _SlowSession:
async def ws_connect(self, url: str) -> None:
await asyncio.sleep(100)
async def close(self) -> None:
pass
return _SlowSession()
with patch.object(mod, "_import_aiohttp", return_value=_SlowAiohttp()):
with pytest.raises(ExecTimeoutError):
await session.pty_exec_start("echo", "hello", timeout=0.01)
@pytest.mark.asyncio
async def test_pty_exec_start_timeout_reports_default_timeout(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
from agents.extensions.sandbox.blaxel.sandbox import BlaxelTimeouts
state = _make_state()
state.timeouts = BlaxelTimeouts(exec_timeout_s=1)
session = _make_session(fake_sandbox, state=state)
class _SlowAiohttp:
WSMsgType = _FakeAiohttp.WSMsgType
def ClientSession(self) -> Any:
class _SlowSession:
async def ws_connect(self, url: str) -> None:
await asyncio.sleep(100)
async def close(self) -> None:
pass
return _SlowSession()
with patch.object(mod, "_import_aiohttp", return_value=_SlowAiohttp()):
with pytest.raises(ExecTimeoutError) as exc_info:
await session.pty_exec_start("echo", "hello")
assert exc_info.value.timeout_s == 1.0
@pytest.mark.asyncio
async def test_pty_exec_start_connection_error(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class _ErrorAiohttp:
WSMsgType = _FakeAiohttp.WSMsgType
def ClientSession(self) -> Any:
class _ErrorSession:
async def ws_connect(self, url: str) -> None:
raise ConnectionError("ws connect failed")
async def close(self) -> None:
pass
return _ErrorSession()
with patch.object(mod, "_import_aiohttp", return_value=_ErrorAiohttp()):
with pytest.raises(ExecTransportError):
await session.pty_exec_start("echo", "hello")
@pytest.mark.asyncio
async def test_pty_write_stdin(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
ws = _FakeWS()
entry = _BlaxelPtySessionEntry(
ws_session_id="write-test",
ws=ws,
http_session=_FakeHTTPSession(ws),
)
session._pty_sessions[1] = entry
session._reserved_pty_process_ids.add(1)
with patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp()):
update = await session.pty_write_stdin(session_id=1, chars="input\n", yield_time_s=0.2)
assert update.output is not None
assert len(ws._sent) == 1
@pytest.mark.asyncio
async def test_pty_write_stdin_empty_chars(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
ws = _FakeWS()
entry = _BlaxelPtySessionEntry(
ws_session_id="empty-write",
ws=ws,
http_session=_FakeHTTPSession(ws),
)
session._pty_sessions[1] = entry
session._reserved_pty_process_ids.add(1)
with patch.object(mod, "_import_aiohttp", return_value=_FakeAiohttp()):
update = await session.pty_write_stdin(session_id=1, chars="", yield_time_s=0.2)
assert update.output is not None
# Empty chars should not send anything.
assert len(ws._sent) == 0
@pytest.mark.asyncio
async def test_pty_terminate_all(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
ws = _FakeWS()
entry = _BlaxelPtySessionEntry(
ws_session_id="term-all",
ws=ws,
http_session=_FakeHTTPSession(ws),
)
session._pty_sessions[1] = entry
session._reserved_pty_process_ids.add(1)
await session.pty_terminate_all()
assert len(session._pty_sessions) == 0
assert len(session._reserved_pty_process_ids) == 0
assert ws._closed
@pytest.mark.asyncio
async def test_pty_ws_reader_error_message(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
error_msg = json.dumps({"type": "error", "data": "something failed"})
ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, error_msg)])
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("bad_cmd", yield_time_s=0.5)
assert update.output is not None
assert b"something failed" in update.output
@pytest.mark.asyncio
async def test_pty_ws_reader_binary_message(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
output_msg = json.dumps({"type": "output", "data": "binary-data"}).encode()
ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.BINARY, output_msg)])
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.5)
assert b"binary-data" in update.output
@pytest.mark.asyncio
async def test_pty_ws_reader_close_message(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
ws = _FakeWS(
messages=[
_FakeWSMessage(
_FakeAiohttp.WSMsgType.TEXT, json.dumps({"type": "output", "data": "hi"})
),
_FakeWSMessage(_FakeAiohttp.WSMsgType.CLOSE, ""),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.5)
assert b"hi" in update.output
@pytest.mark.asyncio
async def test_pty_ws_reader_invalid_json(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
ws = _FakeWS(
messages=[
_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, "not json"),
_FakeWSMessage(
_FakeAiohttp.WSMsgType.TEXT,
json.dumps({"type": "output", "data": "valid"}),
),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.5)
# Invalid JSON should be silently ignored; valid output should appear.
assert b"valid" in update.output
@pytest.mark.asyncio
async def test_pty_ws_reader_error_type_message(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
ws = _FakeWS(
messages=[
_FakeWSMessage(_FakeAiohttp.WSMsgType.ERROR, "ws error"),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.3)
# Error WS message should break the reader loop.
assert update.output is not None
@pytest.mark.asyncio
async def test_pty_finalize_done_session(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="test-done",
ws=None,
http_session=None,
done=True,
exit_code=0,
)
# Manually register the entry.
session._pty_sessions[1] = entry
session._reserved_pty_process_ids.add(1)
result = await session._finalize_pty_update(
process_id=1,
entry=entry,
output=b"done output",
original_token_count=None,
)
assert result.process_id is None
assert result.exit_code == 0
assert 1 not in session._pty_sessions
@pytest.mark.asyncio
async def test_pty_prune_sessions(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX
session = _make_session(fake_sandbox)
# Fill to max capacity with done entries.
for i in range(PTY_PROCESSES_MAX):
entry = _BlaxelPtySessionEntry(
ws_session_id=f"test-{i}",
ws=None,
http_session=None,
done=True,
exit_code=0,
)
entry.last_used = time.monotonic() - (PTY_PROCESSES_MAX - i)
session._pty_sessions[i] = entry
session._reserved_pty_process_ids.add(i)
pruned = session._prune_pty_sessions_if_needed()
assert pruned is not None
@pytest.mark.asyncio
async def test_pty_prune_below_max(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
# Below max, no pruning.
pruned = session._prune_pty_sessions_if_needed()
assert pruned is None
@pytest.mark.asyncio
async def test_terminate_pty_entry_with_reader_task(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
ws = _FakeWS()
http = _FakeHTTPSession(ws)
async def _reader() -> None:
await asyncio.sleep(100)
task = asyncio.create_task(_reader())
entry = _BlaxelPtySessionEntry(
ws_session_id="term-test",
ws=ws,
http_session=http,
reader_task=task,
)
await session._terminate_pty_entry(entry)
assert task.cancelled() or task.done()
assert ws._closed
assert http._closed
@pytest.mark.asyncio
async def test_terminate_pty_entry_all_none(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="null-test",
ws=None,
http_session=None,
reader_task=None,
)
# Should not raise.
await session._terminate_pty_entry(entry)
@pytest.mark.asyncio
async def test_pty_exec_default_yield_time(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
ws = _FakeWS(
messages=[
_FakeWSMessage(
_FakeAiohttp.WSMsgType.TEXT,
json.dumps({"type": "output", "data": "quick"}),
),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
# Pass yield_time_s=None to test default (10s), but with a short timeout.
# We use a small timeout to not wait 10 seconds.
update = await session.pty_exec_start("echo", "test", yield_time_s=0.1)
assert b"quick" in update.output
@pytest.mark.asyncio
async def test_pty_ws_reader_capital_type_keys(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
# Test the alternative capitalized key paths (Type/Data).
output_msg = json.dumps({"Type": "output", "Data": "cap-data"})
ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)])
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.5)
assert b"cap-data" in update.output
@pytest.mark.asyncio
async def test_pty_max_output_tokens(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
long_output = "x" * 10000
output_msg = json.dumps({"type": "output", "data": long_output})
ws = _FakeWS(messages=[_FakeWSMessage(_FakeAiohttp.WSMsgType.TEXT, output_msg)])
fake_aiohttp = _FakeAiohttp(ws=ws)
session = _make_session(fake_sandbox)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start(
"echo", "test", yield_time_s=0.5, max_output_tokens=10
)
# Output should be truncated.
assert len(update.output) < len(long_output.encode())
assert update.original_token_count is not None
# ---------------------------------------------------------------------------
# Persist workspace with mount handling
# ---------------------------------------------------------------------------
class TestPersistWithMounts:
@pytest.mark.asyncio
async def test_persist_unmount_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
mock_strategy = MagicMock()
mock_strategy.teardown_for_snapshot = AsyncMock(side_effect=RuntimeError("unmount fail"))
mock_mount = MagicMock()
mock_mount.mount_strategy = mock_strategy
mount_path = Path("/workspace/mount")
orig_manifest = session.state.manifest
mock_manifest = MagicMock(wraps=orig_manifest)
mock_manifest.root = orig_manifest.root
mock_manifest.environment = orig_manifest.environment
mock_manifest.ephemeral_mount_targets = MagicMock(return_value=[(mock_mount, mount_path)])
session.state.manifest = mock_manifest
with pytest.raises(WorkspaceArchiveReadError):
await session.persist_workspace()
@pytest.mark.asyncio
async def test_persist_remount_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"data"})
mock_strategy = MagicMock()
mock_strategy.teardown_for_snapshot = AsyncMock()
mock_strategy.restore_after_snapshot = AsyncMock(side_effect=RuntimeError("remount fail"))
mock_mount = MagicMock()
mock_mount.mount_strategy = mock_strategy
mount_path = Path("/workspace/mount")
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=0, output=""),
_FakeExecResult(exit_code=0, output=""),
]
tar_path = f"/tmp/bl-persist-{session.state.session_id.hex}.tar"
fake_sandbox.fs.files[tar_path] = tar_data
orig_manifest = session.state.manifest
mock_manifest = MagicMock(wraps=orig_manifest)
mock_manifest.root = orig_manifest.root
mock_manifest.environment = orig_manifest.environment
mock_manifest.ephemeral_mount_targets = MagicMock(return_value=[(mock_mount, mount_path)])
session.state.manifest = mock_manifest
with pytest.raises(WorkspaceArchiveReadError):
await session.persist_workspace()
@pytest.mark.asyncio
async def test_persist_snapshot_error_still_remounts(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
mock_strategy = MagicMock()
mock_strategy.teardown_for_snapshot = AsyncMock()
mock_strategy.restore_after_snapshot = AsyncMock()
mock_mount = MagicMock()
mock_mount.mount_strategy = mock_strategy
mount_path = Path("/workspace/mount")
fake_sandbox.process._results_queue = [
_FakeExecResult(exit_code=1, output="tar fail"),
_FakeExecResult(exit_code=0, output=""),
]
orig_manifest = session.state.manifest
mock_manifest = MagicMock(wraps=orig_manifest)
mock_manifest.root = orig_manifest.root
mock_manifest.environment = orig_manifest.environment
mock_manifest.ephemeral_mount_targets = MagicMock(return_value=[(mock_mount, mount_path)])
session.state.manifest = mock_manifest
with pytest.raises(WorkspaceArchiveReadError):
await session.persist_workspace()
mock_strategy.restore_after_snapshot.assert_called_once()
# ---------------------------------------------------------------------------
# _import_blaxel_sdk actual error path
# ---------------------------------------------------------------------------
class TestImportBlaxelSdkActual:
def test_actual_import_error(self) -> None:
# Force the actual function (not mocked) to fail by hiding the module.
from agents.extensions.sandbox.blaxel.sandbox import _import_blaxel_sdk
with patch.dict(
"sys.modules", {"blaxel": None, "blaxel.core": None, "blaxel.core.sandbox": None}
):
with pytest.raises(ImportError, match="BlaxelSandboxClient requires"):
_import_blaxel_sdk()
def test_actual_import_aiohttp_error(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_aiohttp
with patch.dict("sys.modules", {"aiohttp": None}):
with pytest.raises(ImportError, match="aiohttp"):
_import_aiohttp()
# ---------------------------------------------------------------------------
# shared tar validation: unsupported member type (for example, device or fifo)
# ---------------------------------------------------------------------------
class TestValidateTarBytesExtra:
def test_unsupported_member_type(self) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name="device")
info.type = tarfile.CHRTYPE # Character device, not dir or reg.
tar.addfile(info)
with pytest.raises(ValueError, match="unsupported member type"):
validate_tar_bytes(buf.getvalue())
def test_hardlink_rejected(self) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
info = tarfile.TarInfo(name="hardlink")
info.type = tarfile.LNKTYPE
info.linkname = "target"
tar.addfile(info)
with pytest.raises(ValueError, match="hardlink"):
validate_tar_bytes(buf.getvalue())
# ---------------------------------------------------------------------------
# Additional coverage: tar_exclude_args with skip paths
# ---------------------------------------------------------------------------
class TestTarExcludeArgsWithSkipPaths:
@pytest.mark.asyncio
async def test_exclude_args_with_skip_paths(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
session._runtime_persist_workspace_skip_relpaths = {
Path("node_modules"),
Path(".git"),
}
args = session._tar_exclude_args()
assert len(args) > 0
assert any("node_modules" in a for a in args)
assert any(".git" in a for a in args)
@pytest.mark.asyncio
async def test_exclude_args_skips_empty_and_dot(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
session._runtime_persist_workspace_skip_relpaths = {
Path("."),
Path("keep_me"),
}
args = session._tar_exclude_args()
# "." should be skipped, "keep_me" should be included.
assert any("keep_me" in a for a in args)
assert not any(a == "--exclude='.'" for a in args)
# ---------------------------------------------------------------------------
# Additional coverage: terminate entry with close errors
# ---------------------------------------------------------------------------
class TestTerminatePtyEntryErrors:
@pytest.mark.asyncio
async def test_terminate_ws_close_error(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
class _ErrorWS:
async def close(self) -> None:
raise ConnectionError("ws close failed")
class _ErrorHTTP:
async def close(self) -> None:
raise ConnectionError("http close failed")
entry = _BlaxelPtySessionEntry(
ws_session_id="err-close",
ws=_ErrorWS(),
http_session=_ErrorHTTP(),
reader_task=None,
)
# Should not raise.
await session._terminate_pty_entry(entry)
@pytest.mark.asyncio
async def test_terminate_reader_already_done(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
async def _done_task() -> None:
pass
task = asyncio.create_task(_done_task())
await task # Let it complete.
entry = _BlaxelPtySessionEntry(
ws_session_id="done-reader",
ws=_FakeWS(),
http_session=_FakeHTTPSession(),
reader_task=task,
)
await session._terminate_pty_entry(entry)
# ---------------------------------------------------------------------------
# Additional coverage: _collect_pty_output with entry already done at start
# ---------------------------------------------------------------------------
class TestCollectPtyOutputEdgeCases:
@pytest.mark.asyncio
async def test_collect_output_entry_done_immediately(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="done-imm",
ws=None,
http_session=None,
done=True,
)
entry.output_chunks.append(b"final output")
output, token_count = await session._collect_pty_output(
entry=entry, yield_time_ms=100, max_output_tokens=None
)
assert b"final output" in output
@pytest.mark.asyncio
async def test_collect_output_timeout_path(self, fake_sandbox: _FakeSandboxInstance) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="timeout-collect",
ws=None,
http_session=None,
)
# Very short yield time, no output, not done.
output, token_count = await session._collect_pty_output(
entry=entry, yield_time_ms=1, max_output_tokens=None
)
assert output == b""
# ---------------------------------------------------------------------------
# Additional coverage: actual import success paths
# ---------------------------------------------------------------------------
class TestActualImportSuccess:
def test_import_blaxel_sdk_success(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_blaxel_sdk
try:
result = _import_blaxel_sdk()
assert result is not None
except ImportError:
pytest.skip("blaxel not available")
def test_import_aiohttp_success(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_aiohttp
try:
result = _import_aiohttp()
assert result is not None
except ImportError:
pytest.skip("aiohttp not available")
# ---------------------------------------------------------------------------
# Additional coverage: hydrate cleanup and persist cleanup rm paths
# ---------------------------------------------------------------------------
class TestCleanupPaths:
@pytest.mark.asyncio
async def test_persist_cleanup_rm_failure_suppressed(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
call_count = 0
async def _counting_exec(config: dict[str, Any], **kw: object) -> _FakeExecResult:
nonlocal call_count
call_count += 1
if call_count == 1:
# tar command succeeds.
return _FakeExecResult(exit_code=0, output="")
# rm cleanup fails.
raise ConnectionError("rm failed")
fake_sandbox.process.exec = _counting_exec # type: ignore[method-assign]
tar_path = f"/tmp/bl-persist-{session.state.session_id.hex}.tar"
fake_sandbox.fs.files[tar_path] = tar_data
# Should succeed despite rm failure.
result = await session.persist_workspace()
assert result.read() == tar_data
@pytest.mark.asyncio
async def test_hydrate_cleanup_rm_failure_suppressed(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
call_count = 0
async def _counting_exec(config: dict[str, Any], **kw: object) -> _FakeExecResult:
nonlocal call_count
call_count += 1
command = str(config.get("command", ""))
helper_result = _fake_helper_exec_result(
command, symlinks=fake_sandbox.process.symlinks
)
if helper_result is not None:
return helper_result
if "tar" in command:
if "xf" in command:
# tar extract succeeds.
return _FakeExecResult(exit_code=0, output="")
if "rm" in command:
raise ConnectionError("rm failed")
return _FakeExecResult(exit_code=0, output="")
fake_sandbox.process.exec = _counting_exec # type: ignore[method-assign]
# Should succeed despite rm failure.
await session.hydrate_workspace(io.BytesIO(tar_data))
# ---------------------------------------------------------------------------
# Additional coverage: client branch partials
# ---------------------------------------------------------------------------
class TestClientBranchCoverage:
@pytest.mark.asyncio
async def test_create_no_name_generates_one(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions() # No name.
session = await client.create(options=options)
assert session is not None
@pytest.mark.asyncio
async def test_resume_reconnects_no_new_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
# Create an instance with no URL.
class _NoUrlSandbox(_FakeSandboxInstance):
def __init__(self, name: str = "no-url") -> None:
super().__init__(name=name)
self.sandbox = _FakeSandboxModel(name=name, url="")
_FakeSandboxInstance._instances["no-url-sandbox"] = _NoUrlSandbox("no-url-sandbox")
client = mod.BlaxelSandboxClient(token="test-token")
state = _make_state(sandbox_name="no-url-sandbox", pause_on_exit=True)
session = await client.resume(state)
assert session is not None
@pytest.mark.asyncio
async def test_delete_shutdown_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
monkeypatch.setattr(mod, "_import_blaxel_sdk", lambda: _FakeSandboxInstance)
client = mod.BlaxelSandboxClient(token="test-token")
options = mod.BlaxelSandboxClientOptions(name="del-err")
session = await client.create(options=options)
# Make shutdown raise.
async def _raise() -> None:
raise RuntimeError("shutdown error")
session._inner.shutdown = _raise # type: ignore[method-assign]
# delete should suppress the error.
result = await client.delete(session)
assert result is session
# ---------------------------------------------------------------------------
# Final coverage gap tests
# ---------------------------------------------------------------------------
class TestFinalCoverageGaps:
@pytest.mark.asyncio
async def test_exec_reraises_exec_timeout_error(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Cover line 401: except (ExecTimeoutError, ExecTransportError): raise."""
session = _make_session(fake_sandbox)
async def _timeout_exec(*args: object, **kw: object) -> None:
raise ExecTimeoutError(command=("test",), timeout_s=1.0, cause=None)
fake_sandbox.process.exec = _timeout_exec # type: ignore[assignment]
with pytest.raises(ExecTimeoutError):
await session._exec_internal("test")
@pytest.mark.asyncio
async def test_persist_rm_exception_suppressed(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Cover lines 493-494: except Exception: pass in persist cleanup."""
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
async def _exec_with_rm_fail(config: dict[str, Any], **kw: object) -> _FakeExecResult:
command = str(config.get("command", ""))
helper_result = _fake_helper_exec_result(
command, symlinks=fake_sandbox.process.symlinks
)
if helper_result is not None:
return helper_result
if "rm" in command:
raise OSError("rm failed")
return _FakeExecResult(exit_code=0, output="")
fake_sandbox.process.exec = _exec_with_rm_fail # type: ignore[method-assign]
tar_path = f"/tmp/bl-persist-{session.state.session_id.hex}.tar"
fake_sandbox.fs.files[tar_path] = tar_data
result = await session.persist_workspace()
assert result.read() == tar_data
@pytest.mark.asyncio
async def test_hydrate_rm_exception_suppressed(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Cover lines 560-561: except Exception: pass in hydrate cleanup."""
session = _make_session(fake_sandbox)
tar_data = _make_tar({"file.txt": b"hello"})
async def _exec_with_rm_fail(config: dict[str, Any], **kw: object) -> _FakeExecResult:
command = str(config.get("command", ""))
helper_result = _fake_helper_exec_result(
command, symlinks=fake_sandbox.process.symlinks
)
if helper_result is not None:
return helper_result
if "rm" in command:
raise OSError("rm failed")
return _FakeExecResult(exit_code=0, output="")
fake_sandbox.process.exec = _exec_with_rm_fail # type: ignore[method-assign]
await session.hydrate_workspace(io.BytesIO(tar_data))
@pytest.mark.asyncio
async def test_pty_exec_with_pruning(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Cover line 638: pruned entry termination in pty_exec_start."""
from agents.extensions.sandbox.blaxel import sandbox as mod
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX
session = _make_session(fake_sandbox)
# Fill sessions to capacity with done entries.
for i in range(PTY_PROCESSES_MAX):
entry = _BlaxelPtySessionEntry(
ws_session_id=f"fill-{i}",
ws=None,
http_session=None,
done=True,
exit_code=0,
)
entry.last_used = time.monotonic() - (PTY_PROCESSES_MAX - i)
session._pty_sessions[i + 100] = entry
session._reserved_pty_process_ids.add(i + 100)
ws = _FakeWS(
messages=[
_FakeWSMessage(
_FakeAiohttp.WSMsgType.TEXT,
json.dumps({"type": "output", "data": "pruned-test"}),
),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.3)
assert b"pruned-test" in update.output
@pytest.mark.asyncio
async def test_pty_warning_threshold(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Cover line 641: warning log for high PTY count."""
from agents.extensions.sandbox.blaxel import sandbox as mod
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
from agents.sandbox.session.pty_types import PTY_PROCESSES_WARNING
session = _make_session(fake_sandbox)
# Fill up to just below warning threshold.
for i in range(PTY_PROCESSES_WARNING - 1):
entry = _BlaxelPtySessionEntry(
ws_session_id=f"warn-{i}",
ws=None,
http_session=None,
)
session._pty_sessions[i + 200] = entry
session._reserved_pty_process_ids.add(i + 200)
ws = _FakeWS(
messages=[
_FakeWSMessage(
_FakeAiohttp.WSMsgType.TEXT,
json.dumps({"type": "output", "data": "warn-test"}),
),
]
)
fake_aiohttp = _FakeAiohttp(ws=ws)
with patch.object(mod, "_import_aiohttp", return_value=fake_aiohttp):
update = await session.pty_exec_start("echo", "test", yield_time_s=0.3)
assert update.output is not None
@pytest.mark.asyncio
async def test_pty_ws_reader_exception_in_iter(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Cover line 744: except Exception: pass in _pty_ws_reader."""
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
class _ErrorWS:
_sent: list[str] = []
_closed = False
async def send_str(self, data: str) -> None:
self._sent.append(data)
async def close(self) -> None:
self._closed = True
def __aiter__(self) -> _ErrorWS:
return self
async def __anext__(self) -> None:
raise RuntimeError("WS iteration error")
entry = _BlaxelPtySessionEntry(
ws_session_id="err-iter",
ws=_ErrorWS(),
http_session=_FakeHTTPSession(),
)
# Run the reader directly.
await session._pty_ws_reader(entry)
assert entry.done is True
@pytest.mark.asyncio
async def test_terminate_pty_outer_exception(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Cover lines 841-842: outer except Exception: pass in _terminate_pty_entry."""
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
class _BadReaderTask:
"""Fake task whose done() raises."""
def done(self) -> bool:
raise RuntimeError("task check failed")
def cancel(self) -> None:
pass
entry = _BlaxelPtySessionEntry(
ws_session_id="outer-err",
ws=None,
http_session=None,
reader_task=_BadReaderTask(), # type: ignore[arg-type]
)
# Should not raise.
await session._terminate_pty_entry(entry)
@pytest.mark.asyncio
async def test_prune_returns_none_when_no_pid(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Cover line 819: prune returns None when process_id_to_prune_from_meta returns None."""
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
from agents.sandbox.session.pty_types import PTY_PROCESSES_MAX
session = _make_session(fake_sandbox)
# Fill to max with entries, then patch process_id_to_prune_from_meta to return None.
for i in range(PTY_PROCESSES_MAX):
entry = _BlaxelPtySessionEntry(
ws_session_id=f"no-prune-{i}",
ws=None,
http_session=None,
)
session._pty_sessions[i + 300] = entry
session._reserved_pty_process_ids.add(i + 300)
with patch(
"agents.extensions.sandbox.blaxel.sandbox.process_id_to_prune_from_meta",
return_value=None,
):
result = session._prune_pty_sessions_if_needed()
assert result is None
@pytest.mark.asyncio
async def test_collect_output_deadline_break(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""Cover lines 765, 774: deadline and remaining_s break paths."""
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="deadline-test",
ws=None,
http_session=None,
)
entry.output_chunks.append(b"some data")
# yield_time_ms=1 means very short deadline, should hit deadline break.
output, _ = await session._collect_pty_output(
entry=entry, yield_time_ms=1, max_output_tokens=None
)
assert b"some data" in output
@pytest.mark.asyncio
async def test_collect_output_done_with_remaining_chunks(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""Cover line 769: collecting remaining chunks when entry is done."""
from agents.extensions.sandbox.blaxel.sandbox import _BlaxelPtySessionEntry
session = _make_session(fake_sandbox)
entry = _BlaxelPtySessionEntry(
ws_session_id="done-chunks",
ws=None,
http_session=None,
done=True,
)
# Add chunks after marking done, to test the inner drain loop.
entry.output_chunks.append(b"chunk1")
entry.output_chunks.append(b"chunk2")
output, _ = await session._collect_pty_output(
entry=entry, yield_time_ms=5000, max_output_tokens=None
)
assert b"chunk1" in output
assert b"chunk2" in output
# ---------------------------------------------------------------------------
# Mounts tests
# ---------------------------------------------------------------------------
class _FakeExecResultForMount:
def __init__(self, exit_code: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
class _FakeMountSession:
"""Minimal BaseSandboxSession stand-in for mount tests."""
__name__ = "BlaxelSandboxSession"
def __init__(self) -> None:
self.exec_calls: list[tuple[tuple[str, ...], dict[str, float]]] = []
self._next_results: list[_FakeExecResultForMount] = []
self._default_result = _FakeExecResultForMount()
async def exec(self, *cmd: str, timeout: float = 120) -> _FakeExecResultForMount:
self.exec_calls.append((cmd, {"timeout": timeout}))
if self._next_results:
return self._next_results.pop(0)
return self._default_result
class __class__:
__name__ = "BlaxelSandboxSession"
# Override type name for _assert_blaxel_session check.
_FakeMountSession.__name__ = "BlaxelSandboxSession"
def _bl_strategy() -> Any:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
return BlaxelCloudBucketMountStrategy()
class TestMountsModule:
def test_build_mount_config_s3(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.entries import S3Mount
mount = S3Mount(
bucket="my-bucket",
mount_strategy=_bl_strategy(),
access_key_id="AKID",
secret_access_key="SECRET",
region="us-east-1",
prefix="data/",
read_only=True,
)
config = _build_mount_config(mount, mount_path="/mnt/s3")
assert config.provider == "s3"
assert config.bucket == "my-bucket"
assert config.mount_path == "/mnt/s3"
assert config.access_key_id == "AKID"
assert config.region == "us-east-1"
assert config.prefix == "data/"
def test_build_mount_config_r2(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.entries import R2Mount
mount = R2Mount(
bucket="r2-bucket",
mount_strategy=_bl_strategy(),
account_id="acc123",
access_key_id="R2KEY",
secret_access_key="R2SECRET",
)
config = _build_mount_config(mount, mount_path="/mnt/r2")
assert config.provider == "r2"
assert "r2.cloudflarestorage.com" in (config.endpoint_url or "")
def test_build_mount_config_r2_custom_domain(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.entries import R2Mount
mount = R2Mount(
bucket="r2-bucket",
account_id="acc123",
mount_strategy=_bl_strategy(),
access_key_id="R2KEY",
secret_access_key="R2SECRET",
custom_domain="https://custom.example.com",
)
config = _build_mount_config(mount, mount_path="/mnt/r2")
assert config.endpoint_url == "https://custom.example.com"
def test_build_mount_config_gcs(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.entries import GCSMount
mount = GCSMount(
bucket="gcs-bucket",
mount_strategy=_bl_strategy(),
service_account_credentials='{"type":"service_account"}',
prefix="prefix/",
)
config = _build_mount_config(mount, mount_path="/mnt/gcs")
assert config.provider == "gcs"
assert config.service_account_key is not None
def test_build_mount_config_gcs_hmac(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.entries import GCSMount
mount = GCSMount(
bucket="gcs-bucket",
mount_strategy=_bl_strategy(),
access_id="GOOG1",
secret_access_key="SECRET",
endpoint_url="https://storage.googleapis.com",
prefix="prefix/",
)
config = _build_mount_config(mount, mount_path="/mnt/gcs")
assert config.provider == "s3"
assert config.access_key_id == "GOOG1"
assert config.secret_access_key == "SECRET"
assert config.endpoint_url == "https://storage.googleapis.com"
assert config.prefix == "prefix/"
def test_build_mount_config_unsupported(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _build_mount_config
from agents.sandbox.errors import MountConfigError
# Use a MagicMock with a type attribute to simulate an unsupported mount.
mount = MagicMock()
mount.type = "unsupported_mount"
with pytest.raises(MountConfigError, match="only support"):
_build_mount_config(mount, mount_path="/mnt/x")
def test_assert_blaxel_session_wrong_type(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _assert_blaxel_session
from agents.sandbox.errors import MountConfigError
class _WrongSession:
pass
with pytest.raises(MountConfigError, match="BlaxelSandboxSession"):
_assert_blaxel_session(_WrongSession()) # type: ignore[arg-type]
def test_validate_mount(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
mount = S3Mount(bucket="test-bucket", mount_strategy=_bl_strategy())
strategy.validate_mount(mount)
def test_build_docker_volume_driver_config_returns_none(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
mount = S3Mount(bucket="test", mount_strategy=_bl_strategy())
assert strategy.build_docker_volume_driver_config(mount) is None
@pytest.mark.asyncio
async def test_mount_s3_with_credentials(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3
session = _FakeMountSession()
# Simulate: which s3fs succeeds.
session._next_results = [
_FakeExecResultForMount(exit_code=0, stdout=b"/usr/bin/s3fs"), # which s3fs
_FakeExecResultForMount(exit_code=0), # write cred file
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # s3fs mount
_FakeExecResultForMount(exit_code=0), # rm cred file
]
config = BlaxelCloudBucketMountConfig(
provider="s3",
bucket="my-bucket",
mount_path="/mnt/s3",
access_key_id="AKID",
secret_access_key="SECRET",
region="us-east-1",
prefix="data/",
read_only=True,
)
await _mount_s3(session, config) # type: ignore[arg-type]
assert len(session.exec_calls) == 5
@pytest.mark.asyncio
async def test_mount_s3_public_bucket(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # s3fs mount (no cred cleanup)
]
config = BlaxelCloudBucketMountConfig(
provider="s3",
bucket="public-bucket",
mount_path="/mnt/pub",
read_only=True,
)
await _mount_s3(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_s3_with_endpoint(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # s3fs mount
]
config = BlaxelCloudBucketMountConfig(
provider="s3",
bucket="endpoint-bucket",
mount_path="/mnt/ep",
endpoint_url="https://custom-s3.example.com",
)
await _mount_s3(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_s3_r2_sigv4(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs
_FakeExecResultForMount(exit_code=0), # write cred
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # s3fs mount
_FakeExecResultForMount(exit_code=0), # rm cred
]
config = BlaxelCloudBucketMountConfig(
provider="r2",
bucket="r2-bucket",
mount_path="/mnt/r2",
access_key_id="KEY",
secret_access_key="SECRET",
endpoint_url="https://acc.r2.cloudflarestorage.com",
)
await _mount_s3(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_s3_fails(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_s3
from agents.sandbox.errors import MountConfigError
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=1, stderr=b"mount error"), # s3fs fails
]
config = BlaxelCloudBucketMountConfig(
provider="s3",
bucket="fail-bucket",
mount_path="/mnt/fail",
)
with pytest.raises(MountConfigError, match="s3fs mount failed"):
await _mount_s3(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_gcs_with_key(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which gcsfuse
_FakeExecResultForMount(exit_code=0), # write key
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # gcsfuse mount
_FakeExecResultForMount(exit_code=0), # rm key
]
config = BlaxelCloudBucketMountConfig(
provider="gcs",
bucket="gcs-bucket",
mount_path="/mnt/gcs",
service_account_key='{"type":"service_account"}',
read_only=True,
prefix="data/",
)
await _mount_gcs(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_gcs_anonymous(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which gcsfuse
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # gcsfuse mount
]
config = BlaxelCloudBucketMountConfig(
provider="gcs",
bucket="pub-gcs",
mount_path="/mnt/pub-gcs",
)
await _mount_gcs(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_gcs_fails(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountConfig, _mount_gcs
from agents.sandbox.errors import MountConfigError
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which gcsfuse
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=1, stderr=b"gcs error"), # fails
]
config = BlaxelCloudBucketMountConfig(
provider="gcs",
bucket="fail-gcs",
mount_path="/mnt/fail-gcs",
)
with pytest.raises(MountConfigError, match="gcsfuse mount failed"):
await _mount_gcs(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_bucket_dispatch_s3(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import (
BlaxelCloudBucketMountConfig,
_mount_bucket,
)
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # s3fs mount
]
config = BlaxelCloudBucketMountConfig(provider="s3", bucket="b", mount_path="/m")
await _mount_bucket(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_mount_bucket_dispatch_gcs(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import (
BlaxelCloudBucketMountConfig,
_mount_bucket,
)
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0),
_FakeExecResultForMount(exit_code=0),
_FakeExecResultForMount(exit_code=0),
]
config = BlaxelCloudBucketMountConfig(provider="gcs", bucket="b", mount_path="/m")
await _mount_bucket(session, config) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_unmount_bucket_fusermount(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _unmount_bucket
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # fusermount succeeds
]
await _unmount_bucket(session, "/mnt/test") # type: ignore[arg-type]
assert len(session.exec_calls) == 1
@pytest.mark.asyncio
async def test_unmount_bucket_umount_fallback(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _unmount_bucket
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=1), # fusermount fails
_FakeExecResultForMount(exit_code=0), # umount succeeds
]
await _unmount_bucket(session, "/mnt/test") # type: ignore[arg-type]
assert len(session.exec_calls) == 2
@pytest.mark.asyncio
async def test_unmount_bucket_lazy(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _unmount_bucket
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=1), # fusermount fails
_FakeExecResultForMount(exit_code=1), # umount fails
_FakeExecResultForMount(exit_code=0), # umount -l
]
await _unmount_bucket(session, "/mnt/test") # type: ignore[arg-type]
assert len(session.exec_calls) == 3
@pytest.mark.asyncio
async def test_install_tool_with_apk(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _install_tool
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0, stdout=b"apk"), # detect pkg mgr
_FakeExecResultForMount(exit_code=0), # apk add succeeds
]
await _install_tool(session, "s3fs") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_install_tool_with_apt(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _install_tool
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0, stdout=b"apt"), # detect pkg mgr
_FakeExecResultForMount(exit_code=0), # apt-get install succeeds
]
await _install_tool(session, "gcsfuse") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_install_tool_fails_after_retries(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _install_tool
from agents.sandbox.errors import MountConfigError
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0, stdout=b"apt"), # detect
_FakeExecResultForMount(exit_code=1), # attempt 1
_FakeExecResultForMount(exit_code=1), # attempt 2
_FakeExecResultForMount(exit_code=1), # attempt 3
]
with pytest.raises(MountConfigError, match="failed to install"):
await _install_tool(session, "s3fs") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_ensure_tool_already_installed(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _ensure_tool
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which s3fs succeeds
]
await _ensure_tool(session, "s3fs") # type: ignore[arg-type]
assert len(session.exec_calls) == 1
@pytest.mark.asyncio
async def test_ensure_tool_needs_install(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _ensure_tool
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=1), # which fails
_FakeExecResultForMount(exit_code=0, stdout=b"apt"), # detect
_FakeExecResultForMount(exit_code=0), # install
]
await _ensure_tool(session, "s3fs") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_activate(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # mount
]
mount = S3Mount(bucket="test", mount_strategy=_bl_strategy(), mount_path=Path("/mnt/s3"))
# activate needs a real mount path resolution, mock it.
mount._resolve_mount_path = lambda s, d: Path("/workspace/mnt/s3") # type: ignore[assignment]
result = await strategy.activate(
mount,
session, # type: ignore[arg-type]
Path("/workspace/mnt/s3"),
Path("/workspace"),
)
assert result == []
@pytest.mark.asyncio
async def test_deactivate(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
session = _FakeMountSession()
session._next_results = [_FakeExecResultForMount(exit_code=0)]
mount = S3Mount(bucket="test", mount_strategy=_bl_strategy(), mount_path=Path("/mnt/s3"))
mount._resolve_mount_path = lambda s, d: Path("/workspace/mnt/s3") # type: ignore[assignment]
await strategy.deactivate(
mount,
session, # type: ignore[arg-type]
Path("/workspace/mnt/s3"),
Path("/workspace"),
)
@pytest.mark.asyncio
async def test_teardown_for_snapshot(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
session = _FakeMountSession()
session._next_results = [_FakeExecResultForMount(exit_code=0)]
mount = S3Mount(bucket="test", mount_strategy=_bl_strategy())
await strategy.teardown_for_snapshot(
mount,
session, # type: ignore[arg-type]
Path("/workspace/mnt/s3"),
)
@pytest.mark.asyncio
async def test_restore_after_snapshot(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelCloudBucketMountStrategy
from agents.sandbox.entries import S3Mount
strategy = BlaxelCloudBucketMountStrategy()
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=0), # which
_FakeExecResultForMount(exit_code=0), # mkdir
_FakeExecResultForMount(exit_code=0), # mount
]
mount = S3Mount(bucket="test", mount_strategy=_bl_strategy())
await strategy.restore_after_snapshot(
mount,
session, # type: ignore[arg-type]
Path("/workspace/mnt/s3"),
)
# ---------------------------------------------------------------------------
# SDK exception mapping tests
# ---------------------------------------------------------------------------
class TestSdkExceptionMapping:
def test_import_sandbox_api_error(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_sandbox_api_error
cls = _import_sandbox_api_error()
if cls is None:
pytest.skip("blaxel not available")
assert issubclass(cls, BaseException)
def test_import_sandbox_api_error_missing_sdk(self) -> None:
from agents.extensions.sandbox.blaxel.sandbox import _import_sandbox_api_error
with patch.dict(
"sys.modules",
{"blaxel": None, "blaxel.core": None, "blaxel.core.sandbox": None},
):
assert _import_sandbox_api_error() is None
@pytest.mark.asyncio
async def test_exec_maps_sdk_api_error_408_to_timeout(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""SandboxAPIError with status_code=408 should map to ExecTimeoutError."""
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
# Create a fake SandboxAPIError with status_code.
class FakeApiError(Exception):
def __init__(self, msg: str, status_code: int) -> None:
super().__init__(msg)
self.status_code = status_code
async def _raise_timeout(*args: object, **kw: object) -> None:
raise FakeApiError("request timeout", status_code=408)
fake_sandbox.process.exec = _raise_timeout # type: ignore[assignment]
with patch.object(mod, "_import_sandbox_api_error", return_value=FakeApiError):
with pytest.raises(ExecTimeoutError):
await session._exec_internal("sleep", "100")
@pytest.mark.asyncio
async def test_exec_maps_sdk_api_error_504_to_timeout(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""SandboxAPIError with status_code=504 should map to ExecTimeoutError."""
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class FakeApiError(Exception):
def __init__(self, msg: str, status_code: int) -> None:
super().__init__(msg)
self.status_code = status_code
async def _raise_504(*args: object, **kw: object) -> None:
raise FakeApiError("gateway timeout", status_code=504)
fake_sandbox.process.exec = _raise_504 # type: ignore[assignment]
with patch.object(mod, "_import_sandbox_api_error", return_value=FakeApiError):
with pytest.raises(ExecTimeoutError):
await session._exec_internal("sleep", "100")
@pytest.mark.asyncio
async def test_exec_non_timeout_api_error_becomes_transport(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
"""SandboxAPIError with status_code=500 should map to ExecTransportError."""
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class FakeApiError(Exception):
def __init__(self, msg: str, status_code: int) -> None:
super().__init__(msg)
self.status_code = status_code
async def _raise_500(*args: object, **kw: object) -> None:
raise FakeApiError("internal error", status_code=500)
fake_sandbox.process.exec = _raise_500 # type: ignore[assignment]
with patch.object(mod, "_import_sandbox_api_error", return_value=FakeApiError):
with pytest.raises(ExecTransportError) as exc_info:
await session._exec_internal("echo", "hello")
assert str(exc_info.value) == "Blaxel exec failed: HTTP 500: internal error"
assert exc_info.value.context["backend"] == "blaxel"
assert exc_info.value.context["http_status"] == 500
assert exc_info.value.context["provider_error"] == "HTTP 500: internal error"
assert exc_info.value.retryable is True
@pytest.mark.asyncio
async def test_exec_uses_structured_blaxel_non_retryable_error_code(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class FakeApiError(Exception):
def __init__(self) -> None:
super().__init__("route not found")
self.status_code = 404
self.body = {
"error": {
"code": "ROUTE_NOT_FOUND",
"message": "Preview not found: sandbox",
"retryable": False,
"status": 404,
}
}
async def _raise_route_not_found(*args: object, **kw: object) -> None:
raise FakeApiError()
fake_sandbox.process.exec = _raise_route_not_found # type: ignore[assignment]
with patch.object(mod, "_import_sandbox_api_error", return_value=FakeApiError):
with pytest.raises(ExecTransportError) as exc_info:
await session._exec_internal("echo", "hello")
assert str(exc_info.value) == "Blaxel exec failed: HTTP 404: route not found"
assert exc_info.value.context["backend"] == "blaxel"
assert exc_info.value.context["http_status"] == 404
assert exc_info.value.context["provider_error"] == "HTTP 404: route not found"
assert exc_info.value.context["provider_error_code"] == "ROUTE_NOT_FOUND"
assert exc_info.value.retryable is False
@pytest.mark.asyncio
async def test_exec_uses_structured_blaxel_retryable_error_code(
self, fake_sandbox: _FakeSandboxInstance
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
session = _make_session(fake_sandbox)
class FakeApiError(Exception):
def __init__(self) -> None:
super().__init__("workload unavailable")
self.status_code = 404
self.body = {
"error": {
"code": "WORKLOAD_UNAVAILABLE",
"message": "No healthy replica is serving workload",
"retryable": True,
"status": 404,
}
}
async def _raise_workload_unavailable(*args: object, **kw: object) -> None:
raise FakeApiError()
fake_sandbox.process.exec = _raise_workload_unavailable # type: ignore[assignment]
with patch.object(mod, "_import_sandbox_api_error", return_value=FakeApiError):
with pytest.raises(ExecTransportError) as exc_info:
await session._exec_internal("echo", "hello")
assert str(exc_info.value) == "Blaxel exec failed: HTTP 404: workload unavailable"
assert exc_info.value.context["backend"] == "blaxel"
assert exc_info.value.context["http_status"] == 404
assert exc_info.value.context["provider_error"] == "HTTP 404: workload unavailable"
assert exc_info.value.context["provider_error_code"] == "WORKLOAD_UNAVAILABLE"
assert exc_info.value.retryable is True
@pytest.mark.parametrize(
("code", "expected_retryable"),
[
("ROUTE_NOT_FOUND", False),
("WORKLOAD_NOT_FOUND", False),
("WORKSPACE_NOT_FOUND", False),
("WORKLOAD_UNAVAILABLE", True),
("AUTHENTICATION_REQUIRED", False),
("AUTHENTICATION_FAILED", False),
("FORBIDDEN", False),
("BAD_REQUEST", False),
("USAGE_LIMIT_EXCEEDED", False),
("POLICY_VIOLATION", False),
],
)
def test_blaxel_retryability_error_code_table(
self,
code: str,
expected_retryable: bool,
) -> None:
from agents.extensions.sandbox.blaxel import sandbox as mod
class FakeApiError(Exception):
def __init__(self) -> None:
super().__init__(code)
self.body = {"error": {"code": code, "message": code}}
retryable, provider_error_code = mod._blaxel_provider_retryability(FakeApiError())
assert retryable is expected_retryable
assert provider_error_code == code
# ---------------------------------------------------------------------------
# Timeout coercion tests
# ---------------------------------------------------------------------------
class TestCoerceExecTimeout:
def test_none_returns_default(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
result = session._coerce_exec_timeout(None)
assert result == 300.0 # Default from BlaxelTimeouts.
def test_positive_value_passthrough(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
assert session._coerce_exec_timeout(42.5) == 42.5
def test_zero_returns_small_positive(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
assert session._coerce_exec_timeout(0) == 0.001
def test_negative_returns_small_positive(self, fake_sandbox: _FakeSandboxInstance) -> None:
session = _make_session(fake_sandbox)
assert session._coerce_exec_timeout(-5) == 0.001
# ---------------------------------------------------------------------------
# Drive mount tests
# ---------------------------------------------------------------------------
class TestDriveMounts:
@pytest.mark.asyncio
async def test_attach_drive_success(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountConfig, _attach_drive
sandbox = _FakeSandboxInstance()
config = BlaxelDriveMountConfig(
drive_name="test-drive", mount_path="/mnt/data", drive_path="/"
)
await _attach_drive(sandbox, config)
assert sandbox.drives.mount_calls == [("test-drive", "/mnt/data", "/")]
@pytest.mark.asyncio
async def test_attach_drive_error(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountConfig, _attach_drive
from agents.sandbox.errors import MountConfigError
sandbox = _FakeSandboxInstance()
sandbox.drives.mount_error = RuntimeError("mount api error")
config = BlaxelDriveMountConfig(
drive_name="test-drive", mount_path="/mnt/data", drive_path="/"
)
with pytest.raises(MountConfigError, match="drive mount failed"):
await _attach_drive(sandbox, config)
@pytest.mark.asyncio
async def test_attach_drive_no_drives_api(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountConfig, _attach_drive
from agents.sandbox.errors import MountConfigError
class _NoDrives:
pass
config = BlaxelDriveMountConfig(
drive_name="test-drive", mount_path="/mnt/data", drive_path="/"
)
with pytest.raises(MountConfigError, match="does not expose a drives API"):
await _attach_drive(_NoDrives(), config)
@pytest.mark.asyncio
async def test_detach_drive_success(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _detach_drive
sandbox = _FakeSandboxInstance()
await _detach_drive(sandbox, "/mnt/data")
assert sandbox.drives.unmount_calls == ["/mnt/data"]
@pytest.mark.asyncio
async def test_detach_drive_error_logged_not_raised(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _detach_drive
sandbox = _FakeSandboxInstance()
sandbox.drives.unmount_error = RuntimeError("unmount failed")
# Should not raise; error is logged.
await _detach_drive(sandbox, "/mnt/data")
@pytest.mark.asyncio
async def test_detach_drive_no_drives_api(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _detach_drive
class _NoDrives:
pass
# Should not raise when drives API is missing.
await _detach_drive(_NoDrives(), "/mnt/data")
@pytest.mark.asyncio
async def test_drive_strategy_validate_wrong_mount_type(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountStrategy
from agents.sandbox.errors import MountConfigError
strategy = BlaxelDriveMountStrategy()
mount = MagicMock()
mount.type = "blaxel_drive"
with pytest.raises(MountConfigError, match="BlaxelDriveMount"):
strategy.validate_mount(mount)
@pytest.mark.asyncio
async def test_drive_strategy_validate_non_drive_mount(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountStrategy
from agents.sandbox.errors import MountConfigError
strategy = BlaxelDriveMountStrategy()
mount = MagicMock()
mount.type = "s3_mount"
with pytest.raises(MountConfigError, match="BlaxelDriveMount"):
strategy.validate_mount(mount)
def test_drive_strategy_build_docker_volume_returns_none(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import BlaxelDriveMountStrategy
strategy = BlaxelDriveMountStrategy()
mount = MagicMock()
assert strategy.build_docker_volume_driver_config(mount) is None
# ---------------------------------------------------------------------------
# Unmount bucket stderr logging tests
# ---------------------------------------------------------------------------
class TestUnmountBucketLogging:
@pytest.mark.asyncio
async def test_unmount_all_attempts_fail_logs_warning(self) -> None:
from agents.extensions.sandbox.blaxel.mounts import _unmount_bucket
session = _FakeMountSession()
session._next_results = [
_FakeExecResultForMount(exit_code=1), # fusermount fails
_FakeExecResultForMount(exit_code=1), # umount fails
_FakeExecResultForMount(exit_code=1), # umount -l fails
]
# Should not raise, just log warning.
await _unmount_bucket(session, "/mnt/test") # type: ignore[arg-type]
assert len(session.exec_calls) == 3
# ---------------------------------------------------------------------------
# FakeFs.ls improvement tests
# ---------------------------------------------------------------------------
class TestFakeFs:
@pytest.mark.asyncio
async def test_ls_returns_matching_paths(self) -> None:
fs = _FakeFs()
fs.files["/workspace/a.txt"] = b"a"
fs.files["/workspace/b.txt"] = b"b"
fs.files["/other/c.txt"] = b"c"
result = await fs.ls("/workspace")
assert "/workspace/a.txt" in result
assert "/workspace/b.txt" in result
assert "/other/c.txt" not in result
@pytest.mark.asyncio
async def test_ls_empty_returns_path(self) -> None:
fs = _FakeFs()
result = await fs.ls("/empty")
assert result == ["/empty"]
# ---------------------------------------------------------------------------
# Shutdown logging tests
# ---------------------------------------------------------------------------
class TestShutdownLogging:
@pytest.mark.asyncio
async def test_shutdown_delete_logs_warning(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""shutdown() should log a warning when delete fails, not silently suppress."""
session = _make_session(fake_sandbox)
async def _raise() -> None:
raise RuntimeError("delete failed")
fake_sandbox.delete = _raise # type: ignore[method-assign]
# Should not raise.
await session.shutdown()
@pytest.mark.asyncio
async def test_running_false_logs_debug(self, fake_sandbox: _FakeSandboxInstance) -> None:
"""running() should log at debug level when health check fails."""
session = _make_session(fake_sandbox)
async def _raise(*args: object, **kw: object) -> None:
raise ConnectionError("offline")
fake_sandbox.fs.ls = _raise # type: ignore[assignment]
assert await session.running() is False