Files
openai--openai-agents-python/tests/extensions/sandbox/test_vercel.py
T
2026-07-13 12:39:17 +08:00

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"