Files
2026-07-13 12:39:17 +08:00

201 lines
7.0 KiB
Python

from __future__ import annotations
import base64
import io
import uuid
from pathlib import Path
from typing import cast
import pytest
from agents.sandbox import Manifest
from agents.sandbox.capabilities.tools import ViewImageTool
from agents.sandbox.errors import WorkspaceReadNotFoundError
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExecResult, User
from agents.tool import ToolOutputImage
from agents.tool_context import ToolContext
from tests.utils.factories import TestSessionState
_MAX_IMAGE_BYTES = 10 * 1024 * 1024
_PNG_BASE64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII="
)
_PNG_BYTES = base64.b64decode(_PNG_BASE64)
class _ImageSession(BaseSandboxSession):
def __init__(self, manifest: Manifest) -> None:
self.state = TestSessionState(
manifest=manifest,
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
)
self.files: dict[Path, bytes] = {}
self.read_users: list[str | None] = []
async def start(self) -> None:
return None
async def stop(self) -> None:
return None
async def shutdown(self) -> None:
return None
async def running(self) -> bool:
return True
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
self.read_users.append(user.name if isinstance(user, User) else user)
normalized = self.normalize_path(path)
if normalized not in self.files:
raise FileNotFoundError(normalized)
return io.BytesIO(self.files[normalized])
async def write(
self,
path: Path,
data: io.IOBase,
*,
user: str | User | None = None,
) -> None:
_ = user
normalized = self.normalize_path(path)
payload = data.read()
if isinstance(payload, str):
self.files[normalized] = payload.encode("utf-8")
else:
self.files[normalized] = bytes(payload)
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
_ = (command, timeout)
raise AssertionError("_exec_internal() should not be called")
async def persist_workspace(self) -> io.IOBase:
return io.BytesIO()
async def hydrate_workspace(self, data: io.IOBase) -> None:
_ = data
class _ProviderNotFoundImageSession(_ImageSession):
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
self.read_users.append(user.name if isinstance(user, User) else user)
normalized = self.normalize_path(path)
if normalized in self.files:
return io.BytesIO(self.files[normalized])
raise WorkspaceReadNotFoundError(path=normalized)
class TestViewImageTool:
def test_view_image_accepts_needs_approval_setting(self) -> None:
session = _ImageSession(Manifest(root="/workspace"))
async def needs_approval(_ctx: object, params: dict[str, object], _call_id: str) -> bool:
return str(params["path"]).startswith("sensitive/")
tool = ViewImageTool(session=session, needs_approval=needs_approval)
assert cast(object, tool.needs_approval) is needs_approval
@pytest.mark.asyncio
async def test_view_image_returns_tool_output_image_for_png(self) -> None:
session = _ImageSession(Manifest(root="/workspace"))
session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES
tool = ViewImageTool(session=session)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"images/dot.png"}',
)
assert isinstance(output, ToolOutputImage)
assert output.image_url == f"data:image/png;base64,{_PNG_BASE64}"
assert output.detail is None
@pytest.mark.asyncio
async def test_view_image_reads_as_bound_user(self) -> None:
session = _ImageSession(Manifest(root="/workspace"))
session.files[Path("/workspace/images/dot.png")] = _PNG_BYTES
tool = ViewImageTool(session=session, user=User(name="sandbox-user"))
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"images/dot.png"}',
)
assert isinstance(output, ToolOutputImage)
assert session.read_users == ["sandbox-user"]
@pytest.mark.asyncio
async def test_view_image_rejects_non_image_files(self) -> None:
session = _ImageSession(Manifest(root="/workspace"))
session.files[Path("/workspace/notes.txt")] = b"hello\n"
tool = ViewImageTool(session=session)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"notes.txt"}',
)
assert output == "image path `notes.txt` is not a supported image file"
@pytest.mark.asyncio
async def test_view_image_rejects_images_larger_than_10mb(self) -> None:
session = _ImageSession(Manifest(root="/workspace"))
session.files[Path("/workspace/images/huge.png")] = b"\x89PNG\r\n\x1a\n" + (
b"0" * (_MAX_IMAGE_BYTES + 1)
)
tool = ViewImageTool(session=session)
output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"images/huge.png"}',
)
assert output == (
"image path `images/huge.png` exceeded the allowed size of 10MB; "
"resize or compress the image and try again"
)
@pytest.mark.asyncio
async def test_view_image_rejection_text_does_not_expose_provider_path(self) -> None:
provider_root = Path("/provider/private/root")
session = _ProviderNotFoundImageSession(Manifest(root=str(provider_root)))
session.files[provider_root / "notes.txt"] = b"hello\n"
session.files[provider_root / "images/huge.png"] = b"\x89PNG\r\n\x1a\n" + (
b"0" * (_MAX_IMAGE_BYTES + 1)
)
tool = ViewImageTool(session=session)
missing_output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"images/missing.png"}',
)
non_image_output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"notes.txt"}',
)
huge_output = await tool.on_invoke_tool(
cast(ToolContext[object], None),
'{"path":"images/huge.png"}',
)
outputs = [missing_output, non_image_output, huge_output]
assert outputs == [
"image path `images/missing.png` was not found",
"image path `notes.txt` is not a supported image file",
(
"image path `images/huge.png` exceeded the allowed size of 10MB; "
"resize or compress the image and try again"
),
]
for output in outputs:
assert isinstance(output, str)
assert str(provider_root) not in output