2564 lines
85 KiB
Python
2564 lines
85 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import builtins
|
|
import inspect
|
|
import io
|
|
import logging
|
|
import shlex
|
|
import tarfile
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Literal, cast
|
|
|
|
import pytest
|
|
from pydantic import Field, PrivateAttr
|
|
|
|
import agents.extensions.sandbox.e2b.sandbox as e2b_module
|
|
from agents.extensions.sandbox._rclone import (
|
|
ensure_rclone as _ensure_rclone,
|
|
rclone_pattern_for_session as _rclone_pattern_for_session,
|
|
)
|
|
from agents.extensions.sandbox.e2b.mounts import (
|
|
E2BCloudBucketMountStrategy,
|
|
_assert_e2b_session,
|
|
_ensure_fuse_support,
|
|
)
|
|
from agents.extensions.sandbox.e2b.sandbox import (
|
|
E2BSandboxClient,
|
|
E2BSandboxClientOptions,
|
|
E2BSandboxSession,
|
|
E2BSandboxSessionState,
|
|
)
|
|
from agents.sandbox import Manifest
|
|
from agents.sandbox.entries import (
|
|
Dir,
|
|
InContainerMountStrategy,
|
|
Mount,
|
|
MountpointMountPattern,
|
|
RcloneMountPattern,
|
|
S3Mount,
|
|
)
|
|
from agents.sandbox.entries.mounts.base import InContainerMountAdapter
|
|
from agents.sandbox.errors import (
|
|
ExecTimeoutError,
|
|
ExecTransportError,
|
|
InvalidManifestPathError,
|
|
MountConfigError,
|
|
WorkspaceArchiveReadError,
|
|
WorkspaceArchiveWriteError,
|
|
WorkspaceStartError,
|
|
)
|
|
from agents.sandbox.files import EntryKind
|
|
from agents.sandbox.materialization import MaterializedFile
|
|
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
|
from agents.sandbox.session.dependencies import Dependencies
|
|
from agents.sandbox.session.runtime_helpers import (
|
|
RESOLVE_WORKSPACE_PATH_HELPER,
|
|
WORKSPACE_FINGERPRINT_HELPER,
|
|
)
|
|
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase
|
|
from agents.sandbox.types import ExecResult, User
|
|
|
|
|
|
def test_e2b_package_re_exports_backend_symbols() -> None:
|
|
package_module = __import__(
|
|
"agents.extensions.sandbox.e2b",
|
|
fromlist=["E2BCloudBucketMountStrategy", "E2BSandboxClient"],
|
|
)
|
|
|
|
assert package_module.E2BCloudBucketMountStrategy is E2BCloudBucketMountStrategy
|
|
assert package_module.E2BSandboxClient is E2BSandboxClient
|
|
|
|
|
|
def test_e2b_extension_re_exports_cloud_bucket_strategy() -> None:
|
|
package_module = __import__(
|
|
"agents.extensions.sandbox",
|
|
fromlist=["E2BCloudBucketMountStrategy"],
|
|
)
|
|
|
|
assert package_module.E2BCloudBucketMountStrategy is E2BCloudBucketMountStrategy
|
|
|
|
|
|
def test_e2b_mount_strategy_type_and_default_pattern() -> None:
|
|
strategy = E2BCloudBucketMountStrategy()
|
|
|
|
assert strategy.type == "e2b_cloud_bucket"
|
|
assert isinstance(strategy.pattern, RcloneMountPattern)
|
|
assert strategy.pattern.mode == "fuse"
|
|
|
|
|
|
def test_e2b_mount_strategy_round_trips_through_manifest() -> None:
|
|
manifest = Manifest.model_validate(
|
|
{
|
|
"root": "/workspace",
|
|
"entries": {
|
|
"bucket": {
|
|
"type": "s3_mount",
|
|
"bucket": "my-bucket",
|
|
"mount_strategy": {"type": "e2b_cloud_bucket"},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
mount = manifest.entries["bucket"]
|
|
assert isinstance(mount, S3Mount)
|
|
assert isinstance(mount.mount_strategy, E2BCloudBucketMountStrategy)
|
|
|
|
|
|
def test_e2b_session_guard_rejects_wrong_type() -> None:
|
|
class _WrongSession:
|
|
pass
|
|
|
|
with pytest.raises(MountConfigError, match="E2BSandboxSession"):
|
|
_assert_e2b_session(_WrongSession()) # type: ignore[arg-type]
|
|
|
|
|
|
def test_e2b_session_guard_accepts_correct_type() -> None:
|
|
_assert_e2b_session(_FakeMountSession())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_ensure_fuse_uses_root_chmod() -> None:
|
|
session = _FakeMountSession([_exec_ok(), _exec_ok()])
|
|
|
|
await _ensure_fuse_support(session)
|
|
|
|
assert session.exec_calls == [
|
|
(
|
|
"sh -lc test -c /dev/fuse && grep -qw fuse /proc/filesystems && "
|
|
"(command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1)"
|
|
),
|
|
(
|
|
"sudo -u root -- sh -lc chmod a+rw /dev/fuse && "
|
|
"touch /etc/fuse.conf && "
|
|
"(grep -qxF user_allow_other /etc/fuse.conf || "
|
|
"printf '\\nuser_allow_other\\n' >> /etc/fuse.conf)"
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_ensure_rclone_installs_with_root_apt() -> None:
|
|
session = _FakeMountSession(
|
|
[
|
|
_exec_fail(), # rclone missing
|
|
_exec_ok(), # apt-get present
|
|
_exec_ok(), # apt-get update succeeds
|
|
_exec_ok(), # package install succeeds
|
|
_exec_ok(), # upstream rclone install succeeds
|
|
_exec_ok(), # rclone now present
|
|
]
|
|
)
|
|
|
|
await _ensure_rclone(session)
|
|
|
|
assert session.exec_calls[:2] == [
|
|
"sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone",
|
|
"sh -lc command -v apt-get >/dev/null 2>&1",
|
|
]
|
|
assert session.exec_calls[2] == (
|
|
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
|
|
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 update -qq"
|
|
)
|
|
assert session.exec_calls[3] == (
|
|
"sudo -u root -- sh -lc DEBIAN_FRONTEND=noninteractive "
|
|
"DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0 install -y -qq "
|
|
"curl unzip ca-certificates"
|
|
)
|
|
assert (
|
|
session.exec_calls[4]
|
|
== "sudo -u root -- sh -lc curl -fsSL https://rclone.org/install.sh | bash"
|
|
)
|
|
assert session.exec_calls[5] == (
|
|
"sh -lc command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_rclone_pattern_adds_fuse_access_args() -> None:
|
|
session = _FakeMountSession([_exec_ok(stdout=b"1000\n1000\n")])
|
|
|
|
pattern = await _rclone_pattern_for_session(session, RcloneMountPattern(mode="fuse"))
|
|
|
|
assert pattern.extra_args == ["--allow-other", "--uid", "1000", "--gid", "1000"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_rclone_pattern_preserves_explicit_access_args() -> None:
|
|
session = _FakeMountSession([_exec_ok(stdout=b"1000\n1000\n")])
|
|
source_pattern = RcloneMountPattern(
|
|
mode="fuse",
|
|
extra_args=["--allow-other", "--uid", "123", "--gid", "456", "--buffer-size", "0"],
|
|
)
|
|
|
|
pattern = await _rclone_pattern_for_session(session, source_pattern)
|
|
|
|
assert pattern.extra_args == [
|
|
"--allow-other",
|
|
"--uid",
|
|
"123",
|
|
"--gid",
|
|
"456",
|
|
"--buffer-size",
|
|
"0",
|
|
]
|
|
|
|
|
|
class _FakeE2BResult:
|
|
def __init__(self, *, stdout: str = "", stderr: str = "", exit_code: int = 0) -> None:
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.exit_code = exit_code
|
|
|
|
|
|
class _FakeE2BCommandExitException(Exception):
|
|
def __init__(self, *, exit_code: int) -> None:
|
|
super().__init__(f"command exited with {exit_code}")
|
|
self.exit_code = exit_code
|
|
|
|
|
|
class _FakeE2BAsyncCommandHandle:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
result_exit_code: int = 0,
|
|
initial_exit_code: int | None = None,
|
|
wait_delay_s: float = 0,
|
|
wait_error: BaseException | None = None,
|
|
wait_never: bool = False,
|
|
wait_until_released: bool = False,
|
|
) -> None:
|
|
self.exit_code = initial_exit_code
|
|
self.result_exit_code = result_exit_code
|
|
self.wait_delay_s = wait_delay_s
|
|
self.wait_error = wait_error
|
|
self.wait_never = wait_never
|
|
self.wait_until_released = wait_until_released
|
|
self.wait_calls = 0
|
|
self.wait_cancelled = False
|
|
self.kill_calls = 0
|
|
self._wait_released = asyncio.Event()
|
|
|
|
async def wait(self) -> _FakeE2BResult:
|
|
self.wait_calls += 1
|
|
try:
|
|
if self.wait_never:
|
|
await asyncio.Event().wait()
|
|
if self.wait_until_released:
|
|
await self._wait_released.wait()
|
|
if self.wait_delay_s:
|
|
await asyncio.sleep(self.wait_delay_s)
|
|
if self.wait_error is not None:
|
|
raise self.wait_error
|
|
self.exit_code = self.result_exit_code
|
|
return _FakeE2BResult(exit_code=self.result_exit_code)
|
|
except asyncio.CancelledError:
|
|
self.wait_cancelled = True
|
|
raise
|
|
|
|
async def kill(self) -> bool:
|
|
self.kill_calls += 1
|
|
self.exit_code = 0
|
|
return True
|
|
|
|
def release_wait(self) -> None:
|
|
self._wait_released.set()
|
|
|
|
|
|
class _FakeE2BFiles:
|
|
def __init__(self) -> None:
|
|
self.make_dir_calls: list[tuple[str, float | None]] = []
|
|
|
|
async def write(
|
|
self,
|
|
path: str,
|
|
data: bytes,
|
|
request_timeout: float | None = None,
|
|
) -> None:
|
|
_ = (path, data, request_timeout)
|
|
|
|
async def remove(self, path: str, request_timeout: float | None = None) -> None:
|
|
_ = (path, request_timeout)
|
|
|
|
async def make_dir(self, path: str, request_timeout: float | None = None) -> bool:
|
|
self.make_dir_calls.append((path, request_timeout))
|
|
return True
|
|
|
|
async def read(self, path: str, format: str = "bytes") -> bytes:
|
|
_ = (path, format)
|
|
return b""
|
|
|
|
|
|
class _FakeE2BCommands:
|
|
def __init__(self) -> None:
|
|
self.exec_root_ready = False
|
|
self.calls: list[dict[str, object]] = []
|
|
self.mkdir_result: _FakeE2BResult | None = None
|
|
self.next_result = _FakeE2BResult()
|
|
self.background_calls: list[dict[str, object]] = []
|
|
self.background_error: BaseException | None = None
|
|
self.next_async_command_handle: _FakeE2BAsyncCommandHandle | None = None
|
|
self.async_command_stdout_chunks: list[bytes | str] = []
|
|
|
|
async def run(
|
|
self,
|
|
command: str,
|
|
background: bool | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
user: str | None = None,
|
|
cwd: str | None = None,
|
|
on_stdout: object | None = None,
|
|
on_stderr: object | None = None,
|
|
stdin: bool | None = None,
|
|
timeout: float | None = None,
|
|
request_timeout: float | None = None,
|
|
) -> object:
|
|
_ = request_timeout
|
|
if background:
|
|
if self.background_error is not None:
|
|
raise self.background_error
|
|
_ = on_stderr
|
|
self.background_calls.append(
|
|
{
|
|
"command": command,
|
|
"timeout": timeout,
|
|
"cwd": cwd,
|
|
"envs": envs,
|
|
"stdin": stdin,
|
|
"background": background,
|
|
}
|
|
)
|
|
if callable(on_stdout):
|
|
for chunk in self.async_command_stdout_chunks:
|
|
result = on_stdout(chunk)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
|
|
return self.next_async_command_handle or _FakeE2BAsyncCommandHandle()
|
|
|
|
self.calls.append(
|
|
{
|
|
"command": command,
|
|
"timeout": timeout,
|
|
"cwd": cwd,
|
|
"envs": envs,
|
|
"user": user,
|
|
}
|
|
)
|
|
parts = shlex.split(command)
|
|
if _is_helper_install_command(command):
|
|
return _FakeE2BResult()
|
|
if _is_helper_present_command(command):
|
|
return _FakeE2BResult()
|
|
if parts and parts[0] == str(RESOLVE_WORKSPACE_PATH_HELPER.install_path):
|
|
return _FakeE2BResult(stdout=parts[2])
|
|
if parts and parts[0] == str(WORKSPACE_FINGERPRINT_HELPER.install_path):
|
|
return _FakeE2BResult(
|
|
stdout='{"fingerprint":"fake-workspace-fingerprint","version":"workspace_tar_sha256_v1"}\n'
|
|
)
|
|
if command == "test -d /workspace" and cwd in (None, "/"):
|
|
exit_code = 0 if self.exec_root_ready else 1
|
|
return _FakeE2BResult(exit_code=exit_code)
|
|
if command == "mkdir -p -- /workspace" and cwd == "/":
|
|
result = self.mkdir_result or _FakeE2BResult()
|
|
if result.exit_code == 0:
|
|
self.exec_root_ready = True
|
|
self.mkdir_result = None
|
|
return result
|
|
if cwd == "/workspace" and not self.exec_root_ready:
|
|
raise ValueError("cwd '/workspace' does not exist")
|
|
result = self.next_result
|
|
self.next_result = _FakeE2BResult()
|
|
return result
|
|
|
|
|
|
class _FakeE2BPtyHandle(_FakeE2BAsyncCommandHandle):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
result_exit_code: int = 0,
|
|
wait_delay_s: float = 0,
|
|
wait_error: BaseException | None = None,
|
|
wait_never: bool = True,
|
|
) -> None:
|
|
super().__init__(
|
|
result_exit_code=result_exit_code,
|
|
wait_delay_s=wait_delay_s,
|
|
wait_error=wait_error,
|
|
wait_never=wait_never,
|
|
)
|
|
self.pid = "pty-123"
|
|
self.stdin_payloads: list[bytes] = []
|
|
|
|
|
|
class _FakeE2BPty:
|
|
def __init__(self) -> None:
|
|
self.handle = _FakeE2BPtyHandle()
|
|
self.on_data: object | None = None
|
|
self.stdin_output_chunks: list[bytes | str] = []
|
|
self.create_error: BaseException | None = None
|
|
self.send_stdin_error: BaseException | None = None
|
|
|
|
async def create(
|
|
self,
|
|
*,
|
|
size: object,
|
|
cwd: str | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
on_data: object | None = None,
|
|
) -> _FakeE2BPtyHandle:
|
|
_ = (size, cwd, envs, timeout)
|
|
if self.create_error is not None:
|
|
raise self.create_error
|
|
self.on_data = on_data
|
|
return self.handle
|
|
|
|
async def send_stdin(
|
|
self,
|
|
pid: object,
|
|
data: bytes,
|
|
request_timeout: float | None = None,
|
|
) -> None:
|
|
_ = (pid, request_timeout)
|
|
if self.send_stdin_error is not None:
|
|
raise self.send_stdin_error
|
|
self.handle.stdin_payloads.append(data)
|
|
if callable(self.on_data):
|
|
for chunk in self.stdin_output_chunks:
|
|
result = self.on_data(chunk)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
self.stdin_output_chunks.clear()
|
|
|
|
|
|
class _FakeE2BSandbox:
|
|
def __init__(self) -> None:
|
|
self.sandbox_id = "sb-123"
|
|
self.files = _FakeE2BFiles()
|
|
self.commands = _FakeE2BCommands()
|
|
self.pty = _FakeE2BPty()
|
|
self.created_snapshot_id = "snap-123"
|
|
self.pause_error: BaseException | None = None
|
|
self.kill_error: BaseException | None = None
|
|
self.pause_calls = 0
|
|
self.kill_calls = 0
|
|
|
|
async def pause(self) -> None:
|
|
self.pause_calls += 1
|
|
if self.pause_error is not None:
|
|
raise self.pause_error
|
|
return
|
|
|
|
async def kill(self) -> None:
|
|
self.kill_calls += 1
|
|
if self.kill_error is not None:
|
|
raise self.kill_error
|
|
return
|
|
|
|
async def is_running(self, request_timeout: float | None = None) -> bool:
|
|
_ = request_timeout
|
|
return True
|
|
|
|
def get_host(self, port: int) -> str:
|
|
return f"{port}-{self.sandbox_id}.sandbox.example.test"
|
|
|
|
async def create_snapshot(self) -> object:
|
|
return type("SnapshotInfo", (), {"snapshot_id": self.created_snapshot_id})()
|
|
|
|
|
|
class _FakeMountSession(BaseSandboxSession):
|
|
__name__ = "E2BSandboxSession"
|
|
|
|
def __init__(self, results: list[ExecResult] | None = None) -> None:
|
|
self.state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sb-123",
|
|
)
|
|
self._results = list(results or [])
|
|
self.exec_calls: list[str] = []
|
|
|
|
async def _exec_internal(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
) -> ExecResult:
|
|
_ = timeout
|
|
cmd_str = " ".join(str(c) for c in command)
|
|
self.exec_calls.append(cmd_str)
|
|
if self._results:
|
|
return self._results.pop(0)
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
|
|
|
async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
|
|
_ = (path, user)
|
|
return io.BytesIO(b"")
|
|
|
|
async def write(self, path: Path, data: io.IOBase, *, user: str | User | None = None) -> None:
|
|
_ = (path, data, user)
|
|
|
|
async def persist_workspace(self) -> io.IOBase:
|
|
raise AssertionError("not expected")
|
|
|
|
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
|
_ = data
|
|
raise AssertionError("not expected")
|
|
|
|
async def running(self) -> bool:
|
|
return True
|
|
|
|
|
|
_FakeMountSession.__name__ = "E2BSandboxSession"
|
|
|
|
|
|
def _exec_ok(stdout: bytes = b"") -> ExecResult:
|
|
return ExecResult(stdout=stdout, stderr=b"", exit_code=0)
|
|
|
|
|
|
def _exec_fail() -> ExecResult:
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=1)
|
|
|
|
|
|
class _RestorableSnapshot(SnapshotBase):
|
|
type: Literal["test-restorable-e2b"] = "test-restorable-e2b"
|
|
payload: bytes = b"restored"
|
|
|
|
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
|
_ = (data, dependencies)
|
|
|
|
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
|
_ = dependencies
|
|
return io.BytesIO(self.payload)
|
|
|
|
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
|
_ = dependencies
|
|
return True
|
|
|
|
|
|
class _RecordingMount(Mount):
|
|
type: str = "recording_mount"
|
|
mount_strategy: InContainerMountStrategy = Field(
|
|
default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern())
|
|
)
|
|
_mounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
_unmounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
_events: list[tuple[str, str]] = PrivateAttr(default_factory=list)
|
|
|
|
def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount:
|
|
self._events = events
|
|
return self
|
|
|
|
def supported_in_container_patterns(
|
|
self,
|
|
) -> tuple[builtins.type[MountpointMountPattern], ...]:
|
|
return (MountpointMountPattern,)
|
|
|
|
def build_docker_volume_driver_config(
|
|
self,
|
|
strategy: object,
|
|
) -> tuple[str, dict[str, str], bool]:
|
|
_ = strategy
|
|
raise MountConfigError(
|
|
message="docker-volume mounts are not supported for this mount type",
|
|
context={"mount_type": self.type},
|
|
)
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
_ = strategy
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("mount", path.as_posix()))
|
|
mount._mounted_paths.append(path)
|
|
return []
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("unmount", path.as_posix()))
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("unmount", path.as_posix()))
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("mount", path.as_posix()))
|
|
mount._mounted_paths.append(path)
|
|
|
|
return _Adapter(self)
|
|
|
|
|
|
class _FailingUnmountMount(_RecordingMount):
|
|
type: str = "failing_unmount_mount"
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
base_adapter = super().in_container_adapter()
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
base_adapter.validate(strategy)
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
return await base_adapter.activate(strategy, session, dest, base_dir)
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("unmount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while unmounting second mount")
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("unmount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while unmounting second mount")
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
await base_adapter.restore_after_snapshot(strategy, session, path)
|
|
|
|
return _Adapter(self)
|
|
|
|
|
|
class _FailingRemountMount(_RecordingMount):
|
|
type: str = "failing_remount_mount"
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
base_adapter = super().in_container_adapter()
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
base_adapter.validate(strategy)
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("mount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while remounting second mount")
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
return await base_adapter.deactivate(strategy, session, dest, base_dir)
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
await base_adapter.teardown_for_snapshot(strategy, session, path)
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("mount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while remounting second mount")
|
|
|
|
return _Adapter(self)
|
|
|
|
|
|
def _session(
|
|
*,
|
|
workspace_root_ready: bool = False,
|
|
exposed_ports: tuple[int, ...] = (),
|
|
) -> tuple[E2BSandboxSession, _FakeE2BSandbox]:
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=workspace_root_ready,
|
|
exposed_ports=exposed_ports,
|
|
)
|
|
return E2BSandboxSession.from_state(state, sandbox=sandbox), sandbox
|
|
|
|
|
|
def _tar_bytes() -> bytes:
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w") as tar:
|
|
info = tarfile.TarInfo("note.txt")
|
|
payload = b"hello"
|
|
info.size = len(payload)
|
|
tar.addfile(info, io.BytesIO(payload))
|
|
return buf.getvalue()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_sandbox_connect_prefers_full_sandbox_wrapper() -> None:
|
|
class _FakeSandboxClass:
|
|
calls: list[tuple[str, str, int | None]] = []
|
|
|
|
@classmethod
|
|
async def connect(cls, *, sandbox_id: str, timeout: int | None = None) -> str:
|
|
cls.calls.append(("connect", sandbox_id, timeout))
|
|
return "full-sandbox-wrapper"
|
|
|
|
@classmethod
|
|
async def _cls_connect_sandbox(cls, *, sandbox_id: str, timeout: int | None = None) -> str:
|
|
cls.calls.append(("_cls_connect_sandbox", sandbox_id, timeout))
|
|
return "private-full-sandbox-wrapper"
|
|
|
|
@classmethod
|
|
async def _cls_connect(cls, *, sandbox_id: str, timeout: int | None = None) -> str:
|
|
cls.calls.append(("_cls_connect", sandbox_id, timeout))
|
|
return "low-level-api-model"
|
|
|
|
connected = await e2b_module._sandbox_connect(
|
|
cast(e2b_module._E2BSandboxFactoryAPI, _FakeSandboxClass),
|
|
sandbox_id="sb-123",
|
|
timeout=300,
|
|
)
|
|
|
|
assert connected == "full-sandbox-wrapper"
|
|
assert _FakeSandboxClass.calls == [("connect", "sb-123", 300)]
|
|
|
|
|
|
def test_e2b_import_resolves_sdk_sandbox_classes_for_canonical_types(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
imports: list[str] = []
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def _fake_import(
|
|
name: str,
|
|
globals: dict[str, object] | None = None,
|
|
locals: dict[str, object] | None = None,
|
|
fromlist: tuple[str, ...] = (),
|
|
level: int = 0,
|
|
) -> object:
|
|
if name == "e2b_code_interpreter":
|
|
imports.append(name)
|
|
return type("FakeCodeInterpreterModule", (), {"AsyncSandbox": object()})()
|
|
if name == "e2b":
|
|
imports.append(name)
|
|
return type("FakeE2BModule", (), {"AsyncSandbox": object()})()
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", _fake_import)
|
|
|
|
assert e2b_module._import_sandbox_class(e2b_module.E2BSandboxType.CODE_INTERPRETER) is not None
|
|
assert e2b_module._import_sandbox_class(e2b_module.E2BSandboxType.E2B) is not None
|
|
assert imports == ["e2b_code_interpreter", "e2b"]
|
|
|
|
|
|
def _visible_command_calls(sandbox: _FakeE2BSandbox) -> list[dict[str, object]]:
|
|
return [
|
|
call
|
|
for call in sandbox.commands.calls
|
|
if not _is_helper_install_command(str(call["command"]))
|
|
and not _is_helper_present_command(str(call["command"]))
|
|
and not _is_helper_invoke_command(str(call["command"]))
|
|
]
|
|
|
|
|
|
def _is_helper_install_command(command: str) -> bool:
|
|
return RESOLVE_WORKSPACE_PATH_HELPER.install_marker in command
|
|
|
|
|
|
def _is_helper_invoke_command(command: str) -> bool:
|
|
parts = shlex.split(command)
|
|
return bool(parts) and parts[0].startswith("/tmp/openai-agents/bin/")
|
|
|
|
|
|
def _is_helper_present_command(command: str) -> bool:
|
|
parts = shlex.split(command)
|
|
return (
|
|
len(parts) == 3
|
|
and parts[:2] == ["test", "-x"]
|
|
and parts[2].startswith("/tmp/openai-agents/bin/")
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_omits_cwd_until_workspace_ready() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
|
|
result = await session._exec_internal("find", ".", timeout=0.01) # noqa: SLF001
|
|
|
|
assert result.ok()
|
|
assert sandbox.commands.calls == [
|
|
{
|
|
"command": "find .",
|
|
"timeout": 0.01,
|
|
"cwd": None,
|
|
"envs": {},
|
|
"user": None,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_uses_manifest_root_after_workspace_ready() -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
sandbox.commands.exec_root_ready = True
|
|
|
|
result = await session._exec_internal("find", ".", timeout=0.01) # noqa: SLF001
|
|
|
|
assert result.ok()
|
|
assert sandbox.commands.calls == [
|
|
{
|
|
"command": "find .",
|
|
"timeout": 0.01,
|
|
"cwd": "/workspace",
|
|
"envs": {},
|
|
"user": None,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_start_prepares_workspace_root_for_command_cwd() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
|
|
await session.start()
|
|
result = await session._exec_internal("pwd", timeout=0.01) # noqa: SLF001
|
|
|
|
assert result.ok()
|
|
assert session.state.workspace_root_ready is True
|
|
assert session._workspace_root_ready is True # noqa: SLF001
|
|
assert _visible_command_calls(sandbox) == [
|
|
{
|
|
"command": "mkdir -p -- /workspace",
|
|
"timeout": 10,
|
|
"cwd": "/",
|
|
"envs": {},
|
|
"user": None,
|
|
},
|
|
{
|
|
"command": "pwd",
|
|
"timeout": 0.01,
|
|
"cwd": "/workspace",
|
|
"envs": {},
|
|
"user": None,
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_start_installs_runtime_helpers() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
|
|
await session.start()
|
|
|
|
assert any(_is_helper_install_command(str(call["command"])) for call in sandbox.commands.calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_start_raises_on_nonzero_workspace_root_setup_exit() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
sandbox.commands.mkdir_result = _FakeE2BResult(stderr="mkdir failed", exit_code=2)
|
|
|
|
with pytest.raises(WorkspaceStartError) as exc_info:
|
|
await session.start()
|
|
|
|
assert exc_info.value.context["reason"] == "workspace_root_nonzero_exit"
|
|
assert exc_info.value.context["exit_code"] == 2
|
|
assert session.state.workspace_root_ready is False
|
|
assert session._workspace_root_ready is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_preserved_start_still_prepares_workspace_root_for_resumed_exec_cwd() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
session._set_start_state_preserved(True) # noqa: SLF001
|
|
|
|
await session.start()
|
|
result = await session._exec_internal("pwd", timeout=0.01) # noqa: SLF001
|
|
|
|
assert result.ok()
|
|
assert session.state.workspace_root_ready is True
|
|
assert session._workspace_root_ready is True # noqa: SLF001
|
|
assert session._can_reuse_preserved_workspace_on_resume() is False # noqa: SLF001
|
|
assert session.should_provision_manifest_accounts_on_resume() is False
|
|
assert _visible_command_calls(sandbox) == [
|
|
{
|
|
"command": "test -d /workspace",
|
|
"timeout": 10.0,
|
|
"cwd": None,
|
|
"envs": {},
|
|
"user": None,
|
|
},
|
|
{
|
|
"command": "mkdir -p -- /workspace",
|
|
"timeout": 10,
|
|
"cwd": "/",
|
|
"envs": {},
|
|
"user": None,
|
|
},
|
|
{
|
|
"command": "pwd",
|
|
"timeout": 0.01,
|
|
"cwd": "/workspace",
|
|
"envs": {},
|
|
"user": None,
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_preserved_start_uses_shared_resume_gate_for_restore() -> None:
|
|
session, _sandbox = _session(workspace_root_ready=True)
|
|
session.state.snapshot = _RestorableSnapshot(id="snapshot")
|
|
session._set_start_state_preserved(True) # noqa: SLF001
|
|
events: list[object] = []
|
|
|
|
async def _gate(*, is_running: bool) -> bool:
|
|
events.append(("gate", is_running))
|
|
return False
|
|
|
|
async def _restore() -> None:
|
|
events.append("restore")
|
|
|
|
async def _reapply() -> None:
|
|
events.append("reapply")
|
|
|
|
session._can_skip_snapshot_restore_on_resume = _gate # type: ignore[method-assign]
|
|
session._restore_snapshot_into_workspace_on_resume = _restore # type: ignore[method-assign]
|
|
session._reapply_ephemeral_manifest_on_resume = _reapply # type: ignore[method-assign]
|
|
|
|
await session.start()
|
|
|
|
assert session.state.workspace_root_ready is True
|
|
assert session._workspace_root_ready is True # noqa: SLF001
|
|
assert events == [("gate", True), "restore", "reapply"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_running_requires_workspace_root_ready() -> None:
|
|
session, _sandbox = _session(workspace_root_ready=False)
|
|
|
|
assert await session.running() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_running_checks_remote_after_workspace_ready() -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
sandbox.commands.exec_root_ready = True
|
|
|
|
assert await session.running() is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_resolve_exposed_port_uses_backend_host() -> None:
|
|
session, _sandbox = _session(workspace_root_ready=True, exposed_ports=(8765,))
|
|
|
|
endpoint = await session.resolve_exposed_port(8765)
|
|
|
|
assert endpoint.host == "8765-sb-123.sandbox.example.test"
|
|
assert endpoint.port == 443
|
|
assert endpoint.tls is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_client_create_enables_public_traffic_for_exposed_ports(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
create_calls: list[dict[str, object]] = []
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(
|
|
*,
|
|
template: str | None = None,
|
|
timeout: int | None = None,
|
|
metadata: dict[str, str] | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
secure: bool = True,
|
|
allow_internet_access: bool = True,
|
|
network: dict[str, object] | None = None,
|
|
lifecycle: dict[str, object] | None = None,
|
|
mcp: dict[str, dict[str, str]] | None = None,
|
|
) -> _FakeE2BSandbox:
|
|
_ = (
|
|
template,
|
|
timeout,
|
|
metadata,
|
|
envs,
|
|
secure,
|
|
allow_internet_access,
|
|
network,
|
|
lifecycle,
|
|
mcp,
|
|
)
|
|
create_calls.append(
|
|
{
|
|
"template": template,
|
|
"timeout": timeout,
|
|
"metadata": metadata,
|
|
"envs": envs,
|
|
"secure": secure,
|
|
"allow_internet_access": allow_internet_access,
|
|
"network": network,
|
|
"lifecycle": lifecycle,
|
|
"mcp": mcp,
|
|
}
|
|
)
|
|
return _FakeE2BSandbox()
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
session = await client.create(
|
|
options=E2BSandboxClientOptions(
|
|
sandbox_type="e2b",
|
|
exposed_ports=(8765,),
|
|
)
|
|
)
|
|
|
|
assert create_calls
|
|
assert create_calls[0]["network"] == {"allow_public_traffic": True}
|
|
assert create_calls[0]["lifecycle"] == {"on_timeout": "pause", "auto_resume": True}
|
|
assert isinstance(session.state, E2BSandboxSessionState)
|
|
assert session.state.exposed_ports == (8765,)
|
|
assert session.state.on_timeout == "pause"
|
|
assert session.state.auto_resume is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_client_create_omits_auto_resume_for_kill_timeout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
create_calls: list[dict[str, object]] = []
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(
|
|
*,
|
|
template: str | None = None,
|
|
timeout: int | None = None,
|
|
metadata: dict[str, str] | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
secure: bool = True,
|
|
allow_internet_access: bool = True,
|
|
network: dict[str, object] | None = None,
|
|
lifecycle: dict[str, object] | None = None,
|
|
mcp: dict[str, dict[str, str]] | None = None,
|
|
) -> _FakeE2BSandbox:
|
|
_ = (
|
|
template,
|
|
timeout,
|
|
metadata,
|
|
envs,
|
|
secure,
|
|
allow_internet_access,
|
|
network,
|
|
lifecycle,
|
|
mcp,
|
|
)
|
|
create_calls.append({"lifecycle": lifecycle})
|
|
return _FakeE2BSandbox()
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
session = await client.create(
|
|
options=E2BSandboxClientOptions(
|
|
sandbox_type="e2b",
|
|
on_timeout="kill",
|
|
)
|
|
)
|
|
|
|
assert create_calls == [{"lifecycle": {"on_timeout": "kill"}}]
|
|
assert isinstance(session.state, E2BSandboxSessionState)
|
|
assert session.state.on_timeout == "kill"
|
|
assert session.state.auto_resume is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_client_create_passes_mcp_config(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
create_calls: list[dict[str, object]] = []
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(
|
|
*,
|
|
template: str | None = None,
|
|
timeout: int | None = None,
|
|
metadata: dict[str, str] | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
secure: bool = True,
|
|
allow_internet_access: bool = True,
|
|
network: dict[str, object] | None = None,
|
|
lifecycle: dict[str, object] | None = None,
|
|
mcp: dict[str, dict[str, str]] | None = None,
|
|
) -> _FakeE2BSandbox:
|
|
_ = (
|
|
template,
|
|
timeout,
|
|
metadata,
|
|
envs,
|
|
secure,
|
|
allow_internet_access,
|
|
network,
|
|
lifecycle,
|
|
mcp,
|
|
)
|
|
create_calls.append({"mcp": mcp})
|
|
return _FakeE2BSandbox()
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
await client.create(
|
|
options=E2BSandboxClientOptions(
|
|
sandbox_type="e2b",
|
|
mcp={
|
|
"exa": {"apiKey": "exa-key"},
|
|
"browserbase": {
|
|
"apiKey": "browserbase-key",
|
|
"geminiApiKey": "gemini-key",
|
|
"projectId": "project-id",
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
assert create_calls == [
|
|
{
|
|
"mcp": {
|
|
"exa": {"apiKey": "exa-key"},
|
|
"browserbase": {
|
|
"apiKey": "browserbase-key",
|
|
"geminiApiKey": "gemini-key",
|
|
"projectId": "project-id",
|
|
},
|
|
}
|
|
}
|
|
]
|
|
|
|
|
|
def test_e2b_deserialize_session_state_defaults_missing_mcp() -> None:
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sb-123",
|
|
mcp={"exa": {"apiKey": "exa-key"}},
|
|
)
|
|
payload = state.model_dump(mode="python")
|
|
payload.pop("mcp")
|
|
|
|
restored = E2BSandboxClient().deserialize_session_state(cast(dict[str, object], payload))
|
|
|
|
assert isinstance(restored, E2BSandboxSessionState)
|
|
assert restored.mcp is None
|
|
|
|
|
|
def test_e2b_client_options_preserves_positional_exposed_ports() -> None:
|
|
options = E2BSandboxClientOptions(
|
|
"e2b",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
True,
|
|
True,
|
|
None,
|
|
False,
|
|
(8765,),
|
|
)
|
|
|
|
assert options.exposed_ports == (8765,)
|
|
assert options.workspace_persistence == "tar"
|
|
assert options.on_timeout == "pause"
|
|
assert options.auto_resume is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_resume_reuses_paused_timeout_lifecycle_sandbox(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created: list[dict[str, object]] = []
|
|
connected: list[tuple[str, int | None]] = []
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(**kwargs: object) -> _FakeE2BSandbox:
|
|
created.append(dict(kwargs))
|
|
return _FakeE2BSandbox()
|
|
|
|
@staticmethod
|
|
async def connect(*, sandbox_id: str, timeout: int | None = None) -> _FakeE2BSandbox:
|
|
connected.append((sandbox_id, timeout))
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.sandbox_id = sandbox_id
|
|
return sandbox
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sb-paused",
|
|
sandbox_timeout=15,
|
|
on_timeout="pause",
|
|
auto_resume=True,
|
|
pause_on_exit=False,
|
|
)
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert connected == [("sb-paused", 15)]
|
|
assert created == []
|
|
assert isinstance(resumed.state, E2BSandboxSessionState)
|
|
assert resumed.state.sandbox_id == "sb-paused"
|
|
assert isinstance(resumed._inner, E2BSandboxSession)
|
|
assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_resume_reuses_live_kill_timeout_sandbox(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created: list[dict[str, object]] = []
|
|
connected: list[tuple[str, int | None]] = []
|
|
|
|
class _LiveSandbox(_FakeE2BSandbox):
|
|
async def is_running(self, request_timeout: float | None = None) -> bool:
|
|
_ = request_timeout
|
|
return True
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(**kwargs: object) -> _FakeE2BSandbox:
|
|
created.append(dict(kwargs))
|
|
return _FakeE2BSandbox()
|
|
|
|
@staticmethod
|
|
async def connect(*, sandbox_id: str, timeout: int | None = None) -> _LiveSandbox:
|
|
connected.append((sandbox_id, timeout))
|
|
sandbox = _LiveSandbox()
|
|
sandbox.sandbox_id = sandbox_id
|
|
return sandbox
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sb-live",
|
|
sandbox_timeout=15,
|
|
workspace_root_ready=True,
|
|
on_timeout="kill",
|
|
auto_resume=True,
|
|
pause_on_exit=False,
|
|
)
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert connected == [("sb-live", 15)]
|
|
assert created == []
|
|
assert isinstance(resumed.state, E2BSandboxSessionState)
|
|
assert resumed.state.sandbox_id == "sb-live"
|
|
assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_resume_recreates_dead_kill_timeout_sandbox_and_preserves_mcp(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created: list[dict[str, object]] = []
|
|
connected: list[tuple[str, int | None]] = []
|
|
|
|
class _DeadSandbox(_FakeE2BSandbox):
|
|
async def is_running(self, request_timeout: float | None = None) -> bool:
|
|
_ = request_timeout
|
|
return False
|
|
|
|
class _CreatedSandbox(_FakeE2BSandbox):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.sandbox_id = "sb-recreated"
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(
|
|
*,
|
|
template: str | None = None,
|
|
timeout: int | None = None,
|
|
metadata: dict[str, str] | None = None,
|
|
envs: dict[str, str] | None = None,
|
|
secure: bool = True,
|
|
allow_internet_access: bool = True,
|
|
network: dict[str, object] | None = None,
|
|
lifecycle: dict[str, object] | None = None,
|
|
mcp: dict[str, dict[str, str]] | None = None,
|
|
) -> _CreatedSandbox:
|
|
_ = (
|
|
template,
|
|
timeout,
|
|
metadata,
|
|
envs,
|
|
secure,
|
|
allow_internet_access,
|
|
network,
|
|
lifecycle,
|
|
mcp,
|
|
)
|
|
created.append(
|
|
{
|
|
"template": template,
|
|
"timeout": timeout,
|
|
"metadata": metadata,
|
|
"envs": envs,
|
|
"secure": secure,
|
|
"allow_internet_access": allow_internet_access,
|
|
"network": network,
|
|
"lifecycle": lifecycle,
|
|
"mcp": mcp,
|
|
}
|
|
)
|
|
return _CreatedSandbox()
|
|
|
|
@staticmethod
|
|
async def connect(*, sandbox_id: str, timeout: int | None = None) -> _DeadSandbox:
|
|
connected.append((sandbox_id, timeout))
|
|
sandbox = _DeadSandbox()
|
|
sandbox.sandbox_id = sandbox_id
|
|
return sandbox
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
client = E2BSandboxClient()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sb-dead",
|
|
sandbox_timeout=15,
|
|
workspace_root_ready=True,
|
|
on_timeout="kill",
|
|
auto_resume=True,
|
|
pause_on_exit=False,
|
|
mcp={"exa": {"apiKey": "exa-key"}},
|
|
)
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert connected == [("sb-dead", 15)]
|
|
assert created == [
|
|
{
|
|
"template": None,
|
|
"timeout": 15,
|
|
"metadata": None,
|
|
"envs": None,
|
|
"secure": True,
|
|
"allow_internet_access": True,
|
|
"network": None,
|
|
"lifecycle": {"on_timeout": "kill"},
|
|
"mcp": {"exa": {"apiKey": "exa-key"}},
|
|
}
|
|
]
|
|
assert isinstance(resumed.state, E2BSandboxSessionState)
|
|
assert resumed.state.sandbox_id == "sb-recreated"
|
|
assert resumed.state.workspace_root_ready is False
|
|
assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_normalize_path_preserves_safe_leaf_symlink_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
session, _sandbox = _session(workspace_root_ready=True)
|
|
|
|
async def _fake_exec(
|
|
*command: object,
|
|
timeout: float | None = None,
|
|
shell: bool | list[str] = True,
|
|
user: object | None = None,
|
|
) -> ExecResult:
|
|
_ = (timeout, shell, user)
|
|
rendered = [str(part) for part in command]
|
|
if (
|
|
rendered[:2] == ["sh", "-c"]
|
|
and RESOLVE_WORKSPACE_PATH_HELPER.install_marker in rendered[2]
|
|
):
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
|
if rendered and rendered[0] == str(RESOLVE_WORKSPACE_PATH_HELPER.install_path):
|
|
return ExecResult(stdout=b"/workspace/target.txt", stderr=b"", exit_code=0)
|
|
raise AssertionError(f"unexpected command: {rendered!r}")
|
|
|
|
monkeypatch.setattr(session, "exec", _fake_exec)
|
|
|
|
normalized = await session._validate_path_access("link.txt") # noqa: SLF001
|
|
|
|
assert normalized == Path("/workspace/link.txt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_normalize_path_rejects_symlink_escape(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
session, _sandbox = _session(workspace_root_ready=True)
|
|
|
|
async def _fake_exec(
|
|
*command: object,
|
|
timeout: float | None = None,
|
|
shell: bool | list[str] = True,
|
|
user: object | None = None,
|
|
) -> ExecResult:
|
|
_ = (timeout, shell, user)
|
|
rendered = [str(part) for part in command]
|
|
if (
|
|
rendered[:2] == ["sh", "-c"]
|
|
and RESOLVE_WORKSPACE_PATH_HELPER.install_marker in rendered[2]
|
|
):
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
|
if rendered and rendered[0] == str(RESOLVE_WORKSPACE_PATH_HELPER.install_path):
|
|
return ExecResult(stdout=b"", stderr=b"workspace escape", exit_code=111)
|
|
raise AssertionError(f"unexpected command: {rendered!r}")
|
|
|
|
monkeypatch.setattr(session, "exec", _fake_exec)
|
|
|
|
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
|
await session._validate_path_access("link/secret.txt") # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_raises_on_nonzero_snapshot_exit() -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
sandbox.commands.exec_root_ready = True
|
|
sandbox.commands.next_result = _FakeE2BResult(stderr="tar failed", exit_code=2)
|
|
|
|
with pytest.raises(WorkspaceArchiveReadError) as exc_info:
|
|
await session.persist_workspace()
|
|
|
|
assert exc_info.value.context["reason"] == "snapshot_nonzero_exit"
|
|
assert exc_info.value.context["exit_code"] == 2
|
|
assert exc_info.value.retryable is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_excludes_runtime_skip_paths() -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
sandbox.commands.exec_root_ready = True
|
|
session.register_persist_workspace_skip_path(Path("logs/events.jsonl"))
|
|
sandbox.commands.next_result = _FakeE2BResult(
|
|
stdout=base64.b64encode(b"fake-tar-bytes").decode("ascii")
|
|
)
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
expected_command = (
|
|
"tar --exclude=logs/events.jsonl --exclude=./logs/events.jsonl "
|
|
"-C /workspace -cf - . | base64 -w0"
|
|
)
|
|
assert sandbox.commands.calls == [
|
|
{
|
|
"command": expected_command,
|
|
"timeout": session.state.timeouts.snapshot_tar_s,
|
|
"cwd": "/",
|
|
"envs": {},
|
|
"user": None,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_native_snapshot_returns_snapshot_ref() -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
session.state.workspace_persistence = "snapshot"
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == e2b_module._encode_e2b_snapshot_ref(snapshot_id="snap-123")
|
|
assert sandbox.commands.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_native_snapshot_times_out_and_remounts_mounts() -> None:
|
|
events: list[tuple[str, str]] = []
|
|
mount = _RecordingMount().bind_events(events)
|
|
|
|
class _SlowSnapshotSandbox(_FakeE2BSandbox):
|
|
async def create_snapshot(self) -> object:
|
|
await asyncio.sleep(0.2)
|
|
return await super().create_snapshot()
|
|
|
|
sandbox = _SlowSnapshotSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace", entries={"mount": mount}),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
workspace_persistence="snapshot",
|
|
)
|
|
state.timeouts.snapshot_tar_s = 0.01
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(WorkspaceArchiveReadError) as exc_info:
|
|
await session.persist_workspace()
|
|
|
|
assert exc_info.value.context["reason"] == "native_snapshot_failed"
|
|
assert type(exc_info.value.cause).__name__ == "TimeoutError"
|
|
assert events == [
|
|
("unmount", "/workspace/mount"),
|
|
("mount", "/workspace/mount"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_native_snapshot_falls_back_to_tar_for_plain_skip_paths() -> (
|
|
None
|
|
):
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
session.state.workspace_persistence = "snapshot"
|
|
session.register_persist_workspace_skip_path(Path("logs/events.jsonl"))
|
|
sandbox.commands.exec_root_ready = True
|
|
sandbox.commands.next_result = _FakeE2BResult(
|
|
stdout=base64.b64encode(b"fake-tar-bytes").decode("ascii")
|
|
)
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
assert sandbox.commands.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_hydrate_workspace_native_snapshot_recreates_from_snapshot_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
session, sandbox = _session(workspace_root_ready=True)
|
|
session.state.workspace_persistence = "snapshot"
|
|
session.state.mcp = {"exa": {"apiKey": "exa-key"}}
|
|
|
|
created: list[dict[str, object]] = []
|
|
|
|
class _CreatedSandbox(_FakeE2BSandbox):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.sandbox_id = "sb-from-snapshot"
|
|
|
|
class _FakeSandboxFactory:
|
|
@staticmethod
|
|
async def create(**kwargs: object) -> _CreatedSandbox:
|
|
created.append(dict(kwargs))
|
|
return _CreatedSandbox()
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module, "_import_sandbox_class", lambda _sandbox_type: _FakeSandboxFactory
|
|
)
|
|
|
|
payload = io.BytesIO(e2b_module._encode_e2b_snapshot_ref(snapshot_id="snap-123"))
|
|
|
|
await session.hydrate_workspace(payload)
|
|
|
|
assert created == [
|
|
{
|
|
"template": "snap-123",
|
|
"timeout": session.state.sandbox_timeout,
|
|
"metadata": session.state.metadata,
|
|
"envs": None,
|
|
"secure": session.state.secure,
|
|
"allow_internet_access": session.state.allow_internet_access,
|
|
"network": None,
|
|
"lifecycle": {"on_timeout": "pause", "auto_resume": True},
|
|
"mcp": {"exa": {"apiKey": "exa-key"}},
|
|
}
|
|
]
|
|
assert session.state.sandbox_id == "sb-from-snapshot"
|
|
assert session.state.workspace_root_ready is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_hydrate_workspace_raises_on_nonzero_extract_exit() -> None:
|
|
session, sandbox = _session(workspace_root_ready=False)
|
|
sandbox.commands.next_result = _FakeE2BResult(stderr="tar failed", exit_code=2)
|
|
|
|
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(io.BytesIO(_tar_bytes()))
|
|
|
|
assert exc_info.value.context["reason"] == "hydrate_nonzero_exit"
|
|
assert exc_info.value.context["exit_code"] == 2
|
|
assert session.state.workspace_root_ready is False
|
|
assert session._workspace_root_ready is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_remounts_mounts_after_snapshot() -> None:
|
|
mount = _RecordingMount()
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.exec_root_ready = True
|
|
sandbox.commands.next_result = _FakeE2BResult(
|
|
stdout=base64.b64encode(b"fake-tar-bytes").decode("ascii")
|
|
)
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace", entries={"mount": mount}),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
assert mount._unmounted_paths == [Path("/workspace/mount")]
|
|
assert mount._mounted_paths == [Path("/workspace/mount")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_uses_nested_mount_targets_and_resolved_excludes() -> None:
|
|
parent_mount = _RecordingMount(mount_path=Path("repo"))
|
|
child_mount = _RecordingMount(mount_path=Path("repo/sub"))
|
|
events: list[tuple[str, str]] = []
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.exec_root_ready = True
|
|
sandbox.commands.next_result = _FakeE2BResult(
|
|
stdout=base64.b64encode(b"fake-tar-bytes").decode("ascii")
|
|
)
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(
|
|
root="/workspace",
|
|
entries={
|
|
"parent": parent_mount.bind_events(events),
|
|
"nested": Dir(children={"child": child_mount.bind_events(events)}),
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
assert [path for kind, path in events if kind == "unmount"] == [
|
|
"/workspace/repo/sub",
|
|
"/workspace/repo",
|
|
]
|
|
assert [path for kind, path in events if kind == "mount"] == [
|
|
"/workspace/repo",
|
|
"/workspace/repo/sub",
|
|
]
|
|
tar_command = str(sandbox.commands.calls[-1]["command"])
|
|
assert "--exclude=repo" in tar_command
|
|
assert "--exclude=./repo" in tar_command
|
|
assert "--exclude=repo/sub" in tar_command
|
|
assert "--exclude=./repo/sub" in tar_command
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_remounts_prior_mounts_after_unmount_failure() -> None:
|
|
events: list[tuple[str, str]] = []
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.exec_root_ready = True
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(
|
|
root="/workspace",
|
|
entries={
|
|
"repo": Dir(
|
|
children={
|
|
"mount1": _RecordingMount().bind_events(events),
|
|
"mount2": _FailingUnmountMount().bind_events(events),
|
|
}
|
|
)
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(WorkspaceArchiveReadError):
|
|
await session.persist_workspace()
|
|
|
|
assert [kind for kind, _path in events] == [
|
|
"unmount",
|
|
"unmount_fail",
|
|
"mount",
|
|
]
|
|
assert sandbox.commands.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_persist_workspace_keeps_remounting_and_raises_remount_error_first() -> None:
|
|
events: list[tuple[str, str]] = []
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.exec_root_ready = True
|
|
sandbox.commands.next_result = _FakeE2BResult(stderr="tar failed", exit_code=2)
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(
|
|
root="/workspace",
|
|
entries={
|
|
"repo": Dir(
|
|
children={
|
|
"a": _RecordingMount().bind_events(events),
|
|
"b": _FailingRemountMount().bind_events(events),
|
|
}
|
|
)
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(WorkspaceArchiveReadError) as exc_info:
|
|
await session.persist_workspace()
|
|
|
|
assert isinstance(exc_info.value.cause, RuntimeError)
|
|
assert str(exc_info.value.cause) == "boom while remounting second mount"
|
|
assert exc_info.value.context["snapshot_error_before_remount_corruption"] == {
|
|
"message": "failed to read archive for path: /workspace",
|
|
}
|
|
assert [kind for kind, _path in events] == [
|
|
"unmount",
|
|
"unmount",
|
|
"mount_fail",
|
|
"mount",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_clear_workspace_root_on_resume_preserves_nested_mounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
session, _sandbox = _session()
|
|
session.state.manifest = Manifest(
|
|
root="/workspace",
|
|
entries={
|
|
"a/b": _RecordingMount(),
|
|
},
|
|
)
|
|
ls_calls: list[Path] = []
|
|
rm_calls: list[tuple[Path, bool]] = []
|
|
|
|
async def _fake_ls(path: Path | str) -> list[object]:
|
|
rendered = Path(path)
|
|
ls_calls.append(rendered)
|
|
if rendered == Path("/workspace"):
|
|
return [
|
|
type("Entry", (), {"path": "/workspace/a", "kind": EntryKind.DIRECTORY})(),
|
|
type("Entry", (), {"path": "/workspace/root.txt", "kind": EntryKind.FILE})(),
|
|
]
|
|
if rendered == Path("/workspace/a"):
|
|
return [
|
|
type("Entry", (), {"path": "/workspace/a/b", "kind": EntryKind.DIRECTORY})(),
|
|
type("Entry", (), {"path": "/workspace/a/local.txt", "kind": EntryKind.FILE})(),
|
|
]
|
|
raise AssertionError(f"unexpected ls path: {rendered}")
|
|
|
|
async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
|
|
rm_calls.append((Path(path), recursive))
|
|
|
|
monkeypatch.setattr(session, "ls", _fake_ls)
|
|
monkeypatch.setattr(session, "rm", _fake_rm)
|
|
|
|
await session._clear_workspace_root_on_resume() # noqa: SLF001
|
|
|
|
assert ls_calls == [Path("/workspace"), Path("/workspace/a")]
|
|
assert rm_calls == [
|
|
(Path("/workspace/a/local.txt"), True),
|
|
(Path("/workspace/root.txt"), True),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_and_write_stdin() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.stdin_output_chunks = [b">>> "]
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)
|
|
|
|
assert started.process_id is not None
|
|
assert b">>>" in started.output
|
|
|
|
sandbox.pty.stdin_output_chunks = [b"10\n"]
|
|
updated = await session.pty_write_stdin(
|
|
session_id=started.process_id,
|
|
chars="5 + 5\n",
|
|
yield_time_s=0.05,
|
|
)
|
|
|
|
assert updated.process_id == started.process_id
|
|
assert b"10" in updated.output
|
|
assert sandbox.pty.handle.stdin_payloads == [b"python3\n", b"5 + 5\n"]
|
|
|
|
await session.pty_terminate_all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_uses_commands_run_in_background() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.async_command_stdout_chunks = ["started\n"]
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("python3", shell=False, tty=False, yield_time_s=0.05)
|
|
|
|
assert started.process_id is None
|
|
assert b"started" in started.output
|
|
assert sandbox.commands.background_calls == [
|
|
{
|
|
"command": "python3",
|
|
"timeout": float(session.state.timeouts.exec_timeout_unbounded_s),
|
|
"cwd": "/workspace",
|
|
"envs": {},
|
|
"stdin": False,
|
|
"background": True,
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_wakes_when_exit_follows_last_output() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BAsyncCommandHandle(wait_delay_s=0.01)
|
|
sandbox.commands.next_async_command_handle = handle
|
|
sandbox.commands.async_command_stdout_chunks = ["started\n"]
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await asyncio.wait_for(
|
|
session.pty_exec_start("python3", shell=False, tty=False, yield_time_s=10),
|
|
timeout=1,
|
|
)
|
|
|
|
assert started.process_id is None
|
|
assert started.exit_code == 0
|
|
assert started.output == b"started\n"
|
|
assert handle.wait_calls == 1
|
|
assert handle.kill_calls == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_tty_wakes_when_session_exits_after_output() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BPtyHandle(wait_never=False, wait_delay_s=0.01)
|
|
sandbox.pty.handle = handle
|
|
sandbox.pty.stdin_output_chunks = [b"bye\n"]
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await asyncio.wait_for(
|
|
session.pty_exec_start("exit", shell=False, tty=True, yield_time_s=10),
|
|
timeout=1,
|
|
)
|
|
|
|
assert started.process_id is None
|
|
assert started.exit_code == 0
|
|
assert started.output == b"bye\n"
|
|
assert handle.stdin_payloads == [b"exit\n"]
|
|
assert handle.wait_calls == 1
|
|
assert handle.kill_calls == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_wakes_on_quiet_exit() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BAsyncCommandHandle(wait_delay_s=0.01)
|
|
sandbox.commands.next_async_command_handle = handle
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await asyncio.wait_for(
|
|
session.pty_exec_start("true", shell=False, tty=False, yield_time_s=10),
|
|
timeout=1,
|
|
)
|
|
|
|
assert started.process_id is None
|
|
assert started.exit_code == 0
|
|
assert started.output == b""
|
|
assert handle.wait_calls == 1
|
|
assert handle.kill_calls == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_wakes_on_nonzero_wait_exit() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BAsyncCommandHandle(
|
|
wait_delay_s=0.01,
|
|
wait_error=_FakeE2BCommandExitException(exit_code=2),
|
|
)
|
|
sandbox.commands.next_async_command_handle = handle
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await asyncio.wait_for(
|
|
session.pty_exec_start("false", shell=False, tty=False, yield_time_s=10),
|
|
timeout=1,
|
|
)
|
|
|
|
assert started.process_id is None
|
|
assert started.exit_code == 2
|
|
assert started.output == b""
|
|
assert handle.wait_calls == 1
|
|
assert handle.kill_calls == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_exited_command_preserves_waiter() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BAsyncCommandHandle(initial_exit_code=0, wait_until_released=True)
|
|
sandbox.commands.next_async_command_handle = handle
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await asyncio.wait_for(
|
|
session.pty_exec_start("true", shell=False, tty=False, yield_time_s=10),
|
|
timeout=1,
|
|
)
|
|
|
|
assert started.process_id is None
|
|
assert started.exit_code == 0
|
|
assert started.output == b""
|
|
assert handle.kill_calls == 0
|
|
|
|
for _ in range(10):
|
|
if handle.wait_calls:
|
|
break
|
|
await asyncio.sleep(0)
|
|
|
|
assert handle.wait_calls == 1
|
|
assert not handle.wait_cancelled
|
|
|
|
handle.release_wait()
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_running_command_cleans_up_waiter() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
handle = _FakeE2BAsyncCommandHandle(wait_never=True)
|
|
sandbox.commands.next_async_command_handle = handle
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("sleep", "60", shell=False, tty=False, yield_time_s=0.01)
|
|
|
|
assert started.process_id is not None
|
|
assert started.exit_code is None
|
|
assert handle.wait_calls == 1
|
|
assert handle.kill_calls == 0
|
|
|
|
await session.pty_terminate_all()
|
|
|
|
assert handle.wait_cancelled
|
|
assert handle.kill_calls == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_non_tty_wraps_background_run_failures() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.commands.background_error = RuntimeError("background failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=False)
|
|
|
|
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
|
assert str(exc_info.value.__cause__) == "background failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_stop_terminates_live_pty_sessions() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)
|
|
assert started.process_id is not None
|
|
|
|
await session.stop()
|
|
|
|
assert sandbox.pty.handle.exit_code == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_shutdown_logs_pause_failure_and_falls_back_to_kill(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pause_error = RuntimeError("pause failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
pause_on_exit=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
caplog.set_level(logging.WARNING, logger=e2b_module.__name__)
|
|
|
|
await session.shutdown()
|
|
|
|
assert sandbox.pause_calls == 1
|
|
assert sandbox.kill_calls == 1
|
|
assert "Failed to pause E2B sandbox on shutdown; falling back to kill." in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_shutdown_logs_kill_failure_after_pause_fallback(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pause_error = RuntimeError("pause failed")
|
|
sandbox.kill_error = RuntimeError("kill failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
pause_on_exit=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
caplog.set_level(logging.WARNING, logger=e2b_module.__name__)
|
|
|
|
await session.shutdown()
|
|
|
|
assert sandbox.pause_calls == 1
|
|
assert sandbox.kill_calls == 1
|
|
assert "Failed to kill E2B sandbox after pause fallback failure." in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_shutdown_logs_direct_kill_failure(caplog: pytest.LogCaptureFixture) -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.kill_error = RuntimeError("kill failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
pause_on_exit=False,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
caplog.set_level(logging.WARNING, logger=e2b_module.__name__)
|
|
|
|
await session.shutdown()
|
|
|
|
assert sandbox.pause_calls == 0
|
|
assert sandbox.kill_calls == 1
|
|
assert "Failed to kill E2B sandbox on shutdown." in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_wraps_startup_failures() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.create_error = FileNotFoundError("missing-shell")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError):
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_cleans_up_partially_created_session_on_failure() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.send_stdin_error = RuntimeError("send failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError):
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
|
|
assert sandbox.pty.handle.exit_code == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_cleans_up_partially_created_session_on_cancellation() -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.send_stdin_error = asyncio.CancelledError()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
|
|
assert sandbox.pty.handle.exit_code == 0
|
|
assert session._pty_processes == {} # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_maps_timeout_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
timeout_error_types = e2b_module._e2b_timeout_error_types()
|
|
if timeout_error_types:
|
|
timeout_exc = timeout_error_types[0]
|
|
else:
|
|
|
|
class _FakeTimeout(Exception):
|
|
pass
|
|
|
|
timeout_exc = _FakeTimeout
|
|
monkeypatch.setattr(
|
|
e2b_module,
|
|
"_e2b_timeout_error_types",
|
|
lambda: (_FakeTimeout,),
|
|
)
|
|
sandbox.pty.create_error = timeout_exc("timed out")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTimeoutError):
|
|
await session.pty_exec_start("python3", shell=False, tty=True, timeout=2.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_timeout_preserves_provider_details(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _FakeTimeout(Exception):
|
|
def __init__(self) -> None:
|
|
super().__init__("context deadline exceeded")
|
|
self.stderr = "chrome stderr"
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module,
|
|
"_e2b_timeout_error_types",
|
|
lambda: (_FakeTimeout,),
|
|
)
|
|
|
|
async def _raise_timeout(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeTimeout()
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_timeout)
|
|
|
|
with pytest.raises(ExecTimeoutError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert exc_info.value.context["provider_error"] == "context deadline exceeded"
|
|
assert exc_info.value.context["stderr"] == "chrome stderr"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_maps_httpcore_read_timeout_to_timeout_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class ReadTimeout(Exception):
|
|
pass
|
|
|
|
ReadTimeout.__module__ = "httpcore"
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
async def _raise_timeout(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise ReadTimeout()
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_timeout)
|
|
|
|
with pytest.raises(ExecTimeoutError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert exc_info.value.context["reason"] == "stream_read_timeout"
|
|
assert exc_info.value.context["provider_error"] == "ReadTimeout"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_maps_missing_sandbox_not_found_to_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _FakeNotFound(Exception):
|
|
pass
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module,
|
|
"_e2b_non_retryable_error_types",
|
|
lambda: (_FakeNotFound,),
|
|
)
|
|
monkeypatch.setattr(e2b_module, "_e2b_retryable_error_types", lambda: ())
|
|
monkeypatch.setattr(e2b_module, "_e2b_timeout_error_types", lambda: ())
|
|
|
|
async def _raise_not_found(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeNotFound("The sandbox was not found: request failed")
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_not_found)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert exc_info.value.context["provider_error"] == "The sandbox was not found: request failed"
|
|
assert exc_info.value.context["reason"] == "_FakeNotFound"
|
|
assert exc_info.value.retryable is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_marks_rate_limit_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _FakeRateLimit(Exception):
|
|
pass
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
monkeypatch.setattr(e2b_module, "_e2b_retryable_error_types", lambda: (_FakeRateLimit,))
|
|
monkeypatch.setattr(e2b_module, "_e2b_non_retryable_error_types", lambda: ())
|
|
monkeypatch.setattr(e2b_module, "_e2b_timeout_error_types", lambda: ())
|
|
|
|
async def _raise_rate_limit(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeRateLimit("rate limit exceeded")
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_rate_limit)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert exc_info.value.context["provider_error"] == "rate limit exceeded"
|
|
assert exc_info.value.context["reason"] == "_FakeRateLimit"
|
|
assert exc_info.value.retryable is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_marks_deterministic_provider_errors_non_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _FakeGitAuth(Exception):
|
|
pass
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
monkeypatch.setattr(e2b_module, "_e2b_retryable_error_types", lambda: ())
|
|
monkeypatch.setattr(e2b_module, "_e2b_non_retryable_error_types", lambda: (_FakeGitAuth,))
|
|
monkeypatch.setattr(e2b_module, "_e2b_timeout_error_types", lambda: ())
|
|
|
|
async def _raise_git_auth(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise _FakeGitAuth("git authentication failed")
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_git_auth)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert exc_info.value.context["provider_error"] == "git authentication failed"
|
|
assert exc_info.value.context["reason"] == "_FakeGitAuth"
|
|
assert exc_info.value.retryable is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_exec_transport_preserves_provider_details(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
sandbox = _FakeE2BSandbox()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
async def _raise_transport(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise RuntimeError("connection closed while reading HTTP status line")
|
|
|
|
monkeypatch.setattr(e2b_module, "_sandbox_run_command", _raise_transport)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session._exec_internal("python3", "build.py", timeout=2.0) # noqa: SLF001
|
|
|
|
assert (
|
|
exc_info.value.context["provider_error"]
|
|
== "connection closed while reading HTTP status line"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_maps_httpcore_read_timeout_to_timeout_error() -> None:
|
|
class ReadTimeout(Exception):
|
|
pass
|
|
|
|
ReadTimeout.__module__ = "httpcore"
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.create_error = ReadTimeout()
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTimeoutError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=True, timeout=2.0)
|
|
|
|
assert exc_info.value.context["reason"] == "stream_read_timeout"
|
|
assert exc_info.value.context["provider_error"] == "ReadTimeout"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_e2b_pty_start_maps_missing_sandbox_not_found_to_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class _FakeNotFound(Exception):
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
e2b_module,
|
|
"_e2b_non_retryable_error_types",
|
|
lambda: (_FakeNotFound,),
|
|
)
|
|
monkeypatch.setattr(e2b_module, "_e2b_retryable_error_types", lambda: ())
|
|
monkeypatch.setattr(e2b_module, "_e2b_timeout_error_types", lambda: ())
|
|
|
|
sandbox = _FakeE2BSandbox()
|
|
sandbox.pty.create_error = _FakeNotFound("The sandbox was not found: request failed")
|
|
state = E2BSandboxSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_root_ready=True,
|
|
)
|
|
session = E2BSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=True, timeout=2.0)
|
|
|
|
assert exc_info.value.context["provider_error"] == "The sandbox was not found: request failed"
|
|
assert exc_info.value.context["reason"] == "_FakeNotFound"
|
|
assert exc_info.value.retryable is False
|