1556 lines
58 KiB
Python
1556 lines
58 KiB
Python
from __future__ import annotations
|
|
|
|
import builtins
|
|
import importlib
|
|
import io
|
|
import sys
|
|
import tarfile
|
|
import types
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import BaseModel, PrivateAttr
|
|
|
|
from agents.sandbox import Manifest, SandboxPathGrant
|
|
from agents.sandbox.entries import File, InContainerMountStrategy, Mount, MountpointMountPattern
|
|
from agents.sandbox.entries.mounts.base import InContainerMountAdapter
|
|
from agents.sandbox.errors import ConfigurationError, InvalidManifestPathError
|
|
from agents.sandbox.manifest import Environment
|
|
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.snapshot import NoopSnapshot, SnapshotBase
|
|
from agents.sandbox.types import User
|
|
from tests._fake_workspace_paths import resolve_fake_workspace_path
|
|
|
|
|
|
class _FakeNetworkPolicyRule(BaseModel):
|
|
pass
|
|
|
|
|
|
class _FakeNetworkPolicySubnets(BaseModel):
|
|
allow: list[str] | None = None
|
|
deny: list[str] | None = None
|
|
|
|
|
|
class _FakeNetworkPolicyCustom(BaseModel):
|
|
allow: dict[str, list[_FakeNetworkPolicyRule]] | list[str] | None = None
|
|
subnets: _FakeNetworkPolicySubnets | None = None
|
|
|
|
|
|
NetworkPolicy = _FakeNetworkPolicyCustom
|
|
NetworkPolicyCustom = _FakeNetworkPolicyCustom
|
|
NetworkPolicyRule = _FakeNetworkPolicyRule
|
|
NetworkPolicySubnets = _FakeNetworkPolicySubnets
|
|
|
|
|
|
class Resources(BaseModel):
|
|
memory: int | None = None
|
|
|
|
|
|
class SnapshotSource(BaseModel):
|
|
type: Literal["snapshot"] = "snapshot"
|
|
snapshot_id: str
|
|
|
|
|
|
class _FakeVercelSandboxError(Exception):
|
|
pass
|
|
|
|
|
|
class _FakeVercelAPIError(_FakeVercelSandboxError):
|
|
def __init__(self, message: str, *, status_code: int, data: object | None = None) -> None:
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.response = types.SimpleNamespace(status_code=status_code)
|
|
self.data = data
|
|
|
|
|
|
class _FakeVercelSandboxAuthError(_FakeVercelAPIError):
|
|
def __init__(self, message: str = "auth failed", *, data: object | None = None) -> None:
|
|
super().__init__(message, status_code=401, data=data)
|
|
|
|
|
|
class _FakeVercelSandboxNotFoundError(_FakeVercelAPIError):
|
|
def __init__(self, message: str = "not found", *, data: object | None = None) -> None:
|
|
super().__init__(message, status_code=404, data=data)
|
|
|
|
|
|
class _FakeVercelSandboxPermissionError(_FakeVercelAPIError):
|
|
def __init__(self, message: str = "permission denied", *, data: object | None = None) -> None:
|
|
super().__init__(message, status_code=403, data=data)
|
|
|
|
|
|
class _FakeVercelSandboxRateLimitError(_FakeVercelAPIError):
|
|
def __init__(self, message: str = "rate limited", *, data: object | None = None) -> None:
|
|
super().__init__(message, status_code=429, data=data)
|
|
|
|
|
|
class _FakeVercelSandboxServerError(_FakeVercelAPIError):
|
|
def __init__(self, message: str = "server error", *, data: object | None = None) -> None:
|
|
super().__init__(message, status_code=500, data=data)
|
|
|
|
|
|
class _FakeVercelSandboxValidationError(_FakeVercelSandboxError):
|
|
def __init__(self, message: str = "validation failed") -> None:
|
|
super().__init__(message)
|
|
|
|
|
|
class _MemorySnapshot(SnapshotBase):
|
|
type: Literal["test-vercel-memory"] = "test-vercel-memory"
|
|
payload: bytes = b""
|
|
is_restorable: bool = False
|
|
|
|
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
|
_ = dependencies
|
|
raw = data.read()
|
|
if isinstance(raw, str):
|
|
raw = raw.encode("utf-8")
|
|
assert isinstance(raw, bytes | bytearray)
|
|
object.__setattr__(self, "payload", bytes(raw))
|
|
object.__setattr__(self, "is_restorable", True)
|
|
|
|
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 self.is_restorable
|
|
|
|
|
|
class _FakeCommandFinished:
|
|
def __init__(self, *, stdout: str = "", stderr: str = "", exit_code: int = 0) -> None:
|
|
self._stdout = stdout
|
|
self._stderr = stderr
|
|
self.exit_code = exit_code
|
|
|
|
async def stdout(self) -> str:
|
|
return self._stdout
|
|
|
|
async def stderr(self) -> str:
|
|
return self._stderr
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
|
|
async def aclose(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FakeAsyncSnapshot:
|
|
def __init__(self, snapshot_id: str) -> None:
|
|
self.snapshot_id = snapshot_id
|
|
|
|
|
|
class _FakeAsyncSandbox:
|
|
create_calls: list[dict[str, object]] = []
|
|
get_calls: list[dict[str, object]] = []
|
|
snapshot_counter = 0
|
|
sandboxes: dict[str, _FakeAsyncSandbox] = {}
|
|
snapshots: dict[str, dict[str, bytes]] = {}
|
|
fail_get_ids: set[str] = set()
|
|
create_failures: list[BaseException] = []
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
sandbox_id: str,
|
|
status: str = "running",
|
|
routes: list[dict[str, object]] | None = None,
|
|
files: dict[str, bytes] | None = None,
|
|
) -> None:
|
|
self.sandbox_id = sandbox_id
|
|
self.status = status
|
|
self.routes = routes or [{"port": 3000, "url": "https://3000-sandbox.vercel.run"}]
|
|
self.files = dict(files or {})
|
|
self.client = _FakeClient()
|
|
self.next_command_result = _FakeCommandFinished()
|
|
self.run_command_calls: list[tuple[str, list[str], str | None]] = []
|
|
self.refresh_calls = 0
|
|
self.read_file_calls: list[tuple[str, str | None]] = []
|
|
self.stop_calls = 0
|
|
self.wait_for_status_calls: list[tuple[object, float | None]] = []
|
|
self.wait_for_status_error: BaseException | None = None
|
|
self.write_failures: list[BaseException] = []
|
|
self.write_files_calls: list[list[dict[str, object]]] = []
|
|
self.tar_create_result: _FakeCommandFinished | None = None
|
|
self.tar_extract_result: _FakeCommandFinished | None = None
|
|
self.symlinks: dict[str, str] = {}
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
cls.create_calls = []
|
|
cls.get_calls = []
|
|
cls.snapshot_counter = 0
|
|
cls.sandboxes = {}
|
|
cls.snapshots = {}
|
|
cls.fail_get_ids = set()
|
|
cls.create_failures = []
|
|
|
|
@classmethod
|
|
async def create(cls, **kwargs: object) -> _FakeAsyncSandbox:
|
|
cls.create_calls.append(dict(kwargs))
|
|
if cls.create_failures:
|
|
raise cls.create_failures.pop(0)
|
|
source = kwargs.get("source")
|
|
sandbox_id = f"vercel-sandbox-{len(cls.create_calls)}"
|
|
files: dict[str, bytes] = {}
|
|
snapshot_id = getattr(source, "snapshot_id", None)
|
|
if getattr(source, "type", None) == "snapshot" and isinstance(snapshot_id, str):
|
|
files = dict(cls.snapshots.get(snapshot_id, {}))
|
|
ports = cast(list[int] | None, kwargs.get("ports"))
|
|
sandbox = cls(
|
|
sandbox_id=sandbox_id,
|
|
routes=[
|
|
{"port": port, "url": f"https://{port}-sandbox.vercel.run"}
|
|
for port in (ports or [3000])
|
|
],
|
|
files=files,
|
|
)
|
|
cls.sandboxes[sandbox_id] = sandbox
|
|
return sandbox
|
|
|
|
@classmethod
|
|
async def get(cls, **kwargs: object) -> _FakeAsyncSandbox:
|
|
cls.get_calls.append(dict(kwargs))
|
|
sandbox_id = kwargs["sandbox_id"]
|
|
assert isinstance(sandbox_id, str)
|
|
if sandbox_id in cls.fail_get_ids:
|
|
raise RuntimeError("sandbox missing")
|
|
sandbox = cls.sandboxes.get(sandbox_id)
|
|
if sandbox is None:
|
|
raise RuntimeError("sandbox missing")
|
|
return sandbox
|
|
|
|
async def refresh(self) -> None:
|
|
self.refresh_calls += 1
|
|
|
|
async def wait_for_status(self, status: object, timeout: float | None = None) -> None:
|
|
self.wait_for_status_calls.append((status, timeout))
|
|
if self.wait_for_status_error is not None:
|
|
raise self.wait_for_status_error
|
|
self.status = str(status)
|
|
|
|
def domain(self, port: int) -> str:
|
|
for route in self.routes:
|
|
if route.get("port") == port:
|
|
return str(route["url"])
|
|
raise ValueError("missing route")
|
|
|
|
async def run_command(
|
|
self,
|
|
cmd: str,
|
|
args: list[str] | None = None,
|
|
*,
|
|
cwd: str | None = None,
|
|
env: dict[str, str] | None = None,
|
|
sudo: bool = False,
|
|
) -> _FakeCommandFinished:
|
|
_ = (env, sudo)
|
|
args = args or []
|
|
self.run_command_calls.append((cmd, list(args), cwd))
|
|
resolved = resolve_fake_workspace_path(
|
|
(cmd, *args),
|
|
symlinks=self.symlinks,
|
|
home_dir="/workspace",
|
|
)
|
|
if resolved is not None:
|
|
return _FakeCommandFinished(
|
|
exit_code=resolved.exit_code,
|
|
stdout=resolved.stdout,
|
|
stderr=resolved.stderr,
|
|
)
|
|
if cmd == "tar" and len(args) >= 3 and args[0] == "cf":
|
|
if self.tar_create_result is not None:
|
|
return self.tar_create_result
|
|
archive_path = args[1]
|
|
assert cwd is not None
|
|
include_root = args[-1] == "."
|
|
exclusions = {
|
|
argument.removeprefix("--exclude=./")
|
|
for argument in args[2:-1]
|
|
if argument.startswith("--exclude=./")
|
|
}
|
|
buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
for path, content in sorted(self.files.items()):
|
|
if not path.startswith(cwd.rstrip("/") + "/"):
|
|
continue
|
|
rel_path = path[len(cwd.rstrip("/")) + 1 :]
|
|
if any(
|
|
rel_path == exclusion or rel_path.startswith(f"{exclusion}/")
|
|
for exclusion in exclusions
|
|
):
|
|
continue
|
|
info = tarfile.TarInfo(name=rel_path if include_root else path)
|
|
info.size = len(content)
|
|
archive.addfile(info, io.BytesIO(content))
|
|
self.files[archive_path] = buffer.getvalue()
|
|
return _FakeCommandFinished()
|
|
if cmd == "tar" and len(args) >= 4 and args[0] == "xf":
|
|
if self.tar_extract_result is not None:
|
|
return self.tar_extract_result
|
|
archive_path = args[1]
|
|
destination = args[3]
|
|
raw = self.files[archive_path]
|
|
with tarfile.open(fileobj=io.BytesIO(raw), mode="r") as archive:
|
|
for member in archive.getmembers():
|
|
if not member.isfile():
|
|
continue
|
|
extracted = archive.extractfile(member)
|
|
assert extracted is not None
|
|
self.files[f"{destination.rstrip('/')}/{member.name}"] = extracted.read()
|
|
return _FakeCommandFinished()
|
|
if cmd == "rm" and args:
|
|
target = args[-1]
|
|
self.files.pop(target, None)
|
|
return _FakeCommandFinished()
|
|
return self.next_command_result
|
|
|
|
async def read_file(self, path: str, *, cwd: str | None = None) -> bytes | None:
|
|
self.read_file_calls.append((path, cwd))
|
|
resolved = path if path.startswith("/") or cwd is None else f"{cwd.rstrip('/')}/{path}"
|
|
return self.files.get(resolved)
|
|
|
|
async def write_files(self, files: list[dict[str, object]]) -> None:
|
|
self.write_files_calls.append(files)
|
|
if self.write_failures:
|
|
raise self.write_failures.pop(0)
|
|
for file in files:
|
|
self.files[str(file["path"])] = bytes(cast(bytes, file["content"]))
|
|
|
|
async def stop(
|
|
self, *, blocking: bool = False, timeout: float = 30.0, poll_interval: float = 0.5
|
|
) -> None:
|
|
_ = (blocking, timeout, poll_interval)
|
|
self.stop_calls += 1
|
|
self.status = "stopped"
|
|
|
|
async def snapshot(self, *, expiration: int | None = None) -> _FakeAsyncSnapshot:
|
|
_ = expiration
|
|
type(self).snapshot_counter += 1
|
|
snapshot_id = f"vercel-snapshot-{type(self).snapshot_counter}"
|
|
type(self).snapshots[snapshot_id] = dict(self.files)
|
|
self.status = "stopped"
|
|
return _FakeAsyncSnapshot(snapshot_id)
|
|
|
|
|
|
class _RecordingMount(Mount):
|
|
type: str = "test_vercel_recording_mount"
|
|
bucket: str = "bucket"
|
|
_events: list[tuple[str, str]] = PrivateAttr(default_factory=list)
|
|
|
|
def supported_in_container_patterns(
|
|
self,
|
|
) -> tuple[builtins.type[MountpointMountPattern], ...]:
|
|
return (MountpointMountPattern,)
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
super().validate(strategy)
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
_ = (strategy, session, dest, base_dir)
|
|
return []
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, dest, base_dir)
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = strategy
|
|
mount._events.append(("unmount", path.as_posix()))
|
|
sandbox = cast(Any, session)._sandbox
|
|
if sandbox is not None:
|
|
sandbox.files.pop(f"{path.as_posix()}/mounted.txt", None)
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = strategy
|
|
mount._events.append(("mount", path.as_posix()))
|
|
sandbox = cast(Any, session)._sandbox
|
|
if sandbox is not None:
|
|
sandbox.files[f"{path.as_posix()}/mounted.txt"] = b"mounted-content"
|
|
|
|
return _Adapter(self)
|
|
|
|
|
|
def _load_vercel_module(monkeypatch: pytest.MonkeyPatch) -> Any:
|
|
_FakeAsyncSandbox.reset()
|
|
|
|
fake_vercel = types.ModuleType("vercel")
|
|
fake_vercel_sandbox = cast(Any, types.ModuleType("vercel.sandbox"))
|
|
fake_vercel_sandbox.AsyncSandbox = _FakeAsyncSandbox
|
|
fake_vercel_sandbox.NetworkPolicy = NetworkPolicy
|
|
fake_vercel_sandbox.NetworkPolicyCustom = NetworkPolicyCustom
|
|
fake_vercel_sandbox.NetworkPolicyRule = NetworkPolicyRule
|
|
fake_vercel_sandbox.NetworkPolicySubnets = NetworkPolicySubnets
|
|
fake_vercel_sandbox.Resources = Resources
|
|
fake_vercel_sandbox.SandboxAuthError = _FakeVercelSandboxAuthError
|
|
fake_vercel_sandbox.SandboxNotFoundError = _FakeVercelSandboxNotFoundError
|
|
fake_vercel_sandbox.SandboxPermissionError = _FakeVercelSandboxPermissionError
|
|
fake_vercel_sandbox.SandboxRateLimitError = _FakeVercelSandboxRateLimitError
|
|
fake_vercel_sandbox.SandboxServerError = _FakeVercelSandboxServerError
|
|
fake_vercel_sandbox.SandboxStatus = types.SimpleNamespace(RUNNING="running")
|
|
fake_vercel_sandbox.SandboxValidationError = _FakeVercelSandboxValidationError
|
|
fake_vercel_sandbox.SnapshotSource = SnapshotSource
|
|
cast(Any, fake_vercel).sandbox = fake_vercel_sandbox
|
|
|
|
monkeypatch.setitem(sys.modules, "vercel", fake_vercel)
|
|
monkeypatch.setitem(sys.modules, "vercel.sandbox", fake_vercel_sandbox)
|
|
sys.modules.pop("agents.extensions.sandbox.vercel.sandbox", None)
|
|
sys.modules.pop("agents.extensions.sandbox.vercel", None)
|
|
|
|
return importlib.import_module("agents.extensions.sandbox.vercel.sandbox")
|
|
|
|
|
|
async def _noop_sleep(*_args: object, **_kwargs: object) -> None:
|
|
return None
|
|
|
|
|
|
def test_vercel_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
package_module = importlib.import_module("agents.extensions.sandbox.vercel")
|
|
|
|
assert package_module.VercelSandboxClient is vercel_module.VercelSandboxClient
|
|
assert package_module.VercelSandboxSessionState is vercel_module.VercelSandboxSessionState
|
|
|
|
|
|
def test_vercel_supports_pty_is_disabled_until_provider_methods_exist(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
noninteractive = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000000",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-noninteractive",
|
|
interactive=False,
|
|
)
|
|
interactive = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000001",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-interactive",
|
|
interactive=True,
|
|
)
|
|
|
|
assert not vercel_module.VercelSandboxSession.from_state(noninteractive).supports_pty()
|
|
assert not vercel_module.VercelSandboxSession.from_state(interactive).supports_pty()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_passes_provider_options(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
network_policy = NetworkPolicyCustom(
|
|
allow={
|
|
"api.openai.com": [NetworkPolicyRule()],
|
|
},
|
|
subnets=NetworkPolicySubnets(allow=["10.0.0.0/8"]),
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient(token="token")
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
environment=Environment(value={"FLAG": "manifest", "FROM_MANIFEST": "1"})
|
|
),
|
|
options=vercel_module.VercelSandboxClientOptions(
|
|
project_id="project",
|
|
team_id="team",
|
|
timeout_ms=12_000,
|
|
runtime="node22",
|
|
resources={"memory": 1024},
|
|
env={"FLAG": "options", "HELLO": "world"},
|
|
exposed_ports=(3000, 4000),
|
|
interactive=True,
|
|
network_policy=network_policy,
|
|
),
|
|
)
|
|
|
|
assert _FakeAsyncSandbox.create_calls == [
|
|
{
|
|
"source": None,
|
|
"ports": [3000, 4000],
|
|
"timeout": 12_000,
|
|
"resources": Resources(memory=1024),
|
|
"runtime": "node22",
|
|
"token": "token",
|
|
"project_id": "project",
|
|
"team_id": "team",
|
|
"interactive": True,
|
|
"env": {"FLAG": "manifest", "HELLO": "world", "FROM_MANIFEST": "1"},
|
|
"network_policy": network_policy,
|
|
}
|
|
]
|
|
assert _FakeAsyncSandbox.sandboxes["vercel-sandbox-1"].wait_for_status_calls == [
|
|
("running", vercel_module.DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S)
|
|
]
|
|
assert session._inner.state.sandbox_id == "vercel-sandbox-1"
|
|
assert session._inner.state.manifest.root == vercel_module.DEFAULT_VERCEL_WORKSPACE_ROOT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_retries_transient_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
monkeypatch.setattr("agents.sandbox.util.retry.asyncio.sleep", _noop_sleep)
|
|
_FakeAsyncSandbox.create_failures = [httpx.ReadError("read failed")]
|
|
|
|
client = vercel_module.VercelSandboxClient(token="token")
|
|
session = await client.create(
|
|
manifest=Manifest(),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
assert len(_FakeAsyncSandbox.create_calls) == 2
|
|
assert _FakeAsyncSandbox.sandboxes[session._inner.state.sandbox_id].wait_for_status_calls == [
|
|
("running", vercel_module.DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_does_not_retry_non_transient_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
monkeypatch.setattr("agents.sandbox.util.retry.asyncio.sleep", _noop_sleep)
|
|
|
|
class _BadRequestError(Exception):
|
|
status_code = 400
|
|
|
|
_FakeAsyncSandbox.create_failures = [_BadRequestError("bad request")]
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
with pytest.raises(_BadRequestError):
|
|
await client.create(
|
|
manifest=Manifest(),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
assert len(_FakeAsyncSandbox.create_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_exec_read_write_and_port_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
snapshot = NoopSnapshot(id="snapshot")
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000001",
|
|
manifest=Manifest(),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-existing",
|
|
exposed_ports=(3000,),
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-existing")
|
|
sandbox.next_command_result = _FakeCommandFinished(stdout="hello\n", stderr="", exit_code=0)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.write(Path("notes.txt"), io.BytesIO(b"payload"))
|
|
result = await session.exec("printf", "hello", shell=False)
|
|
endpoint = await session.resolve_exposed_port(3000)
|
|
payload = await session.read(Path("notes.txt"))
|
|
|
|
assert result.ok()
|
|
assert result.stdout == b"hello\n"
|
|
assert endpoint == vercel_module.ExposedPortEndpoint(
|
|
host="3000-sandbox.vercel.run",
|
|
port=443,
|
|
tls=True,
|
|
)
|
|
assert payload.read() == b"payload"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_exec_marks_typed_not_found_non_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000120",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-exec-missing",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-exec-missing")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
async def _raise_not_found(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise vercel_module.vercel_sandbox.SandboxNotFoundError("sandbox missing")
|
|
|
|
monkeypatch.setattr(sandbox, "run_command", _raise_not_found)
|
|
|
|
with pytest.raises(vercel_module.ExecTransportError) as exc_info:
|
|
await session.exec("pwd", shell=False)
|
|
|
|
assert exc_info.value.retryable is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_exec_marks_typed_rate_limit_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000121",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-exec-rate-limit",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-exec-rate-limit")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
async def _raise_rate_limit(*args: object, **kwargs: object) -> object:
|
|
_ = (args, kwargs)
|
|
raise vercel_module.vercel_sandbox.SandboxRateLimitError("rate limited")
|
|
|
|
monkeypatch.setattr(sandbox, "run_command", _raise_rate_limit)
|
|
|
|
with pytest.raises(vercel_module.ExecTransportError) as exc_info:
|
|
await session.exec("pwd", shell=False)
|
|
|
|
assert exc_info.value.retryable is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_write_marks_typed_validation_error_non_retryable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
monkeypatch.setattr("agents.sandbox.util.retry.asyncio.sleep", _noop_sleep)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000122",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-write-validation",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-write-validation")
|
|
sandbox.write_failures = [vercel_module.vercel_sandbox.SandboxValidationError("invalid write")]
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.write(Path("hello.txt"), io.BytesIO(b"world"))
|
|
|
|
assert len(sandbox.write_files_calls) == 1
|
|
assert exc_info.value.retryable is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "expected_retryable"),
|
|
[
|
|
(400, False),
|
|
(401, False),
|
|
(403, False),
|
|
(404, False),
|
|
(408, True),
|
|
(425, True),
|
|
(422, False),
|
|
(429, True),
|
|
(500, True),
|
|
(502, True),
|
|
(503, True),
|
|
(504, True),
|
|
],
|
|
)
|
|
def test_vercel_retryability_status_table(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status: int,
|
|
expected_retryable: bool,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
class FakeStatusError(Exception):
|
|
status_code = status
|
|
|
|
assert vercel_module._vercel_provider_retryability(FakeStatusError()) is expected_retryable
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_start_uses_base_session_contract_and_materializes_workspace(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000012",
|
|
manifest=Manifest(entries={"notes.txt": File(content=b"payload")}),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-start",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-start")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.start()
|
|
payload = await session.read(Path("notes.txt"))
|
|
|
|
assert sandbox.run_command_calls[0] == ("mkdir", ["-p", "--", "/workspace"], None)
|
|
assert ("mkdir", ["-p", "/workspace"], "/workspace") in sandbox.run_command_calls
|
|
assert session.state.workspace_root_ready is True
|
|
assert payload.read() == b"payload"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_start_materializes_entries_under_literal_manifest_root(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000013",
|
|
manifest=Manifest(
|
|
root="/workspace/my app", entries={"notes.txt": File(content=b"payload")}
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-start-literal",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-start-literal")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.start()
|
|
payload = await session.read(Path("notes.txt"))
|
|
|
|
assert sandbox.run_command_calls[0] == ("mkdir", ["-p", "--", "/workspace/my app"], None)
|
|
assert ("mkdir", ["-p", "/workspace/my app"], "/workspace/my app") in sandbox.run_command_calls
|
|
assert sandbox.write_files_calls == [
|
|
[{"path": "/workspace/my app/notes.txt", "content": b"payload"}]
|
|
]
|
|
assert payload.read() == b"payload"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_start_bootstraps_arbitrary_absolute_root_before_using_it_as_cwd(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000014",
|
|
manifest=Manifest(root="/tmp/outside", entries={"notes.txt": File(content=b"payload")}),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-start-outside",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-start-outside")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.start()
|
|
payload = await session.read(Path("notes.txt"))
|
|
|
|
assert sandbox.run_command_calls[0] == ("mkdir", ["-p", "--", "/tmp/outside"], None)
|
|
assert ("mkdir", ["-p", "/tmp/outside"], "/tmp/outside") in sandbox.run_command_calls
|
|
assert payload.read() == b"payload"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_allows_manifest_root_outside_provider_workspace(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
client = vercel_module.VercelSandboxClient()
|
|
|
|
session = await client.create(
|
|
manifest=Manifest(root="/tmp/outside"),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
assert session._inner.state.manifest.root == "/tmp/outside"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_allows_manifest_root_within_provider_workspace(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
client = vercel_module.VercelSandboxClient()
|
|
|
|
session = await client.create(
|
|
manifest=Manifest(root="/vercel/sandbox/my app"),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
assert session._inner.state.manifest.root == "/vercel/sandbox/my app"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_normalize_path_rejects_workspace_escape_and_allows_absolute_in_root(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
client = vercel_module.VercelSandboxClient()
|
|
|
|
session = await client.create(
|
|
manifest=Manifest(root="/vercel/sandbox/project"),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
inner = session._inner
|
|
|
|
with pytest.raises(InvalidManifestPathError):
|
|
inner.normalize_path("../outside.txt")
|
|
with pytest.raises(InvalidManifestPathError):
|
|
inner.normalize_path("/etc/passwd")
|
|
|
|
assert inner.normalize_path("/vercel/sandbox/project/nested/file.txt") == Path(
|
|
"/vercel/sandbox/project/nested/file.txt"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_read_and_write_reject_paths_outside_workspace_root(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
client = vercel_module.VercelSandboxClient()
|
|
|
|
session = await client.create(
|
|
manifest=Manifest(root="/vercel/sandbox/project"),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
with pytest.raises(InvalidManifestPathError):
|
|
await session.read("../outside.txt")
|
|
with pytest.raises(InvalidManifestPathError):
|
|
await session.write("/etc/passwd", io.BytesIO(b"nope"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_read_rejects_workspace_symlink_to_ungranted_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000016",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-read-escape-link",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-read-escape-link")
|
|
sandbox.symlinks["/workspace/link"] = "/private"
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(InvalidManifestPathError) as exc_info:
|
|
await session.read("link/secret.txt")
|
|
|
|
assert sandbox.read_file_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_vercel_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000015",
|
|
manifest=Manifest(
|
|
root="/workspace",
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-readonly-link",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-readonly-link")
|
|
sandbox.symlinks["/workspace/link"] = "/tmp/protected"
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.write("link/out.txt", io.BytesIO(b"blocked"))
|
|
|
|
assert sandbox.write_files_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_vercel_rejects_sandbox_local_user_arguments(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
client = vercel_module.VercelSandboxClient()
|
|
|
|
session = await client.create(
|
|
manifest=Manifest(root="/vercel/sandbox/project"),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
with pytest.raises(ConfigurationError, match="does not support sandbox-local users"):
|
|
await session.exec("pwd", user="sandbox-user")
|
|
with pytest.raises(ConfigurationError, match="does not support sandbox-local users"):
|
|
await session.read("notes.txt", user=User(name="sandbox-user"))
|
|
with pytest.raises(ConfigurationError, match="does not support sandbox-local users"):
|
|
await session.write("notes.txt", io.BytesIO(b"payload"), user="sandbox-user")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_resume_reconnects_existing_running_sandbox(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing")
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000002",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncSandbox.get_calls == [
|
|
{
|
|
"sandbox_id": "sandbox-existing",
|
|
"token": None,
|
|
"project_id": None,
|
|
"team_id": None,
|
|
}
|
|
]
|
|
assert resumed._inner.state.sandbox_id == "sandbox-existing"
|
|
assert _FakeAsyncSandbox.create_calls == []
|
|
# Sandbox is already RUNNING, so wait_for_status should not be called.
|
|
assert existing.wait_for_status_calls == []
|
|
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_vercel_resume_waits_when_sandbox_pending(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status="pending")
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000200",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(state)
|
|
|
|
assert resumed._inner.state.sandbox_id == "sandbox-existing"
|
|
assert _FakeAsyncSandbox.create_calls == []
|
|
assert existing.wait_for_status_calls == [
|
|
("running", vercel_module.DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S)
|
|
]
|
|
assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"terminal_status", ["stopping", "stopped", "failed", "aborted", "snapshotting"]
|
|
)
|
|
async def test_vercel_resume_recreates_sandbox_when_cannot_reach_running(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
terminal_status: str,
|
|
) -> None:
|
|
"""A sandbox in any state that cannot transition to RUNNING must be recreated
|
|
immediately, without waiting for the wait_for_status timeout."""
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-terminal", status=terminal_status)
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000201",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(state)
|
|
|
|
assert existing.wait_for_status_calls == []
|
|
assert existing.client.closed is True
|
|
assert len(_FakeAsyncSandbox.create_calls) == 1
|
|
assert resumed._inner.state.sandbox_id != "sandbox-terminal"
|
|
assert resumed._inner.state.workspace_root_ready is False
|
|
assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_resume_falls_back_to_recreate_when_sandbox_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
_FakeAsyncSandbox.fail_get_ids.add("sandbox-missing")
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000003",
|
|
manifest=Manifest(environment=Environment(value={"FLAG": "manifest"})),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-missing",
|
|
timeout_ms=90_000,
|
|
runtime="python3.14",
|
|
env={"FLAG": "options", "BASE": "1"},
|
|
exposed_ports=(3000,),
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient(token="token")
|
|
resumed = await client.resume(state)
|
|
|
|
assert resumed._inner.state.sandbox_id == "vercel-sandbox-1"
|
|
assert resumed._inner.state.workspace_root_ready is False
|
|
assert _FakeAsyncSandbox.create_calls[0]["runtime"] == "python3.14"
|
|
assert _FakeAsyncSandbox.create_calls[0]["timeout"] == 90_000
|
|
assert _FakeAsyncSandbox.create_calls[0]["token"] == "token"
|
|
assert _FakeAsyncSandbox.create_calls[0]["env"] == {"FLAG": "manifest", "BASE": "1"}
|
|
assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_resume_recreates_sandbox_after_wait_timeout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
# Use "pending" so that the code enters the wait path (not already RUNNING).
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing", status="pending")
|
|
existing.wait_for_status_error = TimeoutError()
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000101",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(state)
|
|
|
|
assert existing.client.closed is True
|
|
assert resumed._inner.state.sandbox_id == "vercel-sandbox-1"
|
|
assert len(_FakeAsyncSandbox.create_calls) == 1
|
|
assert resumed._inner.state.workspace_root_ready is False
|
|
assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_create_does_not_read_token_or_scope_from_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("VERCEL_TOKEN", "env-token")
|
|
monkeypatch.setenv("VERCEL_PROJECT_ID", "env-project")
|
|
monkeypatch.setenv("VERCEL_TEAM_ID", "env-team")
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
session = await client.create(
|
|
manifest=Manifest(),
|
|
options=vercel_module.VercelSandboxClientOptions(),
|
|
)
|
|
|
|
assert _FakeAsyncSandbox.create_calls[-1]["token"] is None
|
|
assert _FakeAsyncSandbox.create_calls[-1]["project_id"] is None
|
|
assert _FakeAsyncSandbox.create_calls[-1]["team_id"] is None
|
|
assert session._inner.state.project_id is None
|
|
assert session._inner.state.team_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_resume_uses_client_project_and_team_fallbacks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing")
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000099",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient(project_id="client-project", team_id="client-team")
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncSandbox.get_calls[-1]["project_id"] == "client-project"
|
|
assert _FakeAsyncSandbox.get_calls[-1]["team_id"] == "client-team"
|
|
assert resumed._inner.state.project_id == "client-project"
|
|
assert resumed._inner.state.team_id == "client-team"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_resume_does_not_read_token_or_scope_from_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("VERCEL_TOKEN", "env-token")
|
|
monkeypatch.setenv("VERCEL_PROJECT_ID", "env-project")
|
|
monkeypatch.setenv("VERCEL_TEAM_ID", "env-team")
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
existing = _FakeAsyncSandbox(sandbox_id="sandbox-existing")
|
|
_FakeAsyncSandbox.sandboxes[existing.sandbox_id] = existing
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000100",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=existing.sandbox_id,
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncSandbox.get_calls[-1]["token"] is None
|
|
assert _FakeAsyncSandbox.get_calls[-1]["project_id"] is None
|
|
assert _FakeAsyncSandbox.get_calls[-1]["team_id"] is None
|
|
assert resumed._inner.state.project_id is None
|
|
assert resumed._inner.state.team_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_serialized_session_state_omits_token_and_resume_uses_live_client_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
network_policy = NetworkPolicyCustom(
|
|
allow=["example.com"],
|
|
subnets=NetworkPolicySubnets(deny=["192.168.0.0/16"]),
|
|
)
|
|
|
|
client = vercel_module.VercelSandboxClient(token="token-from-client")
|
|
session = await client.create(
|
|
manifest=Manifest(),
|
|
options=vercel_module.VercelSandboxClientOptions(
|
|
project_id="project",
|
|
network_policy=network_policy,
|
|
),
|
|
)
|
|
|
|
payload = client.serialize_session_state(session.state)
|
|
restored = client.deserialize_session_state(payload)
|
|
resumed = await client.resume(restored)
|
|
|
|
assert "token" not in payload
|
|
assert restored.project_id == "project"
|
|
assert payload["network_policy"] == {
|
|
"allow": ["example.com"],
|
|
"subnets": {"allow": None, "deny": ["192.168.0.0/16"]},
|
|
}
|
|
assert restored.network_policy == network_policy
|
|
assert _FakeAsyncSandbox.get_calls[-1]["token"] == "token-from-client"
|
|
assert resumed._inner.state.sandbox_id == session._inner.state.sandbox_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_tar_persistence_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
snapshot = _MemorySnapshot(id="snapshot")
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000004",
|
|
manifest=Manifest(),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-tar",
|
|
workspace_persistence="tar",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-tar")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.write(Path("hello.txt"), io.BytesIO(b"world"))
|
|
await session.stop()
|
|
|
|
restored_state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000005",
|
|
manifest=Manifest(),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-restored",
|
|
workspace_persistence="tar",
|
|
)
|
|
restored = vercel_module.VercelSandboxSession.from_state(
|
|
restored_state,
|
|
sandbox=_FakeAsyncSandbox(sandbox_id="sandbox-restored"),
|
|
)
|
|
await restored.hydrate_workspace(await snapshot.restore())
|
|
payload = await restored.read(Path("hello.txt"))
|
|
|
|
assert payload.read() == b"world"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_tar_persist_raises_archive_error_on_nonzero_exec(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000105",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-tar-fail",
|
|
workspace_persistence="tar",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-tar-fail")
|
|
sandbox.tar_create_result = _FakeCommandFinished(stderr="tar failed", exit_code=2)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveReadError) as exc_info:
|
|
await session.persist_workspace()
|
|
|
|
assert isinstance(exc_info.value.__cause__, vercel_module.ExecNonZeroError)
|
|
assert exc_info.value.__cause__.exit_code == 2
|
|
assert sandbox.run_command_calls[-1] == (
|
|
"rm",
|
|
["/tmp/openai-agents-00000000000000000000000000000105.tar"],
|
|
"/workspace",
|
|
)
|
|
|
|
|
|
def test_vercel_validate_tar_bytes_rejects_unsafe_members(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000103",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-tar-validate",
|
|
)
|
|
session = vercel_module.VercelSandboxSession.from_state(state)
|
|
|
|
absolute_buf = io.BytesIO()
|
|
with tarfile.open(fileobj=absolute_buf, mode="w") as archive:
|
|
info = tarfile.TarInfo(name="/etc/passwd")
|
|
info.size = 4
|
|
archive.addfile(info, io.BytesIO(b"root"))
|
|
with pytest.raises(ValueError, match="absolute path"):
|
|
session._validate_tar_bytes(absolute_buf.getvalue())
|
|
|
|
with pytest.raises(ValueError, match="invalid tar stream"):
|
|
session._validate_tar_bytes(b"not a tar file")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_hydrate_workspace_rejects_unsafe_tar_before_upload(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000104",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-hydrate-unsafe",
|
|
workspace_persistence="tar",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-hydrate-unsafe")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
unsafe_buf = io.BytesIO()
|
|
with tarfile.open(fileobj=unsafe_buf, mode="w") as archive:
|
|
info = tarfile.TarInfo(name="../escape.txt")
|
|
info.size = 4
|
|
archive.addfile(info, io.BytesIO(b"data"))
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(io.BytesIO(unsafe_buf.getvalue()))
|
|
|
|
assert "parent traversal" in str(exc_info.value.__cause__)
|
|
assert sandbox.write_files_calls == []
|
|
assert not any(
|
|
call for call in sandbox.run_command_calls if call[0] == "tar" and call[1][0] == "xf"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_hydrate_workspace_rejects_external_symlink_target_before_upload(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000105",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-hydrate-external-link",
|
|
workspace_persistence="tar",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-hydrate-external-link")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
unsafe_buf = io.BytesIO()
|
|
with tarfile.open(fileobj=unsafe_buf, mode="w") as archive:
|
|
info = tarfile.TarInfo(name="leak")
|
|
info.type = tarfile.SYMTYPE
|
|
info.linkname = "/etc/passwd"
|
|
archive.addfile(info)
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(io.BytesIO(unsafe_buf.getvalue()))
|
|
|
|
assert "absolute symlink target not allowed" in str(exc_info.value.__cause__)
|
|
assert sandbox.write_files_calls == []
|
|
assert not any(
|
|
call for call in sandbox.run_command_calls if call[0] == "tar" and call[1][0] == "xf"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_hydrate_workspace_raises_archive_error_on_nonzero_tar_exec(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000106",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-hydrate-fail",
|
|
workspace_persistence="tar",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-hydrate-fail")
|
|
sandbox.tar_extract_result = _FakeCommandFinished(stderr="extract failed", exit_code=2)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
archive = io.BytesIO()
|
|
with tarfile.open(fileobj=archive, mode="w") as tar:
|
|
info = tarfile.TarInfo(name="hello.txt")
|
|
info.size = 5
|
|
tar.addfile(info, io.BytesIO(b"hello"))
|
|
|
|
with pytest.raises(vercel_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.hydrate_workspace(io.BytesIO(archive.getvalue()))
|
|
|
|
assert isinstance(exc_info.value.__cause__, vercel_module.ExecNonZeroError)
|
|
assert exc_info.value.__cause__.exit_code == 2
|
|
assert sandbox.run_command_calls[-1] == (
|
|
"rm",
|
|
["/tmp/openai-agents-00000000000000000000000000000106.tar"],
|
|
"/workspace",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_write_retries_transient_transport_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
monkeypatch.setattr("agents.sandbox.util.retry.asyncio.sleep", _noop_sleep)
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000102",
|
|
manifest=Manifest(),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-write-retry",
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-write-retry")
|
|
sandbox.write_failures = [httpx.ProtocolError("transient write failure")]
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.write(Path("notes.txt"), io.BytesIO(b"payload"))
|
|
payload = await session.read(Path("notes.txt"))
|
|
|
|
assert payload.read() == b"payload"
|
|
assert len(sandbox.write_files_calls) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_snapshot_mode_resume_uses_native_snapshot_reference(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
snapshot = _MemorySnapshot(id="snapshot")
|
|
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000006",
|
|
manifest=Manifest(),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-snapshot",
|
|
workspace_persistence="snapshot",
|
|
snapshot_expiration_ms=60_000,
|
|
)
|
|
sandbox = _FakeAsyncSandbox(sandbox_id="sandbox-snapshot")
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.write(Path("config.json"), io.BytesIO(b'{"version":1}'))
|
|
await session.stop()
|
|
|
|
resumed_state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000007",
|
|
manifest=Manifest(),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-snapshot",
|
|
workspace_persistence="snapshot",
|
|
snapshot_expiration_ms=60_000,
|
|
)
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(resumed_state)
|
|
payload = await resumed._inner.read(Path("config.json"))
|
|
|
|
assert _FakeAsyncSandbox.create_calls[-1]["source"] == SnapshotSource(
|
|
snapshot_id="vercel-snapshot-1"
|
|
)
|
|
assert resumed._inner.state.sandbox_id == "vercel-sandbox-1"
|
|
assert payload.read() == b'{"version":1}'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_tar_persistence_tears_down_ephemeral_mounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
snapshot = _MemorySnapshot(id="snapshot")
|
|
mount = _RecordingMount(
|
|
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern())
|
|
)
|
|
sandbox = _FakeAsyncSandbox(
|
|
sandbox_id="sandbox-mount-tar",
|
|
files={
|
|
"/workspace/kept.txt": b"kept",
|
|
"/workspace/remote/mounted.txt": b"mounted-content",
|
|
},
|
|
)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000008",
|
|
manifest=Manifest(root="/workspace", entries={"remote": mount}),
|
|
snapshot=snapshot,
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_persistence="tar",
|
|
)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.stop()
|
|
|
|
with tarfile.open(fileobj=io.BytesIO(snapshot.payload), mode="r") as archive:
|
|
archived_names = sorted(member.name for member in archive.getmembers())
|
|
tar_calls = [
|
|
call for call in sandbox.run_command_calls if call[0] == "tar" and call[1][0] == "cf"
|
|
]
|
|
|
|
assert mount._events == [("unmount", "/workspace/remote"), ("mount", "/workspace/remote")]
|
|
assert tar_calls == [
|
|
(
|
|
"tar",
|
|
[
|
|
"cf",
|
|
"/tmp/openai-agents-00000000000000000000000000000008.tar",
|
|
"--exclude=./remote",
|
|
".",
|
|
],
|
|
"/workspace",
|
|
)
|
|
]
|
|
assert archived_names == ["kept.txt"]
|
|
assert sandbox.files["/workspace/remote/mounted.txt"] == b"mounted-content"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_snapshot_persistence_tears_down_ephemeral_mounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
snapshot = _MemorySnapshot(id="snapshot")
|
|
mount = _RecordingMount(
|
|
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern())
|
|
)
|
|
sandbox = _FakeAsyncSandbox(
|
|
sandbox_id="sandbox-mount-snapshot",
|
|
files={
|
|
"/workspace/kept.txt": b"kept",
|
|
"/workspace/remote/mounted.txt": b"mounted-content",
|
|
},
|
|
)
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000009",
|
|
manifest=Manifest(root="/workspace", entries={"remote": mount}),
|
|
snapshot=snapshot,
|
|
sandbox_id=sandbox.sandbox_id,
|
|
workspace_persistence="snapshot",
|
|
snapshot_expiration_ms=60_000,
|
|
)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
await session.stop()
|
|
|
|
restored_state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000010",
|
|
manifest=Manifest(root="/workspace", entries={"remote": mount}),
|
|
snapshot=snapshot,
|
|
sandbox_id="sandbox-mount-snapshot",
|
|
workspace_persistence="snapshot",
|
|
snapshot_expiration_ms=60_000,
|
|
)
|
|
client = vercel_module.VercelSandboxClient()
|
|
resumed = await client.resume(restored_state)
|
|
|
|
assert mount._events == [("unmount", "/workspace/remote"), ("mount", "/workspace/remote")]
|
|
assert "/workspace/remote/mounted.txt" not in _FakeAsyncSandbox.snapshots["vercel-snapshot-1"]
|
|
with pytest.raises(vercel_module.WorkspaceReadNotFoundError):
|
|
await resumed._inner.read(Path("remote/mounted.txt"))
|
|
kept = await resumed._inner.read(Path("kept.txt"))
|
|
assert kept.read() == b"kept"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vercel_snapshot_hydrate_replaces_and_stops_superseded_sandbox(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
vercel_module = _load_vercel_module(monkeypatch)
|
|
current = _FakeAsyncSandbox(
|
|
sandbox_id="sandbox-current",
|
|
files={"/workspace/current.txt": b"before"},
|
|
)
|
|
_FakeAsyncSandbox.snapshots["vercel-snapshot-1"] = {"/workspace/restored.txt": b"after"}
|
|
state = vercel_module.VercelSandboxSessionState(
|
|
session_id="00000000-0000-0000-0000-000000000011",
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=current.sandbox_id,
|
|
workspace_persistence="snapshot",
|
|
)
|
|
session = vercel_module.VercelSandboxSession.from_state(state, sandbox=current)
|
|
|
|
await session.hydrate_workspace(
|
|
io.BytesIO(vercel_module._encode_snapshot_ref(snapshot_id="vercel-snapshot-1"))
|
|
)
|
|
|
|
assert current.stop_calls == 1
|
|
assert current.client.closed is True
|
|
assert session._sandbox is not current
|
|
assert session.state.sandbox_id == "vercel-sandbox-1"
|
|
restored = await session.read(Path("restored.txt"))
|
|
assert restored.read() == b"after"
|