diff --git a/pyproject.toml b/pyproject.toml index 3542d3c14c..b837a69024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ e2b = [ "pygments>=2.20.0", ] modal = [ - "modal==1.4.3", + "modal==1.6.1", "aiohttp>=3.14.3,<4", "cbor2>=5.9.0", "h2>=4.4.1", diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py index b0c8880011..54d07205ea 100644 --- a/src/agents/extensions/sandbox/modal/sandbox.py +++ b/src/agents/extensions/sandbox/modal/sandbox.py @@ -50,6 +50,7 @@ SandboxError, WorkspaceArchiveReadError, WorkspaceArchiveWriteError, + WorkspaceReadNotFoundError, WorkspaceStartError, WorkspaceStopError, WorkspaceWriteTypeError, @@ -1251,30 +1252,23 @@ async def _terminate_pty_entry(self, entry: _ModalPtyProcessEntry) -> None: async def _read_bounded(self, path: Path, *, max_bytes: int) -> bytes: try: workspace_path = await self._validate_path_access(path) - await self._ensure_sandbox() - assert self._sandbox is not None - # Each read starts a remote operation. Use Modal's 100 MiB per-read - # ceiling while respecting the caller's remaining byte budget. - stream = await self._sandbox.open.aio(sandbox_path_str(workspace_path), "rb") - completed = False - try: - result = bytearray() - while len(result) < max_bytes: - chunk = await stream.read.aio(min(100 * 1024 * 1024, max_bytes - len(result))) - if not chunk: - break - result.extend(chunk) - payload = bytes(result) - completed = True - return payload - finally: - try: - await asyncio.wait_for(stream.close.aio(), timeout=5.0) - except Exception: - # Preserve an active read failure or cancellation. A close failure - # still fails a read that would otherwise have completed. - if completed: - raise + # Bound acquisition inside the sandbox, using the same exec transport as read(). + # Like read(), do not impose an exec deadline: it terminates the whole sandbox. + result = await self.exec( + "/bin/sh", + "-c", + "PATH=/usr/bin:/bin; export PATH; " + '[ -e "$1" ] || exit 44; [ -f "$1" ] || exit 45; head -c "$2" < "$1"', + "sh", + sandbox_path_str(workspace_path), + str(max_bytes), + shell=False, + ) + if result.exit_code == 44: + raise WorkspaceReadNotFoundError(path=path) + if not result.ok(): + raise WorkspaceArchiveReadError(path=path) + return result.stdout except (FileNotFoundError, SandboxError): raise except Exception as error: diff --git a/tests/sandbox/test_bounded_read.py b/tests/sandbox/test_bounded_read.py index aaf39cfeae..c0b8b4750c 100644 --- a/tests/sandbox/test_bounded_read.py +++ b/tests/sandbox/test_bounded_read.py @@ -2,6 +2,7 @@ import asyncio import base64 +import os import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -222,68 +223,108 @@ async def test_docker_bounded_read_uses_trusted_utilities_without_encoding() -> @pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="Exercises the Linux sandbox command locally.") @pytest.mark.parametrize( - "close_error", [None, RuntimeError("Close failed"), asyncio.TimeoutError()] + "kind,limit", + [("file", 1), ("file", 4), ("file", 20), ("missing", 5), ("directory", 5), ("fifo", 5)], ) -async def test_modal_bounded_read_closes_provider_descriptor(close_error: Exception | None) -> None: +async def test_modal_bounded_read_limits_source_bytes( + tmp_path: Path, kind: str, limit: int +) -> None: pytest.importorskip("modal") from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession + path = tmp_path / "quoted ' file; $(false).bin" + if kind == "file": + path.write_bytes(b"abc\x00def\xff") + elif kind == "directory": + path.mkdir() + elif kind == "fifo": + os.mkfifo(path) session = object.__new__(ModalSandboxSession) - session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl")) - session._ensure_sandbox = AsyncMock() - stream = SimpleNamespace( - read=SimpleNamespace(aio=AsyncMock(return_value=b"abc")), - close=SimpleNamespace(aio=AsyncMock(side_effect=close_error)), - ) - session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream))) - if close_error is None: - assert await session.read_bounded(Path("out.jsonl"), max_bytes=3) == b"abc" + session._validate_path_access = AsyncMock(return_value=path) + + async def execute(*command: str, shell: bool, timeout: float | None = None) -> ExecResult: + assert shell is False + assert timeout is None + process = await asyncio.create_subprocess_exec( + *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5) + finally: + if process.returncode is None: + process.kill() + await process.wait() + return ExecResult(stdout=stdout, stderr=stderr, exit_code=process.returncode or 0) + + session.exec = AsyncMock(side_effect=execute) + if kind == "file": + assert await session.read_bounded(path, max_bytes=limit) == b"abc\x00def\xff"[:limit] else: - with pytest.raises(WorkspaceArchiveReadError) as caught: - await session.read_bounded(Path("out.jsonl"), max_bytes=3) - assert caught.value.context["reason"] == "bounded_read_failed" + expected = WorkspaceReadNotFoundError if kind == "missing" else WorkspaceArchiveReadError + with pytest.raises(expected) as caught: + await session.read_bounded(path, max_bytes=limit) assert caught.value.__context__ is None - stream.read.aio.assert_awaited_once_with(3) - stream.close.aio.assert_awaited_once() @pytest.mark.asyncio -@pytest.mark.parametrize( - "limit,expected_requests", - [ - (4 * 1024 * 1024, [4 * 1024 * 1024]), - (8 * 1024 * 1024 + 1, [8 * 1024 * 1024 + 1, 4 * 1024 * 1024 + 1]), - (101 * 1024 * 1024, [100 * 1024 * 1024, 97 * 1024 * 1024]), - ], -) -async def test_modal_bounded_read_uses_large_bounded_requests( - limit: int, expected_requests: list[int] +async def test_modal_bounded_read_does_not_set_sandbox_exec_deadline( + monkeypatch: pytest.MonkeyPatch, ) -> None: + modal = pytest.importorskip("modal") + from modal.container_process import ContainerProcess + + from agents.extensions.sandbox.modal.sandbox import ( + ModalSandboxSession, + ModalSandboxSessionState, + ) + from agents.sandbox import Manifest + from agents.sandbox.snapshot import NoopSnapshot + + process = MagicMock(spec=ContainerProcess) + process.stdout.read.aio = AsyncMock(return_value=b"data") + process.stderr.read.aio = AsyncMock(return_value=b"") + process.wait.aio = AsyncMock(return_value=0) + sandbox = MagicMock(spec=modal.Sandbox) + sandbox.object_id = "sb-bounded-read" + sandbox.exec.aio = AsyncMock(return_value=process) + sandbox.terminate.aio = AsyncMock() + state = ModalSandboxSessionState( + manifest=Manifest(root="/workspace"), + snapshot=NoopSnapshot(id="bounded-read"), + app_name="bounded-read-tests", + ) + session = ModalSandboxSession(state=state, sandbox=sandbox) + session._ensure_sandbox = AsyncMock() + session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl")) + wait_for = AsyncMock(wraps=asyncio.wait_for) + monkeypatch.setattr(asyncio, "wait_for", wait_for) + + assert await session.read_bounded(Path("out.jsonl"), max_bytes=4) == b"data" + assert sandbox.exec.aio.call_args.kwargs["timeout"] is None + wait_for.assert_not_awaited() + sandbox.terminate.aio.assert_not_awaited() + assert session._sandbox is sandbox + assert state.sandbox_id == "sb-bounded-read" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exit_code", [44, 45, 1]) +async def test_modal_bounded_read_reports_remote_failure(exit_code: int) -> None: pytest.importorskip("modal") from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession session = object.__new__(ModalSandboxSession) session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl")) - session._ensure_sandbox = AsyncMock() - history = b"x" * (4 * 1024 * 1024) - remaining = history - - async def read(size: int) -> bytes: - nonlocal remaining - chunk, remaining = remaining[:size], remaining[size:] - return chunk - - stream = SimpleNamespace( - read=SimpleNamespace(aio=AsyncMock(side_effect=read)), - close=SimpleNamespace(aio=AsyncMock()), + session.exec = AsyncMock( + return_value=ExecResult(stdout=b"", stderr=b"private", exit_code=exit_code) ) - session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream))) - assert await session.read_bounded(Path("out.jsonl"), max_bytes=limit) == history - requests = [call.args[0] for call in stream.read.aio.await_args_list] - assert requests == expected_requests - assert all(size <= 100 * 1024 * 1024 for size in requests) - stream.close.aio.assert_awaited_once() + expected = WorkspaceReadNotFoundError if exit_code == 44 else WorkspaceArchiveReadError + with pytest.raises(expected) as caught: + await session.read_bounded(Path("out.jsonl"), max_bytes=5) + assert caught.value.__context__ is None + assert "private" not in str(caught.value) @pytest.mark.asyncio @@ -347,7 +388,6 @@ async def test_bounded_provider_read_preserves_retry_policy( from agents.sandbox.snapshot import NoopSnapshot content = _Content(b"synthetic-private-response") - close = AsyncMock() inner: Any if provider == "modal": modal = pytest.importorskip("modal") @@ -359,13 +399,7 @@ async def test_bounded_provider_read_preserves_retry_policy( error_cls = ( modal.exception.InternalError if retryable else modal.exception.PermissionDeniedError ) - stream = SimpleNamespace( - read=SimpleNamespace( - aio=AsyncMock(side_effect=error_cls("synthetic-private-response")) - ), - close=SimpleNamespace(aio=close), - ) - inner._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream))) + inner.exec = AsyncMock(side_effect=error_cls("synthetic-private-response")) else: inner = _session(provider, content, status=503 if retryable else 403) inner.state = SandboxSessionState( @@ -409,8 +443,6 @@ async def test_bounded_provider_read_preserves_retry_policy( if provider in {"cloudflare", "daytona", "blaxel"}: assert content.closed assert content.offset == 0 - elif provider == "modal": - close.assert_awaited_once() @pytest.mark.asyncio @@ -513,34 +545,15 @@ async def aclose(self) -> None: @pytest.mark.asyncio -@pytest.mark.parametrize( - "close_error", [None, RuntimeError("Close failed"), asyncio.TimeoutError()] -) -async def test_modal_bounded_read_cancellation_closes_descriptor( - close_error: Exception | None, -) -> None: +async def test_modal_bounded_read_propagates_cancellation() -> None: pytest.importorskip("modal") from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession - entered = asyncio.Event() - - async def read(size: int) -> bytes: - entered.set() - await asyncio.Event().wait() - return b"unreachable" - - session: Any = object.__new__(ModalSandboxSession) + session = object.__new__(ModalSandboxSession) session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl")) - session._ensure_sandbox = AsyncMock() - close = AsyncMock(side_effect=close_error) - stream = SimpleNamespace(read=SimpleNamespace(aio=read), close=SimpleNamespace(aio=close)) - session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream))) - task = asyncio.create_task(session.read_bounded(Path("out.jsonl"), max_bytes=5)) - await asyncio.wait_for(entered.wait(), timeout=1) - task.cancel() + session.exec = AsyncMock(side_effect=asyncio.CancelledError()) with pytest.raises(asyncio.CancelledError): - await task - close.assert_awaited_once() + await session.read_bounded(Path("out.jsonl"), max_bytes=5) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 74435e7ea0..c3f3b54e25 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -2265,7 +2265,7 @@ wheels = [ [[package]] name = "modal" -version = "1.4.3" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2282,9 +2282,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/4126d0fe879ef3e86002ca821a34cb68a2588ea2e8ccb2bfe421d0f42ffe/modal-1.4.3.tar.gz", hash = "sha256:35b2fc840f759b512e12527afb538e1ea4cc232b84cfbfcef3f5d96d5a66abaa", size = 720488, upload-time = "2026-05-18T22:34:45.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ba/2b36899ea5633bf101e6ba6f7e95a4da3e4b1f57f52bd0ba8f4cfd12e808/modal-1.6.1.tar.gz", hash = "sha256:ff17768f67a65595aa7882e893e6cb78e2e001b3653aa52cfe98d4051d8d9e9e", size = 942670, upload-time = "2026-10-03T16:05:05.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/54/400262056c144ceee5edab40efa2541ae8928ae5f244fd9025f3ad26c909/modal-1.4.3-py3-none-any.whl", hash = "sha256:802917181f576458a0cb833322157dab09c4f367326426c5a732661a0c519577", size = 826232, upload-time = "2026-05-18T22:34:43.335Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3c/6b7d9a15dab9af2833ce674bbc72b5654d4099d5b088f1cc1eda3f8249ca/modal-1.6.1-py3-none-any.whl", hash = "sha256:f408ef88563003a83e491a6be09d4221eaa6e038fa4faa2ad8b15b25afe949a4", size = 1063454, upload-time = "2026-10-03T16:05:02.554Z" }, ] [[package]] @@ -2889,7 +2889,7 @@ requires-dist = [ { name = "httpx2", specifier = ">=2.12.0" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.83.0" }, { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.21.2,<3" }, - { name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" }, + { name = "modal", marker = "extra == 'modal'", specifier = "==1.6.1" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=3.0.0,<4" }, { name = "protobuf", marker = "extra == 'dapr'", specifier = ">=5.29.6,!=6.30.*,!=6.31.*,!=6.32.*,!=6.33.0.*,!=6.33.1.*,!=6.33.2.*,!=6.33.3.*,!=6.33.4.*" }, @@ -4247,14 +4247,14 @@ wheels = [ [[package]] name = "synchronicity" -version = "0.12.2" +version = "0.12.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/5e/50ea27817003665c7cc4f5bdad309f13d6329037f657848ee87fe06c3740/synchronicity-0.12.2.tar.gz", hash = "sha256:6fd605a5035d1ec74ce48fffaca80ea00345c84ca34223914e2436fb4f162ff9", size = 60018, upload-time = "2026-04-06T15:06:15.447Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/5f/9f6f7df5919f0d085013b96b9cb99b622b974fe722a04c126c10497bba8b/synchronicity-0.12.6.tar.gz", hash = "sha256:ac971eadb64c95938816b8d6125d6e28473982f3256789a35bdbe02025e1ce17", size = 62425, upload-time = "2026-10-02T15:41:43.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/44/4f6ba4e2c171847e6f9a460213b196bbf26edea43d0e66889c7ccc55d368/synchronicity-0.12.2-py3-none-any.whl", hash = "sha256:9dbaca81fb7f2b57c6dea326e514e1c80e9ccfd9c9618515e84fa6091026273b", size = 41312, upload-time = "2026-04-06T15:06:14.459Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/8408129632f2cbd70018cff4a7eb78cc105b141de233b699feeda15d44c9/synchronicity-0.12.6-py3-none-any.whl", hash = "sha256:bc2bab6dde31f6bd9389912cef573b7fa7a5cb4a120ec786275e3284f06473ae", size = 41971, upload-time = "2026-10-02T15:41:42.672Z" }, ] [[package]]