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