chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from agents.sandbox import Manifest
|
||||
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 tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
class ApplyPatchSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest | None = None) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest or Manifest(root="/workspace"),
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.files: dict[Path, bytes] = {}
|
||||
self.mkdir_calls: list[tuple[Path, bool]] = []
|
||||
self.rm_calls: list[tuple[Path, bool]] = []
|
||||
|
||||
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:
|
||||
_ = 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
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
self.mkdir_calls.append((normalized, parents))
|
||||
|
||||
async def rm(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
recursive: bool = False,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
self.rm_calls.append((normalized, recursive))
|
||||
self.files.pop(normalized, None)
|
||||
|
||||
|
||||
class ProviderNotFoundApplyPatchSession(ApplyPatchSession):
|
||||
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
|
||||
try:
|
||||
return await super().read(path, user=user)
|
||||
except FileNotFoundError as exc:
|
||||
workspace_path = self.normalize_path(path).relative_to("/")
|
||||
raise WorkspaceReadNotFoundError(
|
||||
path=Path("/provider/private/root") / workspace_path
|
||||
) from exc
|
||||
|
||||
|
||||
class UserRecordingApplyPatchSession(ApplyPatchSession):
|
||||
def __init__(self, manifest: Manifest | None = None) -> None:
|
||||
super().__init__(manifest)
|
||||
self.read_users: list[str | None] = []
|
||||
self.write_users: list[str | None] = []
|
||||
self.mkdir_users: list[str | None] = []
|
||||
self.rm_users: list[str | None] = []
|
||||
|
||||
@staticmethod
|
||||
def _user_name(user: str | User | None) -> str | None:
|
||||
return user.name if isinstance(user, User) else user
|
||||
|
||||
async def read(self, path: Path, *, user: str | User | None = None) -> io.BytesIO:
|
||||
self.read_users.append(self._user_name(user))
|
||||
return await super().read(path)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
path: Path,
|
||||
data: io.IOBase,
|
||||
*,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
self.write_users.append(self._user_name(user))
|
||||
await super().write(path, data)
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
self.mkdir_users.append(self._user_name(user))
|
||||
await super().mkdir(path, parents=parents)
|
||||
|
||||
async def rm(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
recursive: bool = False,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
self.rm_users.append(self._user_name(user))
|
||||
await super().rm(path, recursive=recursive)
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import Agent, CustomTool, RunHooks
|
||||
from agents.editor import ApplyPatchOperation, ApplyPatchResult
|
||||
from agents.items import ToolApprovalItem, ToolCallOutputItem
|
||||
from agents.models.openai_responses import Converter
|
||||
from agents.run import RunConfig
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.run_internal.run_steps import ToolRunCustom
|
||||
from agents.run_internal.tool_actions import CustomToolAction
|
||||
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool
|
||||
from agents.sandbox.types import User
|
||||
from tests.sandbox._apply_patch_test_session import (
|
||||
ApplyPatchSession,
|
||||
UserRecordingApplyPatchSession,
|
||||
)
|
||||
from tests.utils.hitl import make_context_wrapper
|
||||
|
||||
|
||||
class TestSandboxApplyPatchTool:
|
||||
def test_exposes_custom_apply_patch_tool(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
assert isinstance(tool, CustomTool)
|
||||
assert tool.name == "apply_patch"
|
||||
assert tool.tool_config["type"] == "custom"
|
||||
assert tool.tool_config["name"] == "apply_patch"
|
||||
assert tool.tool_config["format"]["type"] == "grammar"
|
||||
assert tool.tool_config["format"]["syntax"] == "lark"
|
||||
|
||||
def test_converter_uses_sandbox_custom_apply_patch_tool_config(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
|
||||
converted = Converter.convert_tools([tool], handoffs=[])
|
||||
|
||||
assert converted.tools[0]["type"] == "custom"
|
||||
assert converted.tools[0]["name"] == "apply_patch"
|
||||
description = converted.tools[0]["description"]
|
||||
assert isinstance(description, str)
|
||||
assert "This is a FREEFORM tool" in description
|
||||
assert "A full patch can combine several operations" in description
|
||||
tool_format = cast(dict[str, Any], converted.tools[0]["format"])
|
||||
assert tool_format["syntax"] == "lark"
|
||||
|
||||
def test_needs_approval_exposes_operation_typed_setting(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type != "create_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=needs_approval)
|
||||
|
||||
assert cast(object, tool.needs_approval) is needs_approval
|
||||
assert cast(object, tool.operation_needs_approval) is needs_approval
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_needs_approval_assignment_drives_runtime_approval(self) -> None:
|
||||
async def needs_approval(
|
||||
_ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type == "delete_file"
|
||||
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession())
|
||||
tool.needs_approval = needs_approval
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="*** Begin Patch\n*** Delete File: notes.txt\n*** End Patch\n",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolApprovalItem)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_patch_input_surfaces_tool_error_after_approval_precheck(self) -> None:
|
||||
tool = SandboxApplyPatchTool(session=ApplyPatchSession(), needs_approval=True)
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=make_context_wrapper(),
|
||||
raw_input="not a valid patch",
|
||||
)
|
||||
|
||||
assert isinstance(result, ToolCallOutputItem)
|
||||
assert "apply_patch input must start with '*** Begin Patch'" in result.output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_create_update_delete_round_trip(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
|
||||
create_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="notes.txt",
|
||||
diff="+hello\n+world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(create_result, ApplyPatchResult)
|
||||
assert create_result.output == "Created notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
update_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="notes.txt",
|
||||
diff="@@\n-hello\n+hi\n world\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(update_result, ApplyPatchResult)
|
||||
assert update_result.output == "Updated notes.txt"
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hi\nworld"
|
||||
|
||||
delete_result = await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="notes.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
assert isinstance(delete_result, ApplyPatchResult)
|
||||
assert delete_result.output == "Deleted notes.txt"
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_runs_file_operations_as_bound_user(self) -> None:
|
||||
session = UserRecordingApplyPatchSession()
|
||||
session.files[Path("/workspace/existing.txt")] = b"old\n"
|
||||
tool = SandboxApplyPatchTool(session=session, user=User(name="sandbox-user"))
|
||||
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.update_file(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="existing.txt",
|
||||
diff="@@\n-old\n+new\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.create_file(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="created.txt",
|
||||
diff="+created\n",
|
||||
)
|
||||
),
|
||||
)
|
||||
await cast(
|
||||
Awaitable[ApplyPatchResult],
|
||||
tool.editor.delete_file(
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="existing.txt",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert session.read_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.mkdir_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user", "sandbox-user"]
|
||||
assert session.rm_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_tool_input_create_update_move_delete(self) -> None:
|
||||
session = ApplyPatchSession()
|
||||
tool = SandboxApplyPatchTool(session=session)
|
||||
context_wrapper = make_context_wrapper()
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=("*** Begin Patch\n*** Add File: notes.txt\n+hello\n+world\n*** End Patch\n"),
|
||||
)
|
||||
assert session.files[Path("/workspace/notes.txt")] == b"hello\nworld"
|
||||
|
||||
result = await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input=(
|
||||
"*** Begin Patch\n"
|
||||
"*** Update File: notes.txt\n"
|
||||
"*** Move to: moved.txt\n"
|
||||
"@@\n"
|
||||
"-hello\n"
|
||||
"+hi\n"
|
||||
" world\n"
|
||||
"*** End Patch\n"
|
||||
),
|
||||
)
|
||||
assert "Updated notes.txt" in result.output
|
||||
assert "Moved notes.txt to moved.txt" in result.output
|
||||
assert Path("/workspace/notes.txt") not in session.files
|
||||
assert session.files[Path("/workspace/moved.txt")] == b"hi\nworld"
|
||||
|
||||
await _execute_custom_tool_call(
|
||||
tool,
|
||||
context_wrapper=context_wrapper,
|
||||
raw_input="*** Begin Patch\n*** Delete File: moved.txt\n*** End Patch\n",
|
||||
)
|
||||
assert Path("/workspace/moved.txt") not in session.files
|
||||
|
||||
|
||||
async def _execute_custom_tool_call(
|
||||
tool: SandboxApplyPatchTool,
|
||||
*,
|
||||
context_wrapper: RunContextWrapper[Any],
|
||||
raw_input: str,
|
||||
) -> Any:
|
||||
result = await CustomToolAction.execute(
|
||||
agent=Agent(name="patcher", tools=[tool]),
|
||||
call=ToolRunCustom(
|
||||
custom_tool=tool,
|
||||
tool_call={
|
||||
"type": "custom_tool_call",
|
||||
"name": "apply_patch",
|
||||
"call_id": "call_apply",
|
||||
"input": raw_input,
|
||||
},
|
||||
),
|
||||
hooks=RunHooks[Any](),
|
||||
context_wrapper=context_wrapper,
|
||||
config=RunConfig(),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.sandbox.capabilities import Compaction, StaticCompactionPolicy
|
||||
|
||||
|
||||
class TestCompactionCapability:
|
||||
def test_sampling_params_uses_static_threshold(self) -> None:
|
||||
"""Tests compaction emits Responses API context management settings."""
|
||||
|
||||
capability = Compaction(policy=StaticCompactionPolicy(threshold=123))
|
||||
|
||||
sampling_params = capability.sampling_params({})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 123,
|
||||
}
|
||||
]
|
||||
}
|
||||
assert isinstance(capability.policy, StaticCompactionPolicy)
|
||||
|
||||
def test_sampling_params_infers_hyphenated_model_threshold(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "gpt-5-2"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 360_000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_sampling_params_infers_gpt_5_6_sol_dynamic_threshold(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "gpt-5.6-sol"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 942_818,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_sampling_params_falls_back_for_unknown_model(self) -> None:
|
||||
capability = Compaction()
|
||||
|
||||
sampling_params = capability.sampling_params({"model": "azure-prod-deployment"})
|
||||
|
||||
assert sampling_params == {
|
||||
"context_management": [
|
||||
{
|
||||
"type": "compaction",
|
||||
"compact_threshold": 240_000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def test_process_context_keeps_items_from_last_compaction(self) -> None:
|
||||
"""Tests compaction truncates history to the last compaction item, inclusive."""
|
||||
|
||||
capability = Compaction()
|
||||
context: list[TResponseInputItem] = [
|
||||
{"type": "message", "role": "user", "content": "old-1"},
|
||||
cast(TResponseInputItem, {"type": "compaction", "summary": "first"}),
|
||||
{"type": "message", "role": "assistant", "content": "between"},
|
||||
cast(TResponseInputItem, {"type": "compaction", "summary": "second"}),
|
||||
{"type": "message", "role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
processed = capability.process_context(context)
|
||||
|
||||
assert processed == context[3:]
|
||||
|
||||
def test_process_context_returns_original_when_no_compaction(self) -> None:
|
||||
"""Tests compaction leaves context unchanged when no compaction item exists."""
|
||||
|
||||
capability = Compaction()
|
||||
context: list[TResponseInputItem] = [
|
||||
{"type": "message", "role": "user", "content": "hello"},
|
||||
{"type": "message", "role": "assistant", "content": "world"},
|
||||
]
|
||||
|
||||
processed = capability.process_context(context)
|
||||
|
||||
assert processed == context
|
||||
|
||||
def test_rejects_unsupported_policy_type(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported compaction policy type: 'unknown'"):
|
||||
Compaction.model_validate({"policy": {"type": "unknown"}})
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.editor import ApplyPatchOperation
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.capabilities import Filesystem, FilesystemToolSet
|
||||
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool, ViewImageTool
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import User
|
||||
from agents.tool import CustomTool, FunctionTool
|
||||
|
||||
|
||||
def _make_session(tmp_path: Path) -> UnixLocalSandboxSession:
|
||||
return UnixLocalSandboxSession(
|
||||
state=UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(tmp_path / "workspace")),
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
workspace_root_owned=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestFilesystemCapability:
|
||||
def test_tools_requires_bound_session(self) -> None:
|
||||
capability = Filesystem()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Filesystem capability is not bound to a SandboxSession",
|
||||
):
|
||||
capability.tools()
|
||||
|
||||
def test_tools_exposes_view_image_and_apply_patch_after_bind(self, tmp_path: Path) -> None:
|
||||
capability = Filesystem()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert isinstance(tools[0], ViewImageTool)
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert isinstance(tools[1], CustomTool)
|
||||
assert tools[0].name == "view_image"
|
||||
assert tools[1].name == "apply_patch"
|
||||
|
||||
def test_configure_tools_can_customize_approvals_after_clone(self, tmp_path: Path) -> None:
|
||||
async def view_image_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["path"]).startswith("sensitive/")
|
||||
|
||||
async def apply_patch_needs_approval(
|
||||
_ctx: Any, operation: ApplyPatchOperation, _call_id: str
|
||||
) -> bool:
|
||||
return operation.type != "create_file"
|
||||
|
||||
def configure_tools(toolset: FilesystemToolSet) -> None:
|
||||
toolset.view_image.needs_approval = view_image_needs_approval
|
||||
toolset.apply_patch.needs_approval = apply_patch_needs_approval
|
||||
|
||||
capability = Filesystem(configure_tools=configure_tools).clone()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
view_image_tool = cast(ViewImageTool, tools[0])
|
||||
apply_patch_tool = cast(SandboxApplyPatchTool, tools[1])
|
||||
|
||||
assert isinstance(view_image_tool, ViewImageTool)
|
||||
assert isinstance(apply_patch_tool, SandboxApplyPatchTool)
|
||||
assert cast(object, view_image_tool.needs_approval) is view_image_needs_approval
|
||||
assert cast(object, apply_patch_tool.needs_approval) is apply_patch_needs_approval
|
||||
|
||||
def test_configure_tools_can_replace_tool_instances(self, tmp_path: Path) -> None:
|
||||
replacement_view_image: ViewImageTool | None = None
|
||||
|
||||
def configure_tools(toolset: FilesystemToolSet) -> None:
|
||||
nonlocal replacement_view_image
|
||||
replacement_view_image = ViewImageTool(
|
||||
session=toolset.view_image.session,
|
||||
needs_approval=True,
|
||||
)
|
||||
toolset.view_image = replacement_view_image
|
||||
|
||||
capability = Filesystem(configure_tools=configure_tools)
|
||||
capability.bind(_make_session(tmp_path))
|
||||
|
||||
tools = capability.tools()
|
||||
view_image_tool = cast(ViewImageTool, tools[0])
|
||||
|
||||
assert replacement_view_image is not None
|
||||
assert view_image_tool is replacement_view_image
|
||||
assert view_image_tool.needs_approval is True
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
|
||||
def test_tools_passes_bound_run_as_to_file_tools(self, tmp_path: Path) -> None:
|
||||
run_as = User(name="sandbox-user")
|
||||
capability = Filesystem()
|
||||
capability.bind(_make_session(tmp_path))
|
||||
capability.bind_run_as(run_as)
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert isinstance(tools[0], ViewImageTool)
|
||||
assert isinstance(tools[1], SandboxApplyPatchTool)
|
||||
assert tools[0].user == run_as
|
||||
assert tools[1].editor.user == run_as
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_default_to_none(self) -> None:
|
||||
capability = Filesystem()
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is None
|
||||
@@ -0,0 +1,891 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.capabilities import Shell, ShellToolSet
|
||||
from agents.sandbox.capabilities.tools import (
|
||||
ExecCommandArgs,
|
||||
ExecCommandTool,
|
||||
WriteStdinArgs,
|
||||
WriteStdinTool,
|
||||
)
|
||||
from agents.sandbox.capabilities.tools.shell_tool import _resolve_shell
|
||||
from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, PtySessionNotFoundError
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.pty_types import PtyExecUpdate
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
from agents.tool import FunctionTool
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
class _ShellSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.exec_calls: list[tuple[str, float | None, bool | list[str]]] = []
|
||||
self.exec_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: object = None) -> io.BytesIO:
|
||||
_ = (path, user)
|
||||
raise AssertionError("read() should not be called")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = (path, data, user)
|
||||
raise AssertionError("write() should not be called")
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = command
|
||||
_ = timeout
|
||||
raise AssertionError("_exec_internal() should not be called directly")
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_users.append(user.name if isinstance(user, User) else user)
|
||||
rendered_command = " ".join(str(part) for part in command)
|
||||
self.exec_calls.append((rendered_command, timeout, shell))
|
||||
return ExecResult(
|
||||
stdout=f"stdout: {rendered_command}".encode(),
|
||||
stderr=f"stderr: {rendered_command}".encode(),
|
||||
exit_code=7,
|
||||
)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
|
||||
class _TimeoutShellSession(_ShellSession):
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
_ = (command, user, shell)
|
||||
raise ExecTimeoutError(command=("sleep 30",), timeout_s=timeout)
|
||||
|
||||
|
||||
class _OutputShellSession(_ShellSession):
|
||||
def __init__(
|
||||
self,
|
||||
manifest: Manifest,
|
||||
*,
|
||||
stdout: bytes,
|
||||
stderr: bytes,
|
||||
exit_code: int = 7,
|
||||
) -> None:
|
||||
super().__init__(manifest)
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.exit_code = exit_code
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_users.append(user.name if isinstance(user, User) else user)
|
||||
rendered_command = " ".join(str(part) for part in command)
|
||||
self.exec_calls.append((rendered_command, timeout, shell))
|
||||
return ExecResult(stdout=self.stdout, stderr=self.stderr, exit_code=self.exit_code)
|
||||
|
||||
|
||||
class _PtyShellSession(_ShellSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
super().__init__(manifest)
|
||||
self._next_session_id = 1337
|
||||
self._live_sessions: set[int] = set()
|
||||
self.last_exec_yield_time_s: float | None = None
|
||||
self.last_exec_user: str | None = None
|
||||
self.last_write_yield_time_s: float | None = None
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return True
|
||||
|
||||
async def pty_exec_start(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
tty: bool = False,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (command, timeout, shell, tty, max_output_tokens)
|
||||
self.last_exec_user = user.name if isinstance(user, User) else user
|
||||
self.last_exec_yield_time_s = yield_time_s
|
||||
session_id = self._next_session_id
|
||||
self._next_session_id += 1
|
||||
self._live_sessions.add(session_id)
|
||||
return PtyExecUpdate(
|
||||
process_id=session_id,
|
||||
output=b"",
|
||||
exit_code=None,
|
||||
original_token_count=None,
|
||||
)
|
||||
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = max_output_tokens
|
||||
self.last_write_yield_time_s = yield_time_s
|
||||
if session_id not in self._live_sessions:
|
||||
raise PtySessionNotFoundError(session_id=session_id)
|
||||
|
||||
self._live_sessions.discard(session_id)
|
||||
return PtyExecUpdate(
|
||||
process_id=None,
|
||||
output=chars.encode("utf-8", errors="replace"),
|
||||
exit_code=0,
|
||||
original_token_count=None,
|
||||
)
|
||||
|
||||
|
||||
class _PtyNoStdinShellSession(_PtyShellSession):
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (chars, yield_time_s, max_output_tokens)
|
||||
if session_id not in self._live_sessions:
|
||||
raise PtySessionNotFoundError(session_id=session_id)
|
||||
raise RuntimeError("stdin is not available for this process")
|
||||
|
||||
|
||||
class _PtyUnexpectedStdinErrorShellSession(_PtyShellSession):
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (session_id, chars, yield_time_s, max_output_tokens)
|
||||
raise RuntimeError("unexpected stdin failure")
|
||||
|
||||
|
||||
class _PtyTransportFailingShellSession(_OutputShellSession):
|
||||
def __init__(
|
||||
self,
|
||||
manifest: Manifest,
|
||||
*,
|
||||
stdout: bytes = b"",
|
||||
stderr: bytes = b"",
|
||||
exit_code: int = 0,
|
||||
transport_context: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
super().__init__(manifest, stdout=stdout, stderr=stderr, exit_code=exit_code)
|
||||
self.transport_context = transport_context or {}
|
||||
self.exec_call_count = 0
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return True
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
user: str | User | None = None,
|
||||
shell: bool | list[str] = False,
|
||||
) -> ExecResult:
|
||||
self.exec_call_count += 1
|
||||
return await super().exec(*command, timeout=timeout, user=user, shell=shell)
|
||||
|
||||
async def pty_exec_start(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
tty: bool = False,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
_ = (timeout, shell, user, tty, yield_time_s, max_output_tokens)
|
||||
raise ExecTransportError(
|
||||
command=command,
|
||||
context=self.transport_context,
|
||||
cause=RuntimeError("connection closed while reading HTTP status line"),
|
||||
)
|
||||
|
||||
|
||||
def _patch_shell_tool_clock(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
chunk_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: uuid.UUID(chunk_id),
|
||||
)
|
||||
times = iter([start, end])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
|
||||
class TestShellCapability:
|
||||
def test_resolve_shell_uses_plain_sh_when_login_is_false(self) -> None:
|
||||
assert _resolve_shell(None, login=False) == ["sh", "-c"]
|
||||
|
||||
def test_tools_requires_bound_session(self) -> None:
|
||||
capability = Shell()
|
||||
|
||||
with pytest.raises(ValueError, match="Shell capability is not bound to a SandboxSession"):
|
||||
capability.tools()
|
||||
|
||||
def test_tools_exposes_exec_command_function_tool_after_bind(self) -> None:
|
||||
capability = Shell()
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert tools[0].name == "exec_command"
|
||||
|
||||
def test_tools_exposes_write_stdin_for_pty_sessions(self) -> None:
|
||||
capability = Shell()
|
||||
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
assert isinstance(tools[1], WriteStdinTool)
|
||||
assert tools[0].name == "exec_command"
|
||||
assert tools[1].name == "write_stdin"
|
||||
|
||||
def test_configure_tools_can_customize_shell_approvals_after_clone(self) -> None:
|
||||
async def exec_command_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["cmd"]).startswith("rm ")
|
||||
|
||||
async def write_stdin_needs_approval(
|
||||
_ctx: Any, params: dict[str, Any], _call_id: str
|
||||
) -> bool:
|
||||
return str(params["chars"]) == "\u0003"
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
toolset.exec_command.needs_approval = exec_command_needs_approval
|
||||
assert toolset.write_stdin is not None
|
||||
toolset.write_stdin.needs_approval = write_stdin_needs_approval
|
||||
|
||||
capability = Shell(configure_tools=configure_tools).clone()
|
||||
capability.bind(_PtyShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
exec_command_tool = cast(ExecCommandTool, tools[0])
|
||||
write_stdin_tool = cast(WriteStdinTool, tools[1])
|
||||
|
||||
assert cast(object, exec_command_tool.needs_approval) is exec_command_needs_approval
|
||||
assert cast(object, write_stdin_tool.needs_approval) is write_stdin_needs_approval
|
||||
|
||||
def test_configure_tools_can_observe_missing_write_stdin_on_non_pty_session(self) -> None:
|
||||
saw_missing_write_stdin = False
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
nonlocal saw_missing_write_stdin
|
||||
saw_missing_write_stdin = toolset.write_stdin is None
|
||||
|
||||
capability = Shell(configure_tools=configure_tools)
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert saw_missing_write_stdin is True
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], ExecCommandTool)
|
||||
|
||||
def test_configure_tools_can_replace_exec_command_tool(self) -> None:
|
||||
replacement_exec_command: ExecCommandTool | None = None
|
||||
|
||||
def configure_tools(toolset: ShellToolSet) -> None:
|
||||
nonlocal replacement_exec_command
|
||||
replacement_exec_command = ExecCommandTool(
|
||||
session=toolset.exec_command.session,
|
||||
needs_approval=True,
|
||||
)
|
||||
toolset.exec_command = replacement_exec_command
|
||||
|
||||
capability = Shell(configure_tools=configure_tools)
|
||||
capability.bind(_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
tools = capability.tools()
|
||||
exec_command_tool = cast(ExecCommandTool, tools[0])
|
||||
|
||||
assert replacement_exec_command is not None
|
||||
assert exec_command_tool is replacement_exec_command
|
||||
assert exec_command_tool.needs_approval is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_match_sandbox_shell_guidance(self) -> None:
|
||||
capability = Shell()
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert (
|
||||
instructions == "When using the shell:\n"
|
||||
"- Use `exec_command` for shell execution.\n"
|
||||
"- If available, use `write_stdin` to interact with or poll running sessions.\n"
|
||||
"- To interrupt a long-running process via `write_stdin`, start it with "
|
||||
"`tty=true` and send Ctrl-C (`\\u0003`).\n"
|
||||
"- Prefer `rg` and `rg --files` for text/file discovery when available.\n"
|
||||
"- Avoid using Python scripts just to print large file chunks."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_runs_commands_with_source_output_format(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
|
||||
times = iter([100.0, 100.25])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: next(uuids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=1500).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [("pwd", 1.5, True)]
|
||||
assert (
|
||||
output == "Chunk ID: 123456\n"
|
||||
"Wall time: 0.2500 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: pwd\n"
|
||||
"stderr: pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_runs_as_bound_user(self) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_users == ["sandbox-user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_includes_original_token_count_when_truncating(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
uuids = iter([uuid.UUID("12345678123456781234567812345678")])
|
||||
times = iter([200.0, 200.5])
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.uuid.uuid4",
|
||||
lambda: next(uuids),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.capabilities.tools.shell_tool.time.perf_counter",
|
||||
lambda: next(times),
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=1500, max_output_tokens=2).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 123456\n"
|
||||
"Wall time: 0.5000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Original token count: 6\n"
|
||||
"Output:\n"
|
||||
"Total output lines: 2\n\n"
|
||||
"stdo…4 tokens truncated… pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_wraps_workdir_and_uses_custom_shell(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="87654321876543218765432187654321",
|
||||
start=300.0,
|
||||
end=300.125,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(
|
||||
cmd="pwd",
|
||||
workdir="src/project",
|
||||
shell="/bin/bash",
|
||||
login=False,
|
||||
).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [
|
||||
("cd /workspace/src/project && pwd", 10.0, ["/bin/bash", "-c"])
|
||||
]
|
||||
assert (
|
||||
output == "Chunk ID: 876543\n"
|
||||
"Wall time: 0.1250 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: cd /workspace/src/project && pwd\n"
|
||||
"stderr: cd /workspace/src/project && pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_allows_extra_path_grant_workdir(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _ShellSession(
|
||||
Manifest(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/tmp", read_only=True),),
|
||||
)
|
||||
)
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="11111111111111111111111111111111",
|
||||
start=310.0,
|
||||
end=310.25,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(
|
||||
cmd="pwd",
|
||||
workdir="/tmp",
|
||||
shell="/bin/bash",
|
||||
login=False,
|
||||
).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.exec_calls == [("cd /tmp && pwd", 10.0, ["/bin/bash", "-c"])]
|
||||
assert (
|
||||
output == "Chunk ID: 111111\n"
|
||||
"Wall time: 0.2500 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout: cd /tmp && pwd\n"
|
||||
"stderr: cd /tmp && pwd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_pty_when_supported(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="abcdef12abcdef12abcdef12abcdef12",
|
||||
start=400.0,
|
||||
end=400.05,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.last_exec_yield_time_s == 0.0
|
||||
assert (
|
||||
output == "Chunk ID: abcdef\n"
|
||||
"Wall time: 0.0500 seconds\n"
|
||||
"Process running with session ID 1337\n"
|
||||
"Output:\n"
|
||||
""
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_starts_pty_as_bound_user(self) -> None:
|
||||
capability = Shell()
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", yield_time_ms=0, tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
assert session.last_exec_user == "sandbox-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_formats_timeout_without_exit_code(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
capability = Shell()
|
||||
session = _TimeoutShellSession(Manifest(root="/workspace"))
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="fedcba98fedcba98fedcba98fedcba98",
|
||||
start=500.0,
|
||||
end=500.005,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="sleep 30", yield_time_ms=5).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: fedcba\n"
|
||||
"Wall time: 0.0050 seconds\n"
|
||||
"Output:\n"
|
||||
"Command timed out after 0.005 seconds."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_falls_back_to_one_shot_exec_after_startup_transport_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"fallback ok",
|
||||
transport_context={"stage": "open_pipe", "retry_safe": True},
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="44444444444444444444444444444444",
|
||||
start=510.0,
|
||||
end=510.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert "PTY transport failed before the interactive session opened" in output
|
||||
assert "Process exited with code 0" in output
|
||||
assert "Process running with session ID" not in output
|
||||
assert "fallback ok" in output
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_fall_back_for_tty_sessions(self) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
transport_context={"stage": "open_pipe", "retry_safe": True, "tty": True},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ExecTransportError):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd", tty=True).model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_fall_back_for_non_retry_safe_transport_errors(
|
||||
self,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_PtyTransportFailingShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
transport_context={"stage": "open_pipe"},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ExecTransportError):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_stdout_only_when_stderr_is_empty(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"stdout only\n",
|
||||
stderr=b"",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="11111111111111111111111111111111",
|
||||
start=600.0,
|
||||
end=600.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 111111\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout only\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_uses_stderr_only_when_stdout_is_empty(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"",
|
||||
stderr=b"stderr only\n",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="22222222222222222222222222222222",
|
||||
start=700.0,
|
||||
end=700.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 222222\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stderr only\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_tool_does_not_insert_extra_newline_when_stdout_already_has_one(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = ExecCommandTool(
|
||||
session=_OutputShellSession(
|
||||
Manifest(root="/workspace"),
|
||||
stdout=b"stdout line\n",
|
||||
stderr=b"stderr line\n",
|
||||
)
|
||||
)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="33333333333333333333333333333333",
|
||||
start=800.0,
|
||||
end=800.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
ExecCommandArgs(cmd="pwd").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 333333\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 7\n"
|
||||
"Output:\n"
|
||||
"stdout line\n"
|
||||
"stderr line\n"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_writes_and_finishes_session(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _PtyShellSession(Manifest(root="/workspace"))
|
||||
session._live_sessions.add(1337)
|
||||
tool = WriteStdinTool(session=session)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="55555555555555555555555555555555",
|
||||
start=900.0,
|
||||
end=900.2,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337, chars="hello").model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 555555\n"
|
||||
"Wall time: 0.2000 seconds\n"
|
||||
"Process exited with code 0\n"
|
||||
"Output:\n"
|
||||
"hello"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_rejects_non_pty_sessions(self) -> None:
|
||||
tool = WriteStdinTool(session=_ShellSession(Manifest(root="/workspace")))
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match="write_stdin is not available for non-PTY sandboxes"
|
||||
):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_formats_unknown_session_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
tool = WriteStdinTool(session=_PtyShellSession(Manifest(root="/workspace")))
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="66666666666666666666666666666666",
|
||||
start=910.0,
|
||||
end=910.1,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=9999).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 666666\n"
|
||||
"Wall time: 0.1000 seconds\n"
|
||||
"Process exited with code 1\n"
|
||||
"Output:\n"
|
||||
"write_stdin failed: PTY session not found: 9999"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_formats_missing_stdin_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _PtyNoStdinShellSession(Manifest(root="/workspace"))
|
||||
session._live_sessions.add(1337)
|
||||
tool = WriteStdinTool(session=session)
|
||||
_patch_shell_tool_clock(
|
||||
monkeypatch,
|
||||
chunk_id="77777777777777777777777777777777",
|
||||
start=920.0,
|
||||
end=920.05,
|
||||
)
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
|
||||
assert (
|
||||
output == "Chunk ID: 777777\n"
|
||||
"Wall time: 0.0500 seconds\n"
|
||||
"Process exited with code 1\n"
|
||||
"Output:\n"
|
||||
"stdin is not available for this process. Start the command with `tty=true` in "
|
||||
"`exec_command` before using `write_stdin`."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_tool_reraises_unexpected_runtime_error(self) -> None:
|
||||
tool = WriteStdinTool(
|
||||
session=_PtyUnexpectedStdinErrorShellSession(Manifest(root="/workspace"))
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected stdin failure"):
|
||||
await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
WriteStdinArgs(session_id=1337).model_dump_json(),
|
||||
)
|
||||
@@ -0,0 +1,688 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skill, Skills
|
||||
from agents.sandbox.entries import Dir, File, LocalDir
|
||||
from agents.sandbox.errors import SkillsConfigError
|
||||
from agents.sandbox.files import EntryKind, FileEntry
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, Permissions, User
|
||||
from agents.sandbox.workspace_paths import coerce_posix_path
|
||||
from agents.tool import FunctionTool
|
||||
from agents.tool_context import ToolContext
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
def _children_keys(entry: Dir) -> set[str]:
|
||||
return {coerce_posix_path(key).as_posix() for key in entry.children}
|
||||
|
||||
|
||||
def _source_granted_manifest(root: str | Path = "/workspace", *, source: Path) -> Manifest:
|
||||
return Manifest(root=str(root), extra_path_grants=(SandboxPathGrant(path=str(source)),))
|
||||
|
||||
|
||||
def _user_name(user: object) -> str | None:
|
||||
if user is None:
|
||||
return None
|
||||
if isinstance(user, User):
|
||||
return user.name
|
||||
if isinstance(user, str):
|
||||
return user
|
||||
return str(user)
|
||||
|
||||
|
||||
class _SkillsSession(BaseSandboxSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id=str(uuid.uuid4())),
|
||||
)
|
||||
self.read_users: list[str | None] = []
|
||||
self.write_users: list[str | None] = []
|
||||
self.mkdir_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: object = None) -> io.BytesIO:
|
||||
self.read_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
return io.BytesIO(normalized.read_bytes())
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
self.write_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = data.read()
|
||||
if isinstance(payload, str):
|
||||
normalized.write_text(payload, encoding="utf-8")
|
||||
else:
|
||||
normalized.write_bytes(bytes(payload))
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = (command, timeout)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: object = None,
|
||||
) -> None:
|
||||
self.mkdir_users.append(_user_name(user))
|
||||
normalized = self.normalize_path(path)
|
||||
normalized.mkdir(parents=parents, exist_ok=True)
|
||||
|
||||
async def ls(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
user: object = None,
|
||||
) -> list[FileEntry]:
|
||||
_ = user
|
||||
normalized = self.normalize_path(path)
|
||||
if not normalized.exists():
|
||||
raise FileNotFoundError(normalized)
|
||||
entries: list[FileEntry] = []
|
||||
for child in sorted(normalized.iterdir(), key=lambda entry: entry.name):
|
||||
stat_result = child.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
path=str(child),
|
||||
permissions=Permissions.from_mode(stat_result.st_mode),
|
||||
owner="owner",
|
||||
group="group",
|
||||
size=stat_result.st_size,
|
||||
kind=EntryKind.DIRECTORY if child.is_dir() else EntryKind.FILE,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
class TestSkillValidation:
|
||||
def test_rejects_directory_content_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(name="my-skill", description="desc", content=Dir())
|
||||
|
||||
def test_rejects_duplicate_script_paths_after_normalization(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="literal",
|
||||
scripts={
|
||||
"run.sh": File(content=b"echo one"),
|
||||
Path("run.sh"): File(content=b"echo two"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsValidation:
|
||||
def test_requires_at_least_one_source(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills()
|
||||
|
||||
def test_rejects_non_directory_from_artifact(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(from_=File(content=b"not-a-dir"))
|
||||
|
||||
def test_rejects_duplicate_skill_names(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[
|
||||
Skill(name="dup", description="first", content="a"),
|
||||
Skill(name="dup", description="second", content="b"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_from_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
from_=Dir(
|
||||
children={"my-skill": Dir(children={"SKILL.md": File(content=b"imported")})}
|
||||
),
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
)
|
||||
|
||||
def test_rejects_combining_literal_and_lazy_sources(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=Path("skills"))),
|
||||
)
|
||||
|
||||
def test_rejects_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="/skills",
|
||||
)
|
||||
|
||||
def test_rejects_windows_drive_absolute_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="C:\\skills",
|
||||
)
|
||||
|
||||
assert exc_info.value.context == {
|
||||
"field": "skills_path",
|
||||
"path": "C:/skills",
|
||||
"reason": "absolute",
|
||||
}
|
||||
|
||||
def test_rejects_escape_root_skills_path(self) -> None:
|
||||
with pytest.raises(SkillsConfigError):
|
||||
Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path="../skills",
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsManifest:
|
||||
def test_literals_materialize_full_skill_structure(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
references={"docs/readme.md": File(content=b"ref")},
|
||||
assets={"images/icon.txt": File(content=b"asset")},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
skill_entry = processed.entries[Path(".agents/my-skill")]
|
||||
assert isinstance(skill_entry, Dir)
|
||||
assert _children_keys(skill_entry) == {"SKILL.md", "assets", "references", "scripts"}
|
||||
|
||||
scripts = skill_entry.children["scripts"]
|
||||
assert isinstance(scripts, Dir)
|
||||
assert _children_keys(scripts) == {"run.sh"}
|
||||
|
||||
references = skill_entry.children["references"]
|
||||
assert isinstance(references, Dir)
|
||||
assert _children_keys(references) == {"docs/readme.md"}
|
||||
|
||||
assets = skill_entry.children["assets"]
|
||||
assert isinstance(assets, Dir)
|
||||
assert _children_keys(assets) == {"images/icon.txt"}
|
||||
|
||||
def test_from_source_is_mapped_to_skills_root(self) -> None:
|
||||
source = Dir(children={"imported": Dir(children={"SKILL.md": File(content=b"imported")})})
|
||||
capability = Skills(from_=source)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")] is source
|
||||
|
||||
def test_local_dir_from_source_stays_eager_by_default(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(from_=LocalDir(src=src_root))
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries[Path(".agents")].type == "local_dir"
|
||||
|
||||
def test_lazy_local_dir_source_skips_manifest_materialization(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
assert processed.entries == {}
|
||||
|
||||
def test_lazy_local_dir_rejects_overlapping_manifest_entries(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={Path(".agents"): Dir()},
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError) as exc_info:
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
assert exc_info.value.message == "skills lazy_from path overlaps existing manifest entries"
|
||||
assert exc_info.value.context == {
|
||||
"path": ".agents",
|
||||
"source": "lazy_from",
|
||||
"overlaps": [".agents"],
|
||||
}
|
||||
|
||||
def test_literal_skills_allow_existing_manifest_entry_when_content_matches(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(
|
||||
name="my-skill",
|
||||
description="desc",
|
||||
content="Use this skill.",
|
||||
scripts={"run.sh": File(content=b"echo run")},
|
||||
)
|
||||
]
|
||||
)
|
||||
rendered_skill = capability.skills[0].as_dir_entry()
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={".agents/my-skill": rendered_skill},
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(manifest)
|
||||
|
||||
assert processed is manifest
|
||||
assert processed.entries[".agents/my-skill"] == rendered_skill
|
||||
|
||||
def test_process_manifest_rejects_exact_path_collision(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
manifest = Manifest(root="/workspace", entries={Path(".agents/my-skill"): Dir()})
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
capability.process_manifest(manifest)
|
||||
|
||||
def test_custom_skills_path_is_used_for_manifest_entries(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
processed = capability.process_manifest(Manifest(root="/workspace"))
|
||||
|
||||
assert processed.entries[Path(".sandbox/skills/my-skill")] == (
|
||||
capability.skills[0].as_dir_entry()
|
||||
)
|
||||
|
||||
|
||||
class TestSkillsInstructions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_include_root_and_literal_index(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[
|
||||
Skill(name="z-skill", description="z description", content="z"),
|
||||
Skill(name="a-skill", description="a description", content="a"),
|
||||
]
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is not None
|
||||
assert instructions.startswith("## Skills\n")
|
||||
assert "### Available skills" in instructions
|
||||
assert "### How to use skills" in instructions
|
||||
assert "- a-skill: a description (file: .agents/a-skill)" in instructions
|
||||
assert "- z-skill: z description (file: .agents/z-skill)" in instructions
|
||||
assert instructions.index(
|
||||
"- a-skill: a description (file: .agents/a-skill)"
|
||||
) < instructions.index("- z-skill: z description (file: .agents/z-skill)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_use_custom_skills_path(self) -> None:
|
||||
capability = Skills(
|
||||
skills=[Skill(name="my-skill", description="desc", content="literal")],
|
||||
skills_path=".sandbox/skills",
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is not None
|
||||
assert "- my-skill: desc (file: .sandbox/skills/my-skill)" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_return_none_when_metadata_is_empty(self) -> None:
|
||||
capability = Skills(from_=Dir())
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_requires_extra_path_grant(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: hidden-skill\ndescription: outside base\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
instructions = await capability.instructions(Manifest(root="/workspace"))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_from_runtime_frontmatter(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
from_=Dir(
|
||||
children={
|
||||
"dynamic-skill": Dir(
|
||||
children={
|
||||
"SKILL.md": File(
|
||||
content=(
|
||||
b"---\n"
|
||||
b"name: discovered-skill\n"
|
||||
b"description: loaded from runtime frontmatter\n"
|
||||
b"---\n\n"
|
||||
b"# Skill\n"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
manifest = capability.process_manifest(Manifest(root=str(workspace_root)))
|
||||
session = _SkillsSession(manifest)
|
||||
await session.apply_manifest()
|
||||
capability.bind(session)
|
||||
|
||||
instructions = await capability.instructions(session.state.manifest)
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: loaded from runtime frontmatter (file: .agents/dynamic-skill)"
|
||||
) in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instructions_resolve_opt_in_lazy_local_dir_metadata(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: discovered-skill\ndescription: local dir metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
assert await capability.instructions(Manifest(root="/workspace")) is None
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is not None
|
||||
assert (
|
||||
"- discovered-skill: local dir metadata (file: .agents/dynamic-skill)" in instructions
|
||||
)
|
||||
assert "Call `load_skill` with a single skill name from the list" in instructions
|
||||
assert "loaded on demand instead of being present up front" in instructions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_metadata_skips_symlinked_skill_directory(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
outside_root = tmp_path / "outside"
|
||||
outside_skill = outside_root / "linked-skill"
|
||||
src_root.mkdir()
|
||||
outside_skill.mkdir(parents=True)
|
||||
(outside_skill / "SKILL.md").write_text(
|
||||
"---\nname: linked-skill\ndescription: linked metadata\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
(src_root / "linked-skill").symlink_to(outside_skill, target_is_directory=True)
|
||||
except OSError as e:
|
||||
pytest.skip(f"symlink unavailable: {e}")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
|
||||
instructions = await capability.instructions(_source_granted_manifest(source=src_root))
|
||||
|
||||
assert instructions is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_local_dir_load_skill_tool_materializes_single_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)),
|
||||
)
|
||||
manifest = capability.process_manifest(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
assert manifest.entries == {}
|
||||
|
||||
session = _SkillsSession(manifest)
|
||||
capability.bind(session)
|
||||
tool = cast(FunctionTool, capability.tools()[0])
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await session.read(Path(".agents/dynamic-skill/SKILL.md"))
|
||||
|
||||
output = await tool.on_invoke_tool(
|
||||
cast(ToolContext[object], None),
|
||||
'{"skill_name":"dynamic-skill"}',
|
||||
)
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
loaded_skill = workspace_root / ".agents" / "dynamic-skill" / "SKILL.md"
|
||||
assert loaded_skill.read_text(encoding="utf-8") == "# dynamic skill\n"
|
||||
|
||||
|
||||
class TestSkillsLazyLoading:
|
||||
def test_tools_returns_empty_without_lazy_source(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
assert capability.tools() == []
|
||||
|
||||
def test_lazy_tools_require_bound_session(self, tmp_path: Path) -> None:
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
with pytest.raises(ValueError, match="Skills is not bound to a SandboxSession"):
|
||||
capability.tools()
|
||||
|
||||
def test_lazy_tools_expose_load_skill_after_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
tools = capability.tools()
|
||||
|
||||
assert len(tools) == 1
|
||||
assert isinstance(tools[0], FunctionTool)
|
||||
assert tools[0].name == "load_skill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_non_lazy_capability(self) -> None:
|
||||
capability = Skills(skills=[Skill(name="my-skill", description="desc", content="literal")])
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("my-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_returns_already_loaded_for_existing_materialized_skill(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
await session.write(
|
||||
Path(".agents/dynamic-skill/SKILL.md"),
|
||||
io.BytesIO(b"# already loaded\n"),
|
||||
)
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "already_loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_materializes_with_bound_run_as_user(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("# dynamic skill\n", encoding="utf-8")
|
||||
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
session = _SkillsSession(_source_granted_manifest(workspace_root, source=src_root))
|
||||
capability.bind(session)
|
||||
capability.bind_run_as(User(name="sandbox-user"))
|
||||
|
||||
output = await capability.load_skill("dynamic-skill")
|
||||
|
||||
assert output == {
|
||||
"status": "loaded",
|
||||
"skill_name": "dynamic-skill",
|
||||
"path": ".agents/dynamic-skill",
|
||||
}
|
||||
assert session.read_users == ["sandbox-user"]
|
||||
assert session.write_users == ["sandbox-user"]
|
||||
assert session.mkdir_users
|
||||
assert set(session.mkdir_users) == {"sandbox-user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_missing_lazy_source_directory(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
capability = Skills(
|
||||
lazy_from=LocalDirLazySkillSource(source=LocalDir(src=tmp_path / "missing-skills"))
|
||||
)
|
||||
capability.bind(
|
||||
_SkillsSession(
|
||||
_source_granted_manifest(workspace_root, source=tmp_path / "missing-skills")
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("missing-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_skill_rejects_ambiguous_skill_name(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
first_dir = src_root / "skill-one"
|
||||
second_dir = src_root / "skill-two"
|
||||
first_dir.mkdir(parents=True)
|
||||
second_dir.mkdir(parents=True)
|
||||
(first_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: first\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(second_dir / "SKILL.md").write_text(
|
||||
"---\nname: shared-skill\ndescription: second\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
|
||||
with pytest.raises(SkillsConfigError):
|
||||
await capability.load_skill("shared-skill")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_metadata_cache_is_reset_on_bind(self, tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
src_root = tmp_path / "skills"
|
||||
skill_dir = src_root / "dynamic-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: old description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
capability = Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=src_root)))
|
||||
|
||||
first_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
skill_md.write_text(
|
||||
"---\nname: cached-skill\ndescription: new description\n---\n# Skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
second_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
capability.bind(_SkillsSession(_source_granted_manifest(workspace_root, source=src_root)))
|
||||
third_instructions = await capability.instructions(
|
||||
_source_granted_manifest(workspace_root, source=src_root)
|
||||
)
|
||||
|
||||
assert first_instructions is not None
|
||||
assert second_instructions is not None
|
||||
assert third_instructions is not None
|
||||
assert "- cached-skill: old description (file: .agents/dynamic-skill)" in first_instructions
|
||||
assert (
|
||||
"- cached-skill: old description (file: .agents/dynamic-skill)" in second_instructions
|
||||
)
|
||||
assert "- cached-skill: new description (file: .agents/dynamic-skill)" in third_instructions
|
||||
@@ -0,0 +1,200 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import function_tool
|
||||
from agents.editor import ApplyPatchOperation
|
||||
from agents.sandbox.capabilities import Capability
|
||||
from agents.sandbox.entries import (
|
||||
AzureBlobMount,
|
||||
Dir,
|
||||
File,
|
||||
GCSMount,
|
||||
GitRepo,
|
||||
InContainerMountStrategy,
|
||||
LocalDir,
|
||||
LocalFile,
|
||||
R2Mount,
|
||||
RcloneMountPattern,
|
||||
S3Mount,
|
||||
)
|
||||
from agents.sandbox.errors import (
|
||||
ApplyPatchPathError,
|
||||
InvalidManifestPathError,
|
||||
WorkspaceReadNotFoundError,
|
||||
)
|
||||
from agents.sandbox.files import EntryKind
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.workspace_paths import SandboxPathGrant
|
||||
from agents.tool import Tool
|
||||
|
||||
BUILTIN_MANIFEST_ENTRY_TYPES = {
|
||||
"azure_blob_mount",
|
||||
"dir",
|
||||
"file",
|
||||
"gcs_mount",
|
||||
"git_repo",
|
||||
"local_dir",
|
||||
"local_file",
|
||||
"r2_mount",
|
||||
"s3_mount",
|
||||
}
|
||||
|
||||
DURABLE_WORKSPACE_TEXTS = {
|
||||
"inline.txt": "inline file v1\n",
|
||||
"delete_me.txt": "delete me v1\n",
|
||||
"tree/nested.txt": "nested file v1\n",
|
||||
"copied_file.txt": "local file source v1\n",
|
||||
"copied_dir/child.txt": "local dir child v1\n",
|
||||
"copied_dir/nested/grandchild.txt": "local dir grandchild v1\n",
|
||||
"repo/README.md": "mock git repo readme v1\n",
|
||||
"repo/pkg/module.py": "VALUE = 'mock git module v1'\n",
|
||||
}
|
||||
|
||||
EPHEMERAL_WORKSPACE_TEXTS = {
|
||||
"tree/ephemeral.txt": "ephemeral file v1\n",
|
||||
}
|
||||
|
||||
MOUNT_WORKSPACE_TEXTS = {
|
||||
"mounts/s3/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/gcs/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/r2/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/azure/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
}
|
||||
|
||||
ARCHIVE_WORKSPACE_TEXTS = {
|
||||
"archive_dir/hello.txt": "hello from tar archive\n",
|
||||
}
|
||||
|
||||
RUNTIME_WORKSPACE_TEXTS = {
|
||||
"runtime_note.txt": "runtime note v1\n",
|
||||
}
|
||||
|
||||
PATCHED_WORKSPACE_TEXTS = {
|
||||
"inline.txt": "inline file v2\n",
|
||||
"created_by_patch.txt": "created by patch",
|
||||
}
|
||||
|
||||
RESTORED_WORKSPACE_DIRS = {
|
||||
"archive_dir",
|
||||
"copied_dir",
|
||||
"copied_dir/nested",
|
||||
"mounts",
|
||||
"mounts/azure",
|
||||
"mounts/gcs",
|
||||
"mounts/r2",
|
||||
"mounts/s3",
|
||||
"repo",
|
||||
"repo/pkg",
|
||||
"tree",
|
||||
}
|
||||
|
||||
RESTORED_WORKSPACE_FILES = {
|
||||
"archive_dir/hello.txt",
|
||||
"bundle.tar",
|
||||
"copied_dir/child.txt",
|
||||
"copied_dir/nested/grandchild.txt",
|
||||
"copied_file.txt",
|
||||
"created_by_patch.txt",
|
||||
"inline.txt",
|
||||
"mounts/azure/.mock-rclone-mounted",
|
||||
"mounts/gcs/.mock-rclone-mounted",
|
||||
"mounts/r2/.mock-rclone-mounted",
|
||||
"mounts/s3/.mock-rclone-mounted",
|
||||
"repo/README.md",
|
||||
"repo/pkg/module.py",
|
||||
"runtime_note.txt",
|
||||
"tree/ephemeral.txt",
|
||||
"tree/nested.txt",
|
||||
}
|
||||
|
||||
SANDBOX_INTERNAL_WORKSPACE_DIR_PREFIXES = (".sandbox-rclone-config",)
|
||||
|
||||
MOCK_TOOL_NAMES = (
|
||||
"blobfuse2",
|
||||
"cp",
|
||||
"fusermount3",
|
||||
"git",
|
||||
"mount-s3",
|
||||
"pkill",
|
||||
"rclone",
|
||||
"rm",
|
||||
"umount",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MockExternalTools:
|
||||
bin_dir: Path
|
||||
log_path: Path
|
||||
|
||||
def calls(self) -> list[str]:
|
||||
if not self.log_path.exists():
|
||||
return []
|
||||
return self.log_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def install_mock_external_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> MockExternalTools:
|
||||
bin_dir = tmp_path / "mock-bin"
|
||||
bin_dir.mkdir()
|
||||
log_path = tmp_path / "mock-tool-calls.tsv"
|
||||
log_path.write_text("", encoding="utf-8")
|
||||
|
||||
for name in MOCK_TOOL_NAMES:
|
||||
tool_path = bin_dir / name
|
||||
tool_path.write_text(_mock_tool_script(), encoding="utf-8")
|
||||
tool_path.chmod(0o755)
|
||||
|
||||
existing_path = os.environ.get("PATH", "")
|
||||
monkeypatch.setenv("SANDBOX_INTEGRATION_TOOL_LOG", str(log_path))
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{existing_path}")
|
||||
return MockExternalTools(bin_dir=bin_dir, log_path=log_path)
|
||||
|
||||
|
||||
def create_local_sources(tmp_path: Path) -> Path:
|
||||
source_root = tmp_path / "manifest-sources"
|
||||
local_dir = source_root / "local-dir"
|
||||
nested_dir = local_dir / "nested"
|
||||
nested_dir.mkdir(parents=True)
|
||||
(source_root / "local-file.txt").write_text("local file source v1\n", encoding="utf-8")
|
||||
(local_dir / "child.txt").write_text("local dir child v1\n", encoding="utf-8")
|
||||
(nested_dir / "grandchild.txt").write_text("local dir grandchild v1\n", encoding="utf-8")
|
||||
return source_root
|
||||
|
||||
|
||||
def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Path) -> Manifest:
|
||||
return Manifest(
|
||||
root=str(workspace_root),
|
||||
extra_path_grants=(SandboxPathGrant(path=str(source_root)),),
|
||||
entries={
|
||||
"inline.txt": File(content=DURABLE_WORKSPACE_TEXTS["inline.txt"].encode("utf-8")),
|
||||
"delete_me.txt": File(content=DURABLE_WORKSPACE_TEXTS["delete_me.txt"].encode("utf-8")),
|
||||
"tree": Dir(
|
||||
children={
|
||||
"nested.txt": File(
|
||||
content=DURABLE_WORKSPACE_TEXTS["tree/nested.txt"].encode("utf-8")
|
||||
),
|
||||
"ephemeral.txt": File(
|
||||
content=EPHEMERAL_WORKSPACE_TEXTS["tree/ephemeral.txt"].encode("utf-8"),
|
||||
ephemeral=True,
|
||||
),
|
||||
}
|
||||
),
|
||||
"copied_file.txt": LocalFile(
|
||||
src=source_root / "local-file.txt",
|
||||
),
|
||||
"copied_dir": LocalDir(
|
||||
src=source_root / "local-dir",
|
||||
),
|
||||
"repo": GitRepo(repo="openai/mock-sandbox-fixture", ref="main"),
|
||||
"mounts/s3": S3Mount(
|
||||
bucket="s3-bucket",
|
||||
access_key_id="s3-access-key-id",
|
||||
secret_access_key="s3-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/gcs": GCSMount(
|
||||
bucket="gcs-bucket",
|
||||
access_id="gcs-access-id",
|
||||
secret_access_key="gcs-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/r2": R2Mount(
|
||||
bucket="r2-bucket",
|
||||
account_id="r2-account-id",
|
||||
access_key_id="r2-access-key-id",
|
||||
secret_access_key="r2-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/azure": AzureBlobMount(
|
||||
account="azure-account",
|
||||
container="azure-container",
|
||||
account_key="azure-account-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def manifest_entry_types(manifest: Manifest) -> set[str]:
|
||||
return {entry.type for _path, entry in manifest.iter_entries()}
|
||||
|
||||
|
||||
async def read_workspace_text(session: BaseSandboxSession, path: str | Path) -> str:
|
||||
handle = await session.read(Path(path))
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8")
|
||||
raise TypeError(f"Unexpected workspace read payload type: {type(payload).__name__}")
|
||||
|
||||
|
||||
async def write_workspace_text(session: BaseSandboxSession, path: str | Path, text: str) -> None:
|
||||
await session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
|
||||
|
||||
async def assert_workspace_texts(
|
||||
session: BaseSandboxSession,
|
||||
expected: Mapping[str, str],
|
||||
) -> None:
|
||||
actual = {path: await read_workspace_text(session, path) for path in expected}
|
||||
assert actual == dict(expected)
|
||||
|
||||
|
||||
async def assert_manifest_materialized(session: BaseSandboxSession) -> None:
|
||||
assert manifest_entry_types(session.state.manifest) == BUILTIN_MANIFEST_ENTRY_TYPES
|
||||
await assert_workspace_texts(session, DURABLE_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, EPHEMERAL_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, MOUNT_WORKSPACE_TEXTS)
|
||||
|
||||
|
||||
async def assert_lifecycle_patch_state(session: BaseSandboxSession) -> None:
|
||||
await assert_workspace_texts(
|
||||
session,
|
||||
{
|
||||
**{
|
||||
path: text
|
||||
for path, text in DURABLE_WORKSPACE_TEXTS.items()
|
||||
if path != "delete_me.txt"
|
||||
},
|
||||
**RUNTIME_WORKSPACE_TEXTS,
|
||||
**PATCHED_WORKSPACE_TEXTS,
|
||||
},
|
||||
)
|
||||
await assert_workspace_missing(session, "delete_me.txt")
|
||||
|
||||
|
||||
async def assert_restored_lifecycle_state(session: BaseSandboxSession) -> None:
|
||||
assert manifest_entry_types(session.state.manifest) == BUILTIN_MANIFEST_ENTRY_TYPES
|
||||
await assert_lifecycle_patch_state(session)
|
||||
await assert_workspace_texts(session, ARCHIVE_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, EPHEMERAL_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, MOUNT_WORKSPACE_TEXTS)
|
||||
await assert_restored_workspace_tree(session)
|
||||
|
||||
|
||||
async def assert_workspace_missing(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await read_workspace_text(session, path)
|
||||
except WorkspaceReadNotFoundError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace path to be missing: {path}")
|
||||
|
||||
|
||||
async def assert_workspace_escape_blocked(session: BaseSandboxSession) -> None:
|
||||
for path in ("../outside.txt", "/tmp/sandbox-outside.txt"):
|
||||
await _assert_read_blocked(session, path)
|
||||
await _assert_write_blocked(session, path)
|
||||
await _assert_patch_blocked(session, path)
|
||||
await _assert_symlink_escape_blocked(session)
|
||||
|
||||
|
||||
async def assert_restored_workspace_tree(session: BaseSandboxSession) -> None:
|
||||
actual_dirs, actual_files = await _workspace_tree(session)
|
||||
assert actual_dirs == RESTORED_WORKSPACE_DIRS, {
|
||||
"actual_dirs": sorted(actual_dirs),
|
||||
"expected_dirs": sorted(RESTORED_WORKSPACE_DIRS),
|
||||
}
|
||||
assert actual_files == RESTORED_WORKSPACE_FILES, {
|
||||
"actual_files": sorted(actual_files),
|
||||
"expected_files": sorted(RESTORED_WORKSPACE_FILES),
|
||||
}
|
||||
|
||||
|
||||
def lifecycle_patch_operations() -> list[ApplyPatchOperation | dict[str, object]]:
|
||||
return [
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="inline.txt",
|
||||
diff="@@\n-inline file v1\n+inline file v2\n",
|
||||
),
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="created_by_patch.txt",
|
||||
diff="+created by patch\n",
|
||||
),
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="delete_me.txt",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SandboxFileCapability(Capability):
|
||||
type: str = "sandbox-file"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="sandbox-file")
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
@function_tool(name_override="write_file", failure_error_function=None)
|
||||
async def write_file(path: str, content: str) -> str:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxFileCapability is not bound to a session.")
|
||||
await write_workspace_text(self.session, path, content)
|
||||
return f"wrote {path}"
|
||||
|
||||
@function_tool(name_override="read_file", failure_error_function=None)
|
||||
async def read_file(path: str) -> str:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxFileCapability is not bound to a session.")
|
||||
return await read_workspace_text(self.session, path)
|
||||
|
||||
return [write_file, read_file]
|
||||
|
||||
|
||||
class SandboxLifecycleProbeCapability(Capability):
|
||||
type: str = "sandbox-lifecycle-probe"
|
||||
pty_process_id: int | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="sandbox-lifecycle-probe")
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
@function_tool(name_override="assert_manifest_materialized", failure_error_function=None)
|
||||
async def assert_manifest_materialized_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_manifest_materialized(session)
|
||||
return "manifest materialized"
|
||||
|
||||
@function_tool(name_override="apply_lifecycle_patch", failure_error_function=None)
|
||||
async def apply_lifecycle_patch() -> str:
|
||||
session = self._require_session()
|
||||
result = await session.apply_patch(lifecycle_patch_operations())
|
||||
assert result == "Done!"
|
||||
await assert_lifecycle_patch_state(session)
|
||||
return "lifecycle patch applied"
|
||||
|
||||
@function_tool(name_override="assert_workspace_escape_blocked", failure_error_function=None)
|
||||
async def assert_workspace_escape_blocked_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_workspace_escape_blocked(session)
|
||||
return "workspace escape blocked"
|
||||
|
||||
@function_tool(name_override="extract_lifecycle_archive", failure_error_function=None)
|
||||
async def extract_lifecycle_archive() -> str:
|
||||
session = self._require_session()
|
||||
await session.extract("bundle.tar", _tar_bytes(ARCHIVE_WORKSPACE_TEXTS))
|
||||
await assert_workspace_texts(session, ARCHIVE_WORKSPACE_TEXTS)
|
||||
return "archive extracted"
|
||||
|
||||
@function_tool(name_override="start_lifecycle_pty", failure_error_function=None)
|
||||
async def start_lifecycle_pty() -> str:
|
||||
session = self._require_session()
|
||||
pty = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'ready\\n'; while IFS= read -r line; do printf 'got:%s\\n' \"$line\"; done",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
assert pty.process_id is not None
|
||||
output = pty.output.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
||||
assert output == "ready\n"
|
||||
self.pty_process_id = pty.process_id
|
||||
update = await session.pty_write_stdin(
|
||||
session_id=pty.process_id,
|
||||
chars="hello pty\n",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
write_output = update.output.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
||||
assert write_output == "hello pty\ngot:hello pty\n"
|
||||
assert update.process_id == pty.process_id
|
||||
assert update.exit_code is None
|
||||
return "pty started and echoed stdin"
|
||||
|
||||
@function_tool(name_override="assert_restored_lifecycle_state", failure_error_function=None)
|
||||
async def assert_restored_lifecycle_state_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_restored_lifecycle_state(session)
|
||||
return "restored lifecycle state verified"
|
||||
|
||||
return [
|
||||
assert_manifest_materialized_tool,
|
||||
apply_lifecycle_patch,
|
||||
assert_workspace_escape_blocked_tool,
|
||||
extract_lifecycle_archive,
|
||||
start_lifecycle_pty,
|
||||
assert_restored_lifecycle_state_tool,
|
||||
]
|
||||
|
||||
def _require_session(self) -> BaseSandboxSession:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxLifecycleProbeCapability is not bound to a session.")
|
||||
return self.session
|
||||
|
||||
|
||||
async def _assert_read_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await read_workspace_text(session, path)
|
||||
except InvalidManifestPathError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace read to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_write_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await write_workspace_text(session, path, "outside write\n")
|
||||
except InvalidManifestPathError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace write to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_patch_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path=path,
|
||||
diff="+outside patch\n",
|
||||
)
|
||||
)
|
||||
except (ApplyPatchPathError, InvalidManifestPathError):
|
||||
return
|
||||
raise AssertionError(f"Expected workspace patch to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_symlink_escape_blocked(session: BaseSandboxSession) -> None:
|
||||
workspace_root = Path(session.state.manifest.root)
|
||||
outside_path = workspace_root.parent / "symlink-outside.txt"
|
||||
symlink_path = workspace_root / "symlink_escape.txt"
|
||||
outside_path.write_text("outside symlink target\n", encoding="utf-8")
|
||||
symlink_path.symlink_to(outside_path)
|
||||
try:
|
||||
await _assert_read_blocked(session, "symlink_escape.txt")
|
||||
await _assert_write_blocked(session, "symlink_escape.txt")
|
||||
await _assert_patch_blocked(session, "symlink_escape.txt")
|
||||
finally:
|
||||
symlink_path.unlink(missing_ok=True)
|
||||
outside_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _tar_bytes(members: Mapping[str, str]) -> io.BytesIO:
|
||||
archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||||
for name, text in members.items():
|
||||
payload = text.encode("utf-8")
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(payload)
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
archive.seek(0)
|
||||
return archive
|
||||
|
||||
|
||||
async def _workspace_tree(session: BaseSandboxSession) -> tuple[set[str], set[str]]:
|
||||
root = Path(session.state.manifest.root).resolve(strict=False)
|
||||
dirs: set[str] = set()
|
||||
files: set[str] = set()
|
||||
|
||||
async def collect(path: Path) -> None:
|
||||
for entry in await session.ls(path):
|
||||
rel_path = _entry_workspace_rel_path(entry.path, root)
|
||||
if entry.kind == EntryKind.DIRECTORY:
|
||||
if _is_sandbox_internal_workspace_dir(rel_path):
|
||||
continue
|
||||
dirs.add(rel_path)
|
||||
await collect(Path(rel_path))
|
||||
elif entry.kind == EntryKind.FILE:
|
||||
files.add(rel_path)
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Unexpected workspace entry kind for {rel_path}: {entry.kind}"
|
||||
)
|
||||
|
||||
await collect(Path("."))
|
||||
return dirs, files
|
||||
|
||||
|
||||
def _entry_workspace_rel_path(entry_path: str, root: Path) -> str:
|
||||
path = Path(entry_path)
|
||||
if path.is_absolute():
|
||||
path = path.resolve(strict=False).relative_to(root)
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def _is_sandbox_internal_workspace_dir(path: str) -> bool:
|
||||
return any(
|
||||
path == prefix or path.startswith(f"{prefix}/")
|
||||
for prefix in SANDBOX_INTERNAL_WORKSPACE_DIR_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def _mock_tool_script() -> str:
|
||||
return """#!/bin/sh
|
||||
set -eu
|
||||
|
||||
tool=$(basename "$0")
|
||||
log_path="${SANDBOX_INTEGRATION_TOOL_LOG:-}"
|
||||
if [ -n "$log_path" ]; then
|
||||
{
|
||||
printf "%s" "$tool"
|
||||
for arg in "$@"; do
|
||||
printf "\\t%s" "$arg"
|
||||
done
|
||||
printf "\\n"
|
||||
} >> "$log_path"
|
||||
fi
|
||||
|
||||
case "$tool" in
|
||||
git)
|
||||
exit 0
|
||||
;;
|
||||
cp)
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest/pkg"
|
||||
printf "mock git repo readme v1\\n" > "$dest/README.md"
|
||||
printf "VALUE = 'mock git module v1'\\n" > "$dest/pkg/module.py"
|
||||
exit 0
|
||||
;;
|
||||
rclone)
|
||||
if [ "${1:-}" = "mount" ] && [ -n "${3:-}" ]; then
|
||||
mkdir -p "$3"
|
||||
printf "mock rclone mount\\n" > "$3/.mock-rclone-mounted"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
blobfuse2)
|
||||
if [ "${1:-}" = "mount" ]; then
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest"
|
||||
printf "mock blobfuse mount\\n" > "$dest/.mock-blobfuse-mounted"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
mount-s3)
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest"
|
||||
printf "mock mount-s3 mount\\n" > "$dest/.mock-mount-s3-mounted"
|
||||
exit 0
|
||||
;;
|
||||
rm)
|
||||
recursive=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-rf|-fr|-r|-f|--)
|
||||
if [ "$arg" = "-rf" ] || [ "$arg" = "-fr" ] || [ "$arg" = "-r" ]; then
|
||||
recursive="-r"
|
||||
fi
|
||||
;;
|
||||
"$HOME"|"$HOME"/*)
|
||||
if [ -n "$recursive" ]; then
|
||||
/bin/rm -rf -- "$arg"
|
||||
else
|
||||
/bin/rm -f -- "$arg"
|
||||
fi
|
||||
;;
|
||||
/*)
|
||||
;;
|
||||
*..*)
|
||||
;;
|
||||
*)
|
||||
if [ -n "$recursive" ]; then
|
||||
/bin/rm -rf -- "$arg"
|
||||
else
|
||||
/bin/rm -f -- "$arg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
fusermount3|umount|pkill)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
"""
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agents.items import TResponseOutputItem
|
||||
from tests.fake_model import FakeModel
|
||||
from tests.test_responses import get_final_output_message, get_function_tool_call
|
||||
|
||||
__test__ = False
|
||||
|
||||
|
||||
class TestModel(FakeModel):
|
||||
"""Reusable queued model for sandbox integration tests."""
|
||||
|
||||
__test__ = False
|
||||
|
||||
def queue_turn(self, *items: TResponseOutputItem) -> None:
|
||||
self.set_next_output(list(items))
|
||||
|
||||
def queue_function_call(
|
||||
self,
|
||||
name: str,
|
||||
arguments: Mapping[str, Any] | str | None = None,
|
||||
*,
|
||||
call_id: str | None = None,
|
||||
namespace: str | None = None,
|
||||
) -> None:
|
||||
self.queue_turn(
|
||||
get_function_tool_call(
|
||||
name,
|
||||
_serialize_arguments(arguments),
|
||||
call_id=call_id,
|
||||
namespace=namespace,
|
||||
)
|
||||
)
|
||||
|
||||
def queue_function_calls(
|
||||
self,
|
||||
calls: Sequence[tuple[str, Mapping[str, Any] | str | None, str | None]],
|
||||
) -> None:
|
||||
self.queue_turn(
|
||||
*[
|
||||
get_function_tool_call(name, _serialize_arguments(arguments), call_id=call_id)
|
||||
for name, arguments, call_id in calls
|
||||
]
|
||||
)
|
||||
|
||||
def queue_final_output(self, output: str) -> None:
|
||||
self.queue_turn(get_final_output_message(output))
|
||||
|
||||
|
||||
def _serialize_arguments(arguments: Mapping[str, Any] | str | None) -> str:
|
||||
if arguments is None:
|
||||
return "{}"
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments)
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import RunConfig, Runner, function_tool
|
||||
from agents.items import RunItem, ToolCallOutputItem
|
||||
from agents.run_state import RunState
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session import CallbackSink, Instrumentation, SandboxSessionEvent
|
||||
from tests.sandbox.integration_tests._helpers import (
|
||||
SandboxFileCapability,
|
||||
SandboxLifecycleProbeCapability,
|
||||
build_manifest_with_all_entry_types,
|
||||
create_local_sources,
|
||||
install_mock_external_tools,
|
||||
)
|
||||
from tests.sandbox.integration_tests.test_model import TestModel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_preserves_unix_local_lifecycle_state_across_pause_and_resume(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
install_mock_external_tools(monkeypatch, tmp_path)
|
||||
source_root = create_local_sources(tmp_path)
|
||||
manifest = build_manifest_with_all_entry_types(
|
||||
workspace_root=Path("/workspace"),
|
||||
source_root=source_root,
|
||||
)
|
||||
events: list[SandboxSessionEvent] = []
|
||||
client = UnixLocalSandboxClient(
|
||||
instrumentation=Instrumentation(
|
||||
sinks=[CallbackSink(lambda event, _session: events.append(event), mode="sync")]
|
||||
)
|
||||
)
|
||||
model = TestModel()
|
||||
model.queue_function_call(
|
||||
"assert_manifest_materialized",
|
||||
{},
|
||||
call_id="call_manifest_materialized",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"write_file",
|
||||
{"path": "runtime_note.txt", "content": "runtime note v1\n"},
|
||||
call_id="call_write_runtime_note",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"apply_lifecycle_patch",
|
||||
{},
|
||||
call_id="call_apply_lifecycle_patch",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"assert_workspace_escape_blocked",
|
||||
{},
|
||||
call_id="call_assert_workspace_escape_blocked",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"extract_lifecycle_archive",
|
||||
{},
|
||||
call_id="call_extract_lifecycle_archive",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"start_lifecycle_pty",
|
||||
{},
|
||||
call_id="call_start_lifecycle_pty",
|
||||
)
|
||||
model.queue_function_call("approval_tool", {}, call_id="call_approval")
|
||||
|
||||
@function_tool(name_override="approval_tool", needs_approval=True)
|
||||
def approval_tool() -> str:
|
||||
return "approved"
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="sandbox",
|
||||
model=model,
|
||||
instructions="Use the sandbox lifecycle tools.",
|
||||
default_manifest=manifest,
|
||||
tools=[approval_tool],
|
||||
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
||||
)
|
||||
|
||||
first_run = await Runner.run(
|
||||
agent,
|
||||
"verify the UnixLocal sandbox lifecycle and wait for approval",
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
||||
)
|
||||
|
||||
assert _tool_outputs(first_run.new_items, agent=agent) == [
|
||||
"manifest materialized",
|
||||
"wrote runtime_note.txt",
|
||||
"lifecycle patch applied",
|
||||
"workspace escape blocked",
|
||||
"archive extracted",
|
||||
"pty started and echoed stdin",
|
||||
]
|
||||
assert len(first_run.interruptions) == 1
|
||||
state = first_run.to_state()
|
||||
assert state._sandbox is not None
|
||||
assert state._sandbox["backend_id"] == "unix_local"
|
||||
assert state._sandbox["current_agent_name"] == "sandbox"
|
||||
session_state = state._sandbox["session_state"]
|
||||
assert isinstance(session_state, dict)
|
||||
snapshot = session_state["snapshot"]
|
||||
assert isinstance(snapshot, dict)
|
||||
assert snapshot["type"] == "local"
|
||||
assert session_state["workspace_root_owned"] is True
|
||||
assert session_state["workspace_root_ready"] is True
|
||||
workspace_root = _session_state_manifest_root(session_state)
|
||||
assert not workspace_root.exists()
|
||||
assert _successful_event_count(events, op="stop") == 1
|
||||
assert _successful_event_count(events, op="shutdown") == 1
|
||||
|
||||
resumed_model = TestModel()
|
||||
resumed_model.queue_function_call(
|
||||
"assert_restored_lifecycle_state",
|
||||
{},
|
||||
call_id="call_assert_restored_lifecycle_state",
|
||||
)
|
||||
resumed_model.queue_function_call(
|
||||
"read_file",
|
||||
{"path": "runtime_note.txt"},
|
||||
call_id="call_read_runtime_note",
|
||||
)
|
||||
resumed_model.queue_final_output("done")
|
||||
resumed_agent = SandboxAgent(
|
||||
name="sandbox",
|
||||
model=resumed_model,
|
||||
instructions="Use the sandbox lifecycle tools.",
|
||||
default_manifest=manifest,
|
||||
tools=[approval_tool],
|
||||
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
||||
)
|
||||
|
||||
restored_state = await RunState.from_json(resumed_agent, state.to_json())
|
||||
restored_interruptions = restored_state.get_interruptions()
|
||||
assert len(restored_interruptions) == 1
|
||||
restored_state.approve(restored_interruptions[0])
|
||||
|
||||
resumed = await Runner.run(
|
||||
resumed_agent,
|
||||
restored_state,
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
||||
)
|
||||
|
||||
assert resumed.final_output == "done"
|
||||
assert not workspace_root.exists()
|
||||
assert _successful_event_count(events, op="stop") == 2
|
||||
assert _successful_event_count(events, op="shutdown") == 2
|
||||
assert _tool_outputs(resumed.new_items, agent=resumed_agent)[-3:] == [
|
||||
"approved",
|
||||
"restored lifecycle state verified",
|
||||
"runtime note v1\n",
|
||||
]
|
||||
|
||||
|
||||
def _session_state_manifest_root(session_state: dict[str, object]) -> Path:
|
||||
manifest = session_state["manifest"]
|
||||
assert isinstance(manifest, dict)
|
||||
root = manifest["root"]
|
||||
assert isinstance(root, str)
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _successful_event_count(events: list[SandboxSessionEvent], *, op: str) -> int:
|
||||
return sum(
|
||||
1
|
||||
for event in events
|
||||
if event.op == op and event.phase == "finish" and getattr(event, "ok", False) is True
|
||||
)
|
||||
|
||||
|
||||
def _tool_outputs(items: Sequence[RunItem], *, agent: SandboxAgent) -> list[str]:
|
||||
outputs: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, ToolCallOutputItem) and item.agent is agent:
|
||||
assert isinstance(item.output, str)
|
||||
outputs.append(item.output)
|
||||
return outputs
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.editor import ApplyPatchOperation
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.errors import (
|
||||
ApplyPatchDecodeError,
|
||||
ApplyPatchDiffError,
|
||||
ApplyPatchFileNotFoundError,
|
||||
ApplyPatchPathError,
|
||||
)
|
||||
from tests.sandbox._apply_patch_test_session import (
|
||||
ApplyPatchSession,
|
||||
ProviderNotFoundApplyPatchSession,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_invalid_context_raises() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/bad.txt")] = b"alpha\nbeta\n"
|
||||
|
||||
with pytest.raises(ApplyPatchDiffError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="bad.txt",
|
||||
diff="@@\n missing\n-beta\n+gamma\n",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_uses_anchor_jump() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/anchor.txt")] = b"a\nb\nmarker\nc\nd\n"
|
||||
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="anchor.txt",
|
||||
diff="@@ marker\n c\n-d\n+e\n",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.files[Path("/workspace/anchor.txt")] == b"a\nb\nmarker\nc\ne\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_matches_end_of_file_context() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/tail.txt")] = b"one\ntwo\nthree\n"
|
||||
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="tail.txt",
|
||||
diff="@@\n two\n-three\n+four\n*** End of File\n",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.files[Path("/workspace/tail.txt")] == b"one\ntwo\nfour\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_missing_diff_raises() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchDiffError):
|
||||
await session.apply_patch(ApplyPatchOperation(type="update_file", path="file.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_missing_file_raises() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchFileNotFoundError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="missing.txt",
|
||||
diff="@@\n-old\n+new\n",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_delete_missing_file_raises() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchFileNotFoundError):
|
||||
await session.apply_patch(ApplyPatchOperation(type="delete_file", path="nope.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_missing_file_errors_use_workspace_path() -> None:
|
||||
session = ProviderNotFoundApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchFileNotFoundError) as update_exc:
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="missing.txt",
|
||||
diff="@@\n-old\n+new\n",
|
||||
)
|
||||
)
|
||||
|
||||
update_message = str(update_exc.value)
|
||||
assert update_message == "apply_patch missing file: missing.txt"
|
||||
assert update_exc.value.context["path"] == "missing.txt"
|
||||
assert "/provider/private/root" not in update_message
|
||||
|
||||
with pytest.raises(ApplyPatchFileNotFoundError) as delete_exc:
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(type="delete_file", path="missing-delete.txt")
|
||||
)
|
||||
|
||||
delete_message = str(delete_exc.value)
|
||||
assert delete_message == "apply_patch missing file: missing-delete.txt"
|
||||
assert delete_exc.value.context["path"] == "missing-delete.txt"
|
||||
assert "/provider/private/root" not in delete_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_rejects_escape_root_path() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchPathError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="../escape.txt",
|
||||
diff="+nope",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_rejects_empty_path() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchPathError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="",
|
||||
diff="+nope",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_allows_absolute_path_within_root() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="/workspace/abs-ok.txt",
|
||||
diff="+hello",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.files[Path("/workspace/abs-ok.txt")] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_rejects_absolute_path_outside_root() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchPathError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="/tmp/outside.txt",
|
||||
diff="+nope",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_create_requires_plus_lines() -> None:
|
||||
session = ApplyPatchSession()
|
||||
|
||||
with pytest.raises(ApplyPatchDiffError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="new.txt",
|
||||
diff="oops",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_rejects_invalid_diff_line_prefix() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/oops.txt")] = b"alpha\nbeta\n"
|
||||
|
||||
with pytest.raises(ApplyPatchDiffError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="oops.txt",
|
||||
diff="oops",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_update_non_utf8_payload_raises() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/binary.txt")] = b"\xff\xfe\xfd"
|
||||
|
||||
with pytest.raises(ApplyPatchDecodeError):
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="binary.txt",
|
||||
diff="@@\n+\n",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_uses_custom_patch_format() -> None:
|
||||
session = ApplyPatchSession()
|
||||
session.files[Path("/workspace/custom.txt")] = b"hello\nworld\n"
|
||||
|
||||
class StubFormat:
|
||||
@staticmethod
|
||||
def apply_diff(input: str, diff: str, mode: str = "default") -> str:
|
||||
del diff
|
||||
return input.replace("world", mode)
|
||||
|
||||
result = await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="custom.txt",
|
||||
diff="@@\n hello\n-world\n+ignored\n",
|
||||
),
|
||||
patch_format=StubFormat(),
|
||||
)
|
||||
|
||||
assert result == "Done!"
|
||||
assert session.files[Path("/workspace/custom.txt")] == b"hello\ndefault\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_patch_supports_non_default_root() -> None:
|
||||
session = ApplyPatchSession(Manifest(root="/custom-workspace"))
|
||||
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="new.txt",
|
||||
diff="+hello",
|
||||
)
|
||||
)
|
||||
|
||||
assert session.files[Path("/custom-workspace/new.txt")] == b"hello"
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.extensions.sandbox.cloudflare import CloudflareSandboxClientOptions
|
||||
from agents.extensions.sandbox.daytona import DaytonaSandboxClientOptions
|
||||
from agents.extensions.sandbox.e2b import E2BSandboxClientOptions
|
||||
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
|
||||
from agents.sandbox.sandboxes import DockerSandboxClientOptions, UnixLocalSandboxClientOptions
|
||||
from agents.sandbox.session import BaseSandboxClientOptions
|
||||
|
||||
|
||||
def test_sandbox_client_options_parse_uses_registered_builtin_type() -> None:
|
||||
parsed = BaseSandboxClientOptions.parse(
|
||||
{
|
||||
"type": "docker",
|
||||
"image": DEFAULT_PYTHON_SANDBOX_IMAGE,
|
||||
"exposed_ports": [8080],
|
||||
}
|
||||
)
|
||||
|
||||
assert parsed == DockerSandboxClientOptions(
|
||||
image=DEFAULT_PYTHON_SANDBOX_IMAGE, exposed_ports=(8080,)
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_client_options_parse_passthrough_existing_instance() -> None:
|
||||
options = UnixLocalSandboxClientOptions(exposed_ports=(8080,))
|
||||
|
||||
parsed = BaseSandboxClientOptions.parse(options)
|
||||
|
||||
assert parsed is options
|
||||
|
||||
|
||||
def test_sandbox_client_options_exclude_unset_preserves_type_discriminator() -> None:
|
||||
try:
|
||||
modal_module = importlib.import_module("agents.extensions.sandbox.modal")
|
||||
except ModuleNotFoundError:
|
||||
pytest.skip("modal is not installed")
|
||||
|
||||
payload = modal_module.ModalSandboxClientOptions(app_name="sandbox-tests").model_dump(
|
||||
exclude_unset=True
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"type": "modal",
|
||||
"app_name": "sandbox-tests",
|
||||
"sandbox_create_timeout_s": None,
|
||||
"workspace_persistence": "tar",
|
||||
"snapshot_filesystem_timeout_s": None,
|
||||
"snapshot_filesystem_restore_timeout_s": None,
|
||||
"exposed_ports": (),
|
||||
"gpu": None,
|
||||
"timeout": 300,
|
||||
"use_sleep_cmd": True,
|
||||
"image_builder_version": "2025.06",
|
||||
"idle_timeout": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[
|
||||
DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE, exposed_ports=(8080,)),
|
||||
UnixLocalSandboxClientOptions(exposed_ports=(8080,)),
|
||||
E2BSandboxClientOptions(sandbox_type="e2b", template="base"),
|
||||
DaytonaSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
|
||||
CloudflareSandboxClientOptions(worker_url="https://example.com"),
|
||||
],
|
||||
)
|
||||
def test_sandbox_client_options_roundtrip_preserves_concrete_type(
|
||||
options: BaseSandboxClientOptions,
|
||||
) -> None:
|
||||
payload = options.model_dump(mode="json")
|
||||
|
||||
restored = BaseSandboxClientOptions.parse(payload)
|
||||
|
||||
assert restored == options
|
||||
assert type(restored) is type(options)
|
||||
|
||||
|
||||
def test_sandbox_client_options_parse_rejects_unknown_type() -> None:
|
||||
with pytest.raises(ValueError, match="unknown sandbox client options type `unknown`"):
|
||||
BaseSandboxClientOptions.parse({"type": "unknown"})
|
||||
|
||||
|
||||
def test_sandbox_client_options_parse_rejects_invalid_payload() -> None:
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="sandbox client options payload must be a BaseSandboxClientOptions or object payload",
|
||||
):
|
||||
BaseSandboxClientOptions.parse("docker")
|
||||
|
||||
|
||||
def test_duplicate_sandbox_client_options_type_registration_raises() -> None:
|
||||
with pytest.raises(TypeError, match="already registered"):
|
||||
|
||||
class DuplicateDockerSandboxClientOptions(BaseSandboxClientOptions):
|
||||
type: Literal["docker"] = "docker"
|
||||
|
||||
|
||||
def test_sandbox_client_options_subclasses_require_type_discriminator_default() -> None:
|
||||
with pytest.raises(TypeError, match="must define a non-empty string default for `type`"):
|
||||
|
||||
class MissingTypeSandboxClientOptions(BaseSandboxClientOptions):
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.capabilities import CompactionModelInfo
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "context_window"),
|
||||
[
|
||||
("gpt-5.4", 1_047_576),
|
||||
("gpt-5.4-pro", 1_047_576),
|
||||
("gpt-5.5", 1_047_576),
|
||||
("gpt-5.5-2026-04-23", 1_047_576),
|
||||
("gpt-5.5-pro", 1_047_576),
|
||||
("gpt-5.5-pro-2026-04-23", 1_047_576),
|
||||
("gpt-5.6", 1_047_576),
|
||||
("gpt-5.6-sol", 1_047_576),
|
||||
("gpt-5.6-terra", 1_047_576),
|
||||
("gpt-5.6-luna", 1_047_576),
|
||||
("gpt-5.3-codex", 400_000),
|
||||
("gpt-5.4-mini", 400_000),
|
||||
("gpt-4.1", 1_047_576),
|
||||
("o3", 200_000),
|
||||
("gpt-4o", 128_000),
|
||||
("openai/gpt-5.4", 1_047_576),
|
||||
("openai/gpt-5.5", 1_047_576),
|
||||
("gpt-5-2", 400_000),
|
||||
("gpt-5-4", 1_047_576),
|
||||
("gpt-5-5", 1_047_576),
|
||||
("openai/gpt-5-4-mini", 400_000),
|
||||
("gpt-4-1-mini", 1_047_576),
|
||||
],
|
||||
)
|
||||
def test_compaction_model_info_for_model_returns_context_window(
|
||||
model: str,
|
||||
context_window: int,
|
||||
) -> None:
|
||||
assert CompactionModelInfo.for_model(model).context_window == context_window
|
||||
|
||||
|
||||
def test_compaction_model_info_for_model_rejects_unknown_model() -> None:
|
||||
with pytest.raises(ValueError, match="Unknown context window for model"):
|
||||
CompactionModelInfo.for_model("not-a-model")
|
||||
|
||||
|
||||
def test_compaction_model_info_maybe_for_model_returns_none_for_unknown_model() -> None:
|
||||
assert CompactionModelInfo.maybe_for_model("not-a-model") is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.session import (
|
||||
Dependencies,
|
||||
DependenciesBindingError,
|
||||
DependenciesMissingDependencyError,
|
||||
)
|
||||
|
||||
|
||||
class _AsyncClosable:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
class _AsyncCloseMethod:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def close(self) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
class _SyncClosable:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
self.calls += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_with_values_binds_multiple_values() -> None:
|
||||
key1 = "tests.with_values.str"
|
||||
key2 = "tests.with_values.int"
|
||||
dependencies = Dependencies.with_values({key1: "hello", key2: 123})
|
||||
|
||||
assert await dependencies.require(key1) == "hello"
|
||||
assert await dependencies.require(key2) == 123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_bind_value_and_require() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.value"
|
||||
dependencies.bind_value(key, "hello")
|
||||
|
||||
assert await dependencies.get(key) == "hello"
|
||||
assert await dependencies.require(key, consumer="test") == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_missing_dependency_includes_key_and_consumer() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.missing"
|
||||
|
||||
with pytest.raises(DependenciesMissingDependencyError, match="tests.missing"):
|
||||
await dependencies.require(key, consumer="SedimentFile")
|
||||
|
||||
|
||||
def test_dependencies_duplicate_binding_raises() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.dup"
|
||||
dependencies.bind_value(key, "a")
|
||||
|
||||
with pytest.raises(DependenciesBindingError, match="already bound"):
|
||||
dependencies.bind_value(key, "b")
|
||||
|
||||
|
||||
def test_dependencies_empty_key_raises() -> None:
|
||||
dependencies = Dependencies()
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
dependencies.bind_value("", "x")
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
dependencies.bind_factory("", lambda _dependencies: "x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_cached_factory_resolves_once() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.cached_factory"
|
||||
calls = 0
|
||||
|
||||
def _factory(_dependencies: Dependencies) -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return f"value-{calls}"
|
||||
|
||||
dependencies.bind_factory(key, _factory, cache=True)
|
||||
|
||||
assert await dependencies.require(key) == "value-1"
|
||||
assert await dependencies.require(key) == "value-1"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_uncached_factory_resolves_every_time() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.uncached_factory"
|
||||
calls = 0
|
||||
|
||||
def _factory(_dependencies: Dependencies) -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return f"value-{calls}"
|
||||
|
||||
dependencies.bind_factory(key, _factory, cache=False)
|
||||
|
||||
assert await dependencies.require(key) == "value-1"
|
||||
assert await dependencies.require(key) == "value-2"
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_async_factory_supported() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.async_factory"
|
||||
|
||||
async def _factory(_dependencies: Dependencies) -> str:
|
||||
return "async-value"
|
||||
|
||||
dependencies.bind_factory(key, _factory)
|
||||
assert await dependencies.require(key) == "async-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_aclose_closes_owned_results_and_is_idempotent() -> None:
|
||||
dependencies = Dependencies()
|
||||
k1 = "tests.async_aclose"
|
||||
k2 = "tests.async_close"
|
||||
k3 = "tests.sync_close"
|
||||
|
||||
dependencies.bind_factory(k1, lambda _deps: _AsyncClosable(), owns_result=True)
|
||||
dependencies.bind_factory(k2, lambda _deps: _AsyncCloseMethod(), owns_result=True)
|
||||
dependencies.bind_factory(k3, lambda _deps: _SyncClosable(), owns_result=True, cache=False)
|
||||
|
||||
v1 = await dependencies.require(k1)
|
||||
v2 = await dependencies.require(k2)
|
||||
v3a = await dependencies.require(k3)
|
||||
v3b = await dependencies.require(k3)
|
||||
|
||||
assert v3a is not v3b
|
||||
|
||||
await dependencies.aclose()
|
||||
await dependencies.aclose()
|
||||
|
||||
assert isinstance(v1, _AsyncClosable) and v1.calls == 1
|
||||
assert isinstance(v2, _AsyncCloseMethod) and v2.calls == 1
|
||||
assert isinstance(v3a, _SyncClosable) and v3a.calls == 1
|
||||
assert isinstance(v3b, _SyncClosable) and v3b.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependencies_bound_values_are_not_closed() -> None:
|
||||
dependencies = Dependencies()
|
||||
key = "tests.bound_value"
|
||||
value = _SyncClosable()
|
||||
dependencies.bind_value(key, value)
|
||||
|
||||
_ = await dependencies.require(key)
|
||||
await dependencies.aclose()
|
||||
|
||||
assert value.calls == 0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agents.sandbox.errors import (
|
||||
ErrorCode,
|
||||
ExecTimeoutError,
|
||||
GitCloneError,
|
||||
GitCopyError,
|
||||
SandboxError,
|
||||
SnapshotPersistError,
|
||||
SnapshotRestoreError,
|
||||
WorkspaceArchiveReadError,
|
||||
WorkspaceReadNotFoundError,
|
||||
WorkspaceStopError,
|
||||
WorkspaceWriteTypeError,
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_error_retryable_can_be_set_explicitly() -> None:
|
||||
error = SandboxError(
|
||||
message="backend is unavailable",
|
||||
error_code=ErrorCode.EXEC_TRANSPORT_ERROR,
|
||||
op="exec",
|
||||
context={},
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
assert error.retryable is True
|
||||
|
||||
|
||||
def test_wrapped_sandbox_error_inherits_retryable_from_cause() -> None:
|
||||
cause = WorkspaceArchiveReadError(
|
||||
path=Path("/workspace"),
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
error = WorkspaceStopError(path=Path("/workspace"), cause=cause)
|
||||
|
||||
assert error.retryable is False
|
||||
|
||||
|
||||
def test_deterministic_sandbox_errors_are_non_retryable() -> None:
|
||||
assert WorkspaceReadNotFoundError(path=Path("/workspace/missing.txt")).retryable is False
|
||||
assert (
|
||||
WorkspaceWriteTypeError(path=Path("/workspace/out.txt"), actual_type="str").retryable
|
||||
is False
|
||||
)
|
||||
assert ExecTimeoutError(command=("python", "script.py"), timeout_s=1.0).retryable is False
|
||||
|
||||
|
||||
def test_broad_archive_errors_default_to_unknown_retryability() -> None:
|
||||
error = WorkspaceArchiveReadError(path=Path("/workspace"))
|
||||
|
||||
assert error.retryable is None
|
||||
|
||||
|
||||
def test_broad_materialization_and_snapshot_errors_default_to_unknown_retryability() -> None:
|
||||
assert GitCloneError(url="https://example.test/repo.git", ref="main").retryable is None
|
||||
assert GitCopyError(src_root="/tmp/repo", dest=Path("/workspace")).retryable is None
|
||||
assert SnapshotPersistError(snapshot_id="snap", path=Path("/tmp/snap")).retryable is None
|
||||
assert SnapshotRestoreError(snapshot_id="snap", path=Path("/tmp/snap")).retryable is None
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.errors import ExposedPortUnavailableError
|
||||
from agents.sandbox.sandboxes import UnixLocalSandboxClient, UnixLocalSandboxClientOptions
|
||||
from agents.sandbox.types import ExposedPortEndpoint
|
||||
|
||||
|
||||
def test_exposed_port_endpoint_formats_urls() -> None:
|
||||
insecure = ExposedPortEndpoint(host="127.0.0.1", port=8765, tls=False)
|
||||
secure = ExposedPortEndpoint(host="sandbox.example.test", port=443, tls=True)
|
||||
|
||||
assert insecure.url_for("http") == "http://127.0.0.1:8765/"
|
||||
assert insecure.url_for("ws") == "ws://127.0.0.1:8765/"
|
||||
assert secure.url_for("http") == "https://sandbox.example.test/"
|
||||
assert secure.url_for("ws") == "wss://sandbox.example.test/"
|
||||
|
||||
|
||||
def test_exposed_port_endpoint_with_query() -> None:
|
||||
endpoint = ExposedPortEndpoint(
|
||||
host="preview.example.com",
|
||||
port=443,
|
||||
tls=True,
|
||||
query="bl_preview_token=abc123",
|
||||
)
|
||||
assert endpoint.url_for("http") == "https://preview.example.com/?bl_preview_token=abc123"
|
||||
assert endpoint.url_for("ws") == "wss://preview.example.com/?bl_preview_token=abc123"
|
||||
|
||||
|
||||
def test_exposed_port_endpoint_accepts_leading_question_mark_query() -> None:
|
||||
endpoint = ExposedPortEndpoint(
|
||||
host="preview.example.com",
|
||||
port=443,
|
||||
tls=True,
|
||||
query="?bl_preview_token=abc123",
|
||||
)
|
||||
|
||||
assert endpoint.url_for("http") == "https://preview.example.com/?bl_preview_token=abc123"
|
||||
assert endpoint.url_for("ws") == "wss://preview.example.com/?bl_preview_token=abc123"
|
||||
|
||||
|
||||
def test_exposed_port_endpoint_empty_query() -> None:
|
||||
endpoint = ExposedPortEndpoint(host="127.0.0.1", port=8080, tls=False, query="")
|
||||
assert endpoint.url_for("http") == "http://127.0.0.1:8080/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_resolve_exposed_port_uses_wrapper_and_normalizes_state() -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
session = await client.create(
|
||||
options=UnixLocalSandboxClientOptions(exposed_ports=(8765, 8765)),
|
||||
)
|
||||
|
||||
try:
|
||||
endpoint = await session.resolve_exposed_port(8765)
|
||||
finally:
|
||||
await session.aclose()
|
||||
await client.delete(session)
|
||||
|
||||
assert session.state.exposed_ports == (8765,)
|
||||
assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=8765, tls=False)
|
||||
assert endpoint.url_for("ws") == "ws://127.0.0.1:8765/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_resolve_exposed_port_rejects_undeclared_ports() -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
session = await client.create(
|
||||
options=UnixLocalSandboxClientOptions(exposed_ports=(8765,)),
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(ExposedPortUnavailableError) as exc_info:
|
||||
await session.resolve_exposed_port(9000)
|
||||
finally:
|
||||
await session.aclose()
|
||||
await client.delete(session)
|
||||
|
||||
assert exc_info.value.context["reason"] == "not_configured"
|
||||
assert exc_info.value.context["exposed_ports"] == [8765]
|
||||
@@ -0,0 +1,857 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox import SandboxArchiveLimits
|
||||
from agents.sandbox.entries import GCSMount, InContainerMountStrategy, MountpointMountPattern
|
||||
from agents.sandbox.errors import (
|
||||
InvalidCompressionSchemeError,
|
||||
InvalidManifestPathError,
|
||||
WorkspaceArchiveWriteError,
|
||||
)
|
||||
from agents.sandbox.files import EntryKind, FileEntry
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.session.archive_extraction import zipfile_compatible_stream
|
||||
from agents.sandbox.session.archive_ops import extract_archive
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, Permissions
|
||||
|
||||
|
||||
def _build_session(tmp_path: Path) -> UnixLocalSandboxSession:
|
||||
state = UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(tmp_path / "workspace")),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
return UnixLocalSandboxSession.from_state(state)
|
||||
|
||||
|
||||
class _CountingExtractSession(BaseSandboxSession):
|
||||
def __init__(self, workspace_root: Path) -> None:
|
||||
self.state = UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(workspace_root)),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
self.ls_calls: list[Path] = []
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = (command, timeout)
|
||||
raise AssertionError("exec() should not be called in this test")
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
|
||||
_ = user
|
||||
return self.normalize_path(path).open("rb")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = user
|
||||
workspace_path = self.normalize_path(path)
|
||||
workspace_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = data.read()
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode("utf-8")
|
||||
workspace_path.write_bytes(payload)
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return
|
||||
|
||||
async def mkdir(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
parents: bool = False,
|
||||
user: object = None,
|
||||
) -> None:
|
||||
_ = user
|
||||
self.normalize_path(path).mkdir(parents=parents, exist_ok=True)
|
||||
|
||||
async def ls(
|
||||
self,
|
||||
path: Path | str,
|
||||
*,
|
||||
user: object = None,
|
||||
) -> list[FileEntry]:
|
||||
_ = user
|
||||
directory = self.normalize_path(path)
|
||||
self.ls_calls.append(directory)
|
||||
if not directory.exists():
|
||||
raise AssertionError(f"ls() called for missing directory: {directory}")
|
||||
|
||||
entries: list[FileEntry] = []
|
||||
for child in directory.iterdir():
|
||||
if child.is_symlink():
|
||||
kind = EntryKind.SYMLINK
|
||||
elif child.is_dir():
|
||||
kind = EntryKind.DIRECTORY
|
||||
else:
|
||||
kind = EntryKind.FILE
|
||||
entries.append(
|
||||
FileEntry(
|
||||
path=str(child),
|
||||
permissions=Permissions(),
|
||||
owner="root",
|
||||
group="root",
|
||||
size=0,
|
||||
kind=kind,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _tar_bytes(*, members: dict[str, bytes]) -> io.BytesIO:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as archive:
|
||||
for name, payload in members.items():
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
def _zip_bytes(*, members: dict[str, bytes]) -> io.BytesIO:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, mode="w") as archive:
|
||||
for name, payload in members.items():
|
||||
archive.writestr(name, payload)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
async def _assert_extract_rejects_member(
|
||||
tmp_path: Path,
|
||||
archive_name: str,
|
||||
data: io.IOBase,
|
||||
*,
|
||||
expected_member: str,
|
||||
expected_reason: str,
|
||||
) -> Path:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
workspace = Path(session.state.manifest.root)
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(archive_name, data)
|
||||
|
||||
assert exc_info.value.context["member"] == expected_member
|
||||
assert exc_info.value.context["reason"] == expected_reason
|
||||
return workspace
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_writes_archive_and_unpacks_contents(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"nested/hello.txt": b"hello from tar"}),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "bundle.tar").is_file()
|
||||
assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from tar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_writes_archive_and_unpacks_contents(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={"nested/hello.txt": b"hello from zip"}),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "bundle.zip").is_file()
|
||||
assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from zip"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_default_archive_limits_none_preserves_no_resource_limit_behavior(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
|
||||
assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"
|
||||
|
||||
|
||||
def test_sandbox_archive_limits_defaults_enable_sdk_thresholds() -> None:
|
||||
limits = SandboxArchiveLimits()
|
||||
|
||||
assert limits.max_input_bytes == 1024 * 1024 * 1024
|
||||
assert limits.max_extracted_bytes == 4 * 1024 * 1024 * 1024
|
||||
assert limits.max_members == 100_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_archive_rejects_missing_compression_scheme(tmp_path: Path) -> None:
|
||||
session = _CountingExtractSession(tmp_path / "workspace")
|
||||
|
||||
with pytest.raises(InvalidCompressionSchemeError) as exc_info:
|
||||
await extract_archive(session, "bundle", io.BytesIO(b"not an archive"))
|
||||
|
||||
assert exc_info.value.context["path"] == "bundle"
|
||||
assert exc_info.value.context["scheme"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "message"),
|
||||
[
|
||||
({"max_input_bytes": 0}, "archive_limits.max_input_bytes must be at least 1"),
|
||||
({"max_extracted_bytes": 0}, "archive_limits.max_extracted_bytes must be at least 1"),
|
||||
({"max_members": 0}, "archive_limits.max_members must be at least 1"),
|
||||
],
|
||||
)
|
||||
def test_sandbox_archive_limits_rejects_non_positive_values(
|
||||
kwargs: dict[str, int],
|
||||
message: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
SandboxArchiveLimits(**kwargs)
|
||||
|
||||
assert str(exc_info.value) == message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_rejects_archive_input_over_limit(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.zip",
|
||||
_ChunkedBinaryStream([b"123", b"45"]),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=4,
|
||||
max_extracted_bytes=None,
|
||||
max_members=None,
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["reason"] == "archive input size exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 4
|
||||
assert exc_info.value.context["actual"] == 5
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert not (workspace / "bundle.zip").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_extracted_bytes_over_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"large.txt": b"12345"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=4,
|
||||
max_members=None,
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "large.txt"
|
||||
assert exc_info.value.context["reason"] == "archive extracted size exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 4
|
||||
assert exc_info.value.context["actual"] == 5
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert not (workspace / "large.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_extracted_bytes_over_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={"large.txt": b"12345"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=4,
|
||||
max_members=None,
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "large.txt"
|
||||
assert exc_info.value.context["reason"] == "archive extracted size exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 4
|
||||
assert exc_info.value.context["actual"] == 5
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert not (workspace / "large.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_member_count_over_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
data = _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"})
|
||||
|
||||
def fail_getmembers(_self: tarfile.TarFile) -> list[tarfile.TarInfo]:
|
||||
raise AssertionError("tar extraction should not materialize all members")
|
||||
|
||||
monkeypatch.setattr(tarfile.TarFile, "getmembers", fail_getmembers)
|
||||
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
data,
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=None,
|
||||
max_members=1,
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "two.txt"
|
||||
assert exc_info.value.context["reason"] == "archive member count exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 1
|
||||
assert exc_info.value.context["actual"] == 2
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert not (workspace / "one.txt").exists()
|
||||
assert not (workspace / "two.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_member_count_over_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=None,
|
||||
max_members=1,
|
||||
),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "two.txt"
|
||||
assert exc_info.value.context["reason"] == "archive member count exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 1
|
||||
assert exc_info.value.context["actual"] == 2
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert not (workspace / "one.txt").exists()
|
||||
assert not (workspace / "two.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_archive_limits_none_disables_only_selected_limits(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"large.txt": b"12345"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=None,
|
||||
max_members=1,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "large.txt").read_text(encoding="utf-8") == "12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_archive_limits_per_call_override_session_default(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
session._set_archive_limits(
|
||||
SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=None,
|
||||
max_members=2,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
|
||||
assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_uses_session_default_archive_limits(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
session._set_archive_limits(
|
||||
SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "two.txt"
|
||||
assert exc_info.value.context["reason"] == "archive member count exceeds limit"
|
||||
assert exc_info.value.context["limit"] == 1
|
||||
assert exc_info.value.context["actual"] == 2
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_archive_limits_object_with_all_none_overrides_session_default(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
session._set_archive_limits(
|
||||
SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1)
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}),
|
||||
archive_limits=SandboxArchiveLimits(
|
||||
max_input_bytes=None,
|
||||
max_extracted_bytes=None,
|
||||
max_members=None,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "one.txt").read_text(encoding="utf-8") == "1"
|
||||
assert (workspace / "two.txt").read_text(encoding="utf-8") == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_rejects_invalid_per_call_archive_limits(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
limits = SandboxArchiveLimits(max_input_bytes=1)
|
||||
limits.max_input_bytes = 0
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"one.txt": b"1"}),
|
||||
archive_limits=limits,
|
||||
)
|
||||
|
||||
assert str(exc_info.value) == "archive_limits.max_input_bytes must be at least 1"
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
class _NoSeekableZipStream(io.IOBase):
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
self._buffer = io.BytesIO(payload)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._buffer.tell()
|
||||
|
||||
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
||||
return self._buffer.seek(offset, whence)
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._buffer.read(size)
|
||||
|
||||
|
||||
class _ChunkedBinaryStream(io.IOBase):
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self.headers = {"Content-Length": str(sum(len(chunk) for chunk in chunks))}
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if not self._chunks:
|
||||
return b""
|
||||
if size < 0:
|
||||
data = b"".join(self._chunks)
|
||||
self._chunks.clear()
|
||||
return data
|
||||
|
||||
remaining = size
|
||||
out = bytearray()
|
||||
while remaining > 0 and self._chunks:
|
||||
chunk = self._chunks[0]
|
||||
if len(chunk) <= remaining:
|
||||
out.extend(self._chunks.pop(0))
|
||||
remaining -= len(chunk)
|
||||
continue
|
||||
out.extend(chunk[:remaining])
|
||||
self._chunks[0] = chunk[remaining:]
|
||||
remaining = 0
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class _SeekableFalseZipStream(io.IOBase):
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
self._buffer = io.BytesIO(payload)
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return False
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._buffer.read(size)
|
||||
|
||||
|
||||
def test_zipfile_compatible_stream_supports_streams_without_seekable() -> None:
|
||||
raw_stream = _NoSeekableZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue())
|
||||
|
||||
with zipfile_compatible_stream(raw_stream) as compatible:
|
||||
assert compatible.seekable() is True
|
||||
with zipfile.ZipFile(compatible) as archive:
|
||||
assert archive.read("file.txt") == b"hello"
|
||||
|
||||
|
||||
def test_zipfile_compatible_stream_buffers_streams_with_seekable_false() -> None:
|
||||
raw_stream = _SeekableFalseZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue())
|
||||
|
||||
with zipfile_compatible_stream(raw_stream) as compatible:
|
||||
assert compatible.seekable() is True
|
||||
with zipfile.ZipFile(compatible) as archive:
|
||||
assert archive.read("file.txt") == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_write_accepts_chunked_non_seekable_binary_stream(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
await session.write(
|
||||
Path("streamed.bin"),
|
||||
_ChunkedBinaryStream([b"hello ", b"from ", b"stream"]),
|
||||
)
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
workspace = Path(session.state.manifest.root)
|
||||
assert (workspace / "streamed.bin").read_bytes() == b"hello from stream"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_symlinked_parent_paths(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
workspace = Path(session.state.manifest.root)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, workspace / "link", target_is_directory=True)
|
||||
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"link/hello.txt": b"hello from tar"}),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "link/hello.txt"
|
||||
assert exc_info.value.context["reason"] == "symlink in parent path: link"
|
||||
assert not (outside / "hello.txt").exists()
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_symlinked_parent_paths(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
workspace = Path(session.state.manifest.root)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, workspace / "link", target_is_directory=True)
|
||||
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.extract(
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={"link/hello.txt": b"hello from zip"}),
|
||||
)
|
||||
|
||||
assert exc_info.value.context["member"] == "link/hello.txt"
|
||||
assert exc_info.value.context["reason"] == "symlink in parent path: link"
|
||||
assert not (outside / "hello.txt").exists()
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_hydrate_workspace_rejects_external_symlink_targets(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||||
info = tarfile.TarInfo(name="leak")
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = "/etc/passwd"
|
||||
tar.addfile(info)
|
||||
archive.seek(0)
|
||||
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
await session.hydrate_workspace(archive)
|
||||
|
||||
assert exc_info.value.context["member"] == "leak"
|
||||
assert (
|
||||
exc_info.value.context["reason"] == "absolute symlink target not allowed: /etc/passwd"
|
||||
)
|
||||
assert not (Path(session.state.manifest.root) / "leak").exists()
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_windows_drive_member_paths(tmp_path: Path) -> None:
|
||||
await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={"C:/tmp/evil.txt": b"evil"}),
|
||||
expected_member="C:/tmp/evil.txt",
|
||||
expected_reason="windows drive path",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_windows_drive_member_paths(tmp_path: Path) -> None:
|
||||
await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={r"C:\tmp\evil.txt": b"evil"}),
|
||||
expected_member=r"C:\tmp\evil.txt",
|
||||
expected_reason="windows drive path",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_windows_separator_member_paths(tmp_path: Path) -> None:
|
||||
await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.tar",
|
||||
_tar_bytes(members={r"..\evil.txt": b"evil"}),
|
||||
expected_member=r"..\evil.txt",
|
||||
expected_reason="windows path separator",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_windows_separator_member_paths(tmp_path: Path) -> None:
|
||||
await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.zip",
|
||||
_zip_bytes(members={r"\evil.txt": b"evil"}),
|
||||
expected_member=r"\evil.txt",
|
||||
expected_reason="windows path separator",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_rejects_member_under_non_directory_member(tmp_path: Path) -> None:
|
||||
workspace = await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.tar",
|
||||
_tar_bytes(
|
||||
members={
|
||||
"nested/hello.txt": b"hello from tar",
|
||||
"nested": b"not a directory",
|
||||
}
|
||||
),
|
||||
expected_member="nested/hello.txt",
|
||||
expected_reason="archive path descends through non-directory: nested",
|
||||
)
|
||||
|
||||
assert not (workspace / "nested").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_zip_rejects_member_under_non_directory_member(tmp_path: Path) -> None:
|
||||
workspace = await _assert_extract_rejects_member(
|
||||
tmp_path,
|
||||
"bundle.zip",
|
||||
_zip_bytes(
|
||||
members={
|
||||
"nested/hello.txt": b"hello from zip",
|
||||
"nested": b"not a directory",
|
||||
}
|
||||
),
|
||||
expected_member="nested/hello.txt",
|
||||
expected_reason="archive path descends through non-directory: nested",
|
||||
)
|
||||
|
||||
assert not (workspace / "nested").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_persist_workspace_excludes_resolved_mount_path(tmp_path: Path) -> None:
|
||||
workspace_root = tmp_path / "workspace"
|
||||
actual_mount_path = workspace_root / "actual"
|
||||
actual_mount_path.mkdir(parents=True)
|
||||
(actual_mount_path / "remote.txt").write_text("remote", encoding="utf-8")
|
||||
(workspace_root / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
|
||||
state = UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(
|
||||
root=str(workspace_root),
|
||||
entries={
|
||||
"logical": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("actual"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
},
|
||||
),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
session = UnixLocalSandboxSession.from_state(state)
|
||||
|
||||
archive = await session.persist_workspace()
|
||||
|
||||
with tarfile.open(fileobj=archive, mode="r:*") as tar:
|
||||
names = set(tar.getnames())
|
||||
|
||||
assert "./keep.txt" in names
|
||||
assert "./actual" not in names
|
||||
assert "./actual/remote.txt" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tar_reuses_directory_listings_during_symlink_checks(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
session = _CountingExtractSession(workspace)
|
||||
|
||||
await session.extract(
|
||||
"bundle.tar",
|
||||
_tar_bytes(
|
||||
members={
|
||||
"nested/one.txt": b"one",
|
||||
"nested/two.txt": b"two",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert (workspace / "nested" / "one.txt").read_text(encoding="utf-8") == "one"
|
||||
assert (workspace / "nested" / "two.txt").read_text(encoding="utf-8") == "two"
|
||||
assert session.ls_calls == [
|
||||
workspace,
|
||||
workspace / "nested",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_helpers_reject_paths_outside_workspace_root(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
await session.ls("../outside")
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
await session.mkdir("../outside", parents=True)
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
await session.rm("../outside")
|
||||
with pytest.raises(InvalidManifestPathError, match="must be relative"):
|
||||
await session.extract("/tmp/bundle.tar", _tar_bytes(members={"a.txt": b"a"}))
|
||||
finally:
|
||||
await session.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_local_helpers_reject_symlink_escape_paths(tmp_path: Path) -> None:
|
||||
session = _build_session(tmp_path)
|
||||
await session.start()
|
||||
try:
|
||||
workspace = Path(session.state.manifest.root)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, workspace / "link", target_is_directory=True)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
await session.mkdir("link/nested", parents=True)
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
await session.ls("link")
|
||||
finally:
|
||||
await session.shutdown()
|
||||
@@ -0,0 +1,214 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.entries import (
|
||||
Dir,
|
||||
File,
|
||||
GCSMount,
|
||||
InContainerMountStrategy,
|
||||
MountpointMountPattern,
|
||||
)
|
||||
from agents.sandbox.errors import InvalidManifestPathError
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.manifest_render import _truncate_manifest_description
|
||||
|
||||
|
||||
def test_manifest_rejects_nested_child_paths_that_escape_workspace() -> None:
|
||||
manifest = Manifest(
|
||||
entries={
|
||||
"safe": Dir(
|
||||
children={
|
||||
"../outside.txt": File(content=b"nope"),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
manifest.validated_entries()
|
||||
|
||||
|
||||
def test_manifest_rejects_nested_absolute_child_paths() -> None:
|
||||
manifest = Manifest(
|
||||
entries={
|
||||
"safe": Dir(
|
||||
children={
|
||||
"/tmp/outside.txt": File(content=b"nope"),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError, match="must be relative"):
|
||||
manifest.validated_entries()
|
||||
|
||||
|
||||
def test_manifest_rejects_windows_drive_absolute_entry_paths() -> None:
|
||||
manifest = Manifest(entries={"C:\\tmp\\outside.txt": File(content=b"nope")})
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
manifest.validated_entries()
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/outside.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/outside.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_manifest_ephemeral_entry_paths_include_nested_children() -> None:
|
||||
manifest = Manifest(
|
||||
entries={
|
||||
"dir": Dir(
|
||||
children={
|
||||
"keep.txt": File(content=b"keep"),
|
||||
"tmp.txt": File(content=b"tmp", ephemeral=True),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assert manifest.ephemeral_entry_paths() == {Path("dir/tmp.txt")}
|
||||
|
||||
|
||||
def test_manifest_ephemeral_persistence_paths_include_resolved_mount_targets() -> None:
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"logical": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("actual"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
),
|
||||
"dir": Dir(
|
||||
children={
|
||||
"tmp.txt": File(content=b"tmp", ephemeral=True),
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
assert manifest.ephemeral_persistence_paths() == {
|
||||
Path("logical"),
|
||||
Path("actual"),
|
||||
Path("dir/tmp.txt"),
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_ephemeral_mount_targets_sort_by_resolved_depth() -> None:
|
||||
parent = GCSMount(
|
||||
bucket="parent",
|
||||
mount_path=Path("repo"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
child = GCSMount(
|
||||
bucket="child",
|
||||
mount_path=Path("repo/sub"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"parent": parent,
|
||||
"nested": Dir(children={"child": child}),
|
||||
},
|
||||
)
|
||||
|
||||
assert manifest.ephemeral_mount_targets() == [
|
||||
(child, Path("/workspace/repo/sub")),
|
||||
(parent, Path("/workspace/repo")),
|
||||
]
|
||||
|
||||
|
||||
def test_manifest_ephemeral_mount_targets_normalize_non_escaping_mount_paths() -> None:
|
||||
mount = GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("/workspace/repo/../actual"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
manifest = Manifest(root="/workspace", entries={"logical": mount})
|
||||
|
||||
assert manifest.ephemeral_mount_targets() == [
|
||||
(mount, Path("/workspace/actual")),
|
||||
]
|
||||
assert manifest.ephemeral_persistence_paths() == {
|
||||
Path("logical"),
|
||||
Path("actual"),
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_ephemeral_mount_targets_reject_escaping_mount_paths() -> None:
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"logical": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("/workspace/../../tmp"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
manifest.ephemeral_mount_targets()
|
||||
|
||||
with pytest.raises(InvalidManifestPathError, match="must not escape root"):
|
||||
manifest.ephemeral_persistence_paths()
|
||||
|
||||
|
||||
def test_manifest_ephemeral_mount_targets_reject_windows_drive_mount_path() -> None:
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"logical": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("C:\\tmp\\mount"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
manifest.ephemeral_mount_targets()
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/mount"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/mount", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_manifest_describe_preserves_tree_rendering_after_renderer_extract() -> None:
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"repo": Dir(
|
||||
description="project root",
|
||||
children={
|
||||
"README.md": File(content=b"hi", description="overview"),
|
||||
},
|
||||
),
|
||||
"data": GCSMount(
|
||||
bucket="bucket",
|
||||
description="shared data",
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
description = manifest.describe(depth=2)
|
||||
|
||||
assert description.startswith("/workspace\n")
|
||||
assert "data/" in description
|
||||
assert "/workspace/data" in description
|
||||
assert "repo/" in description
|
||||
assert "/workspace/repo/README.md" in description
|
||||
|
||||
|
||||
def test_manifest_description_truncation_respects_short_limits() -> None:
|
||||
description = "0123456789" * 20
|
||||
|
||||
for max_chars in range(0, 40):
|
||||
truncated = _truncate_manifest_description(description, max_chars)
|
||||
assert len(truncated) <= max_chars
|
||||
|
||||
|
||||
def test_manifest_description_truncation_preserves_unbounded_description() -> None:
|
||||
description = "short"
|
||||
|
||||
assert _truncate_manifest_description(description, None) == description
|
||||
@@ -0,0 +1,453 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import agents.sandbox.session.manifest_application as manifest_application_module
|
||||
from agents.sandbox.entries import (
|
||||
Dir,
|
||||
File,
|
||||
GCSMount,
|
||||
InContainerMountStrategy,
|
||||
MountpointMountPattern,
|
||||
)
|
||||
from agents.sandbox.errors import ExecNonZeroError
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.materialization import MaterializedFile
|
||||
from agents.sandbox.session.manifest_application import ManifestApplier
|
||||
from agents.sandbox.types import ExecResult, Group, User
|
||||
|
||||
|
||||
def _materialized(dest: Path) -> list[MaterializedFile]:
|
||||
return [MaterializedFile(path=dest, sha256=dest.as_posix())]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_only_applies_ephemeral_entries_without_account_provisioning() -> (
|
||||
None
|
||||
):
|
||||
mkdir_calls: list[Path] = []
|
||||
exec_calls: list[tuple[str, ...]] = []
|
||||
apply_calls: list[tuple[str, Path, Path]] = []
|
||||
|
||||
async def mkdir(path: Path) -> None:
|
||||
mkdir_calls.append(path)
|
||||
|
||||
async def exec_checked_nonzero(*command: str) -> ExecResult:
|
||||
exec_calls.append(command)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(entry: object, dest: Path, base_dir: Path) -> list[MaterializedFile]:
|
||||
apply_calls.append((type(entry).__name__, dest, base_dir))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"keep.txt": File(content=b"keep"),
|
||||
"tmp.txt": File(content=b"tmp", ephemeral=True),
|
||||
},
|
||||
users=[User(name="alice")],
|
||||
groups=[Group(name="dev", users=[User(name="alice")])],
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(manifest, only_ephemeral=True)
|
||||
|
||||
assert mkdir_calls == [Path("/workspace")]
|
||||
assert exec_calls == []
|
||||
assert apply_calls == [("File", Path("/workspace/tmp.txt"), Path("/"))]
|
||||
assert result.files == _materialized(Path("/workspace/tmp.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_only_ephemeral_reapplies_nested_ephemeral_children() -> None:
|
||||
apply_calls: list[tuple[str, Path, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(entry: object, dest: Path, base_dir: Path) -> list[MaterializedFile]:
|
||||
apply_calls.append((type(entry).__name__, dest, base_dir))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"dir": Dir(
|
||||
children={
|
||||
"keep.txt": File(content=b"keep"),
|
||||
"tmp.txt": File(content=b"tmp", ephemeral=True),
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(manifest, only_ephemeral=True)
|
||||
|
||||
assert apply_calls == [("File", Path("/workspace/dir/tmp.txt"), Path("/"))]
|
||||
assert result.files == _materialized(Path("/workspace/dir/tmp.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_only_ephemeral_reapplies_full_ephemeral_directories() -> None:
|
||||
applied_entries: list[tuple[object, Path, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(entry: object, dest: Path, base_dir: Path) -> list[MaterializedFile]:
|
||||
applied_entries.append((entry, dest, base_dir))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"tmp": Dir(
|
||||
ephemeral=True,
|
||||
children={
|
||||
"keep.txt": File(content=b"keep"),
|
||||
"nested": Dir(children={"child.txt": File(content=b"child")}),
|
||||
"tmp.txt": File(content=b"tmp", ephemeral=True),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(manifest, only_ephemeral=True)
|
||||
|
||||
assert len(applied_entries) == 1
|
||||
entry, dest, base_dir = applied_entries[0]
|
||||
assert isinstance(entry, Dir)
|
||||
assert dest == Path("/workspace/tmp")
|
||||
assert base_dir == Path("/")
|
||||
assert set(entry.children) == {"keep.txt", "nested", "tmp.txt"}
|
||||
assert result.files == _materialized(Path("/workspace/tmp"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_respects_explicit_base_dir() -> None:
|
||||
apply_calls: list[tuple[str, Path, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(entry: object, dest: Path, base_dir: Path) -> list[MaterializedFile]:
|
||||
apply_calls.append((type(entry).__name__, dest, base_dir))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(entries={"file.txt": File(content=b"hello")})
|
||||
|
||||
result = await applier.apply_manifest(manifest, base_dir=Path("/tmp/project"))
|
||||
|
||||
assert apply_calls == [("File", Path("/workspace/file.txt"), Path("/tmp/project"))]
|
||||
assert result.files == _materialized(Path("/workspace/file.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_caps_parallel_entry_batch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
observed_limits: list[int | None] = []
|
||||
|
||||
async def gather_with_limit_recording(
|
||||
task_factories: Sequence[Callable[[], Awaitable[list[MaterializedFile]]]],
|
||||
*,
|
||||
max_concurrency: int | None = None,
|
||||
) -> list[list[MaterializedFile]]:
|
||||
observed_limits.append(max_concurrency)
|
||||
return [await factory() for factory in task_factories]
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(_entry: object, dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
return _materialized(dest)
|
||||
|
||||
monkeypatch.setattr(
|
||||
manifest_application_module,
|
||||
"gather_in_order",
|
||||
gather_with_limit_recording,
|
||||
)
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
max_entry_concurrency=2,
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(
|
||||
Manifest(entries={"a.txt": File(content=b"a"), "b.txt": File(content=b"b")})
|
||||
)
|
||||
|
||||
assert observed_limits == [2]
|
||||
assert result.files == [
|
||||
MaterializedFile(path=Path("/workspace/a.txt"), sha256="/workspace/a.txt"),
|
||||
MaterializedFile(path=Path("/workspace/b.txt"), sha256="/workspace/b.txt"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_provisions_groups_and_unique_users_before_entries() -> None:
|
||||
exec_calls: list[tuple[str, ...]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*command: str) -> ExecResult:
|
||||
exec_calls.append(command)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(_entry: object, _dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
return []
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(
|
||||
users=[User(name="alice")],
|
||||
groups=[Group(name="dev", users=[User(name="alice"), User(name="bob")])],
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(manifest)
|
||||
|
||||
assert result.files == []
|
||||
assert exec_calls[0] == ("groupadd", "dev")
|
||||
assert exec_calls.count(("groupadd", "alice")) == 0
|
||||
assert exec_calls.count(("groupadd", "bob")) == 0
|
||||
assert ("useradd", "-U", "-M", "-s", "/usr/sbin/nologin", "alice") in exec_calls
|
||||
assert ("useradd", "-U", "-M", "-s", "/usr/sbin/nologin", "bob") in exec_calls
|
||||
assert ("usermod", "-aG", "dev", "alice") in exec_calls
|
||||
assert ("usermod", "-aG", "dev", "bob") in exec_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_can_apply_full_manifest_without_account_provisioning() -> None:
|
||||
exec_calls: list[tuple[str, ...]] = []
|
||||
apply_calls: list[tuple[str, Path, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*command: str) -> ExecResult:
|
||||
exec_calls.append(command)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(entry: object, dest: Path, base_dir: Path) -> list[MaterializedFile]:
|
||||
apply_calls.append((type(entry).__name__, dest, base_dir))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(
|
||||
entries={"file.txt": File(content=b"hello")},
|
||||
users=[User(name="alice")],
|
||||
groups=[Group(name="dev", users=[User(name="alice")])],
|
||||
)
|
||||
|
||||
result = await applier.apply_manifest(manifest, provision_accounts=False)
|
||||
|
||||
assert exec_calls == []
|
||||
assert apply_calls == [("File", Path("/workspace/file.txt"), Path("/"))]
|
||||
assert result.files == _materialized(Path("/workspace/file.txt"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_raises_with_command_stdout_and_stderr_on_provision_failure() -> (
|
||||
None
|
||||
):
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*command: str) -> ExecResult:
|
||||
raise ExecNonZeroError(
|
||||
ExecResult(stdout=b"groupadd output", stderr=b"groupadd failed", exit_code=9),
|
||||
command=command,
|
||||
)
|
||||
|
||||
async def apply_entry(_entry: object, _dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
return []
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(groups=[Group(name="dev", users=[])])
|
||||
|
||||
with pytest.raises(ExecNonZeroError) as exc_info:
|
||||
await applier.apply_manifest(manifest)
|
||||
|
||||
assert exc_info.value.context["command"] == ("groupadd", "dev")
|
||||
assert exc_info.value.context["command_str"] == "groupadd dev"
|
||||
assert exc_info.value.context["stdout"] == "groupadd output"
|
||||
assert exc_info.value.context["stderr"] == "groupadd failed"
|
||||
assert exc_info.value.message == "stdout: groupadd output\nstderr: groupadd failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manifest_applier_raises_without_stream_labels_when_only_stdout_is_present() -> None:
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*command: str) -> ExecResult:
|
||||
raise ExecNonZeroError(
|
||||
ExecResult(stdout=b"useradd unavailable", stderr=b"", exit_code=127),
|
||||
command=command,
|
||||
)
|
||||
|
||||
async def apply_entry(_entry: object, _dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
return []
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
manifest = Manifest(users=[User(name="sandbox-user")])
|
||||
|
||||
with pytest.raises(ExecNonZeroError) as exc_info:
|
||||
await applier.apply_manifest(manifest)
|
||||
|
||||
assert exc_info.value.context["command_str"] == (
|
||||
"useradd -U -M -s /usr/sbin/nologin sandbox-user"
|
||||
)
|
||||
assert exc_info.value.context["stdout"] == "useradd unavailable"
|
||||
assert exc_info.value.context["stderr"] == ""
|
||||
assert exc_info.value.message == "useradd unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_entry_batch_flushes_parallel_work_before_overlapping_paths() -> None:
|
||||
events: list[tuple[str, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(_entry: object, dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
events.append(("start", dest))
|
||||
await asyncio.sleep(0)
|
||||
events.append(("end", dest))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
destinations = [
|
||||
Path("/workspace/alpha.txt"),
|
||||
Path("/workspace/beta.txt"),
|
||||
Path("/workspace/nested"),
|
||||
Path("/workspace/nested/child.txt"),
|
||||
]
|
||||
|
||||
files = await applier._apply_entry_batch(
|
||||
[
|
||||
(destinations[0], File(content=b"a")),
|
||||
(destinations[1], File(content=b"b")),
|
||||
(destinations[2], Dir()),
|
||||
(destinations[3], File(content=b"c")),
|
||||
],
|
||||
base_dir=Path("/"),
|
||||
)
|
||||
|
||||
assert [file.path for file in files] == destinations
|
||||
child_start = events.index(("start", destinations[3]))
|
||||
assert events.index(("end", destinations[0])) < child_start
|
||||
assert events.index(("end", destinations[1])) < child_start
|
||||
assert events.index(("end", destinations[2])) < child_start
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_entry_batch_flushes_before_and_after_mount_entries() -> None:
|
||||
events: list[tuple[str, Path]] = []
|
||||
|
||||
async def mkdir(_path: Path) -> None:
|
||||
return None
|
||||
|
||||
async def exec_checked_nonzero(*_command: str) -> ExecResult:
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def apply_entry(_entry: object, dest: Path, _base_dir: Path) -> list[MaterializedFile]:
|
||||
events.append(("start", dest))
|
||||
await asyncio.sleep(0)
|
||||
events.append(("end", dest))
|
||||
return _materialized(dest)
|
||||
|
||||
applier = ManifestApplier(
|
||||
mkdir=mkdir,
|
||||
exec_checked_nonzero=exec_checked_nonzero,
|
||||
apply_entry=apply_entry,
|
||||
)
|
||||
destinations = [
|
||||
Path("/workspace/alpha.txt"),
|
||||
Path("/workspace/beta.txt"),
|
||||
Path("/workspace/mount"),
|
||||
Path("/workspace/gamma.txt"),
|
||||
]
|
||||
|
||||
files = await applier._apply_entry_batch(
|
||||
[
|
||||
(destinations[0], File(content=b"a")),
|
||||
(destinations[1], File(content=b"b")),
|
||||
(
|
||||
destinations[2],
|
||||
GCSMount(
|
||||
bucket="sandbox-bucket",
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
),
|
||||
),
|
||||
(destinations[3], File(content=b"c")),
|
||||
],
|
||||
base_dir=Path("/"),
|
||||
)
|
||||
|
||||
assert [file.path for file in files] == destinations
|
||||
mount_start = events.index(("start", destinations[2]))
|
||||
gamma_start = events.index(("start", destinations[3]))
|
||||
assert events.index(("end", destinations[0])) < mount_start
|
||||
assert events.index(("end", destinations[1])) < mount_start
|
||||
assert events.index(("end", destinations[2])) < gamma_start
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.materialization import gather_in_order
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_in_order_limits_concurrency_and_preserves_order() -> None:
|
||||
active_tasks = 0
|
||||
max_active_tasks = 0
|
||||
release_tasks = asyncio.Event()
|
||||
started_tasks: list[int] = []
|
||||
|
||||
def task_factory(index: int) -> Callable[[], Awaitable[str]]:
|
||||
async def run() -> str:
|
||||
nonlocal active_tasks
|
||||
nonlocal max_active_tasks
|
||||
active_tasks += 1
|
||||
max_active_tasks = max(max_active_tasks, active_tasks)
|
||||
started_tasks.append(index)
|
||||
try:
|
||||
await release_tasks.wait()
|
||||
return f"result-{index}"
|
||||
finally:
|
||||
active_tasks -= 1
|
||||
|
||||
return run
|
||||
|
||||
gather_task = asyncio.create_task(
|
||||
gather_in_order([task_factory(index) for index in range(5)], max_concurrency=2)
|
||||
)
|
||||
while len(started_tasks) < 2:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert started_tasks == [0, 1]
|
||||
assert max_active_tasks == 2
|
||||
|
||||
release_tasks.set()
|
||||
result = await gather_task
|
||||
|
||||
assert result == ["result-0", "result-1", "result-2", "result-3", "result-4"]
|
||||
assert max_active_tasks == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_in_order_rejects_invalid_concurrency() -> None:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await gather_in_order([], max_concurrency=0)
|
||||
|
||||
assert str(exc_info.value) == "max_concurrency must be at least 1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.errors import WorkspaceArchiveReadError
|
||||
from agents.sandbox.session.mount_lifecycle import with_ephemeral_mounts_removed
|
||||
|
||||
|
||||
class _FakeMountStrategy:
|
||||
def __init__(
|
||||
self,
|
||||
events: list[str],
|
||||
*,
|
||||
name: str,
|
||||
fail_teardown: bool = False,
|
||||
fail_restore: bool = False,
|
||||
) -> None:
|
||||
self._events = events
|
||||
self._name = name
|
||||
self._fail_teardown = fail_teardown
|
||||
self._fail_restore = fail_restore
|
||||
|
||||
async def teardown_for_snapshot(
|
||||
self,
|
||||
mount: object,
|
||||
session: object,
|
||||
path: Path,
|
||||
) -> None:
|
||||
_ = (mount, session, path)
|
||||
self._events.append(f"teardown:{self._name}")
|
||||
if self._fail_teardown:
|
||||
raise RuntimeError(f"teardown failed: {self._name}")
|
||||
|
||||
async def restore_after_snapshot(
|
||||
self,
|
||||
mount: object,
|
||||
session: object,
|
||||
path: Path,
|
||||
) -> None:
|
||||
_ = (mount, session, path)
|
||||
self._events.append(f"restore:{self._name}")
|
||||
if self._fail_restore:
|
||||
raise RuntimeError(f"restore failed: {self._name}")
|
||||
|
||||
|
||||
class _FakeMount:
|
||||
def __init__(self, strategy: _FakeMountStrategy) -> None:
|
||||
self.mount_strategy = strategy
|
||||
|
||||
|
||||
class _FakeManifest:
|
||||
def __init__(self, mounts: list[tuple[_FakeMount, Path]]) -> None:
|
||||
self._mounts = mounts
|
||||
|
||||
def ephemeral_mount_targets(self) -> list[tuple[_FakeMount, Path]]:
|
||||
return self._mounts
|
||||
|
||||
|
||||
class _FakeState:
|
||||
def __init__(self, manifest: _FakeManifest) -> None:
|
||||
self.manifest = manifest
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, manifest: _FakeManifest) -> None:
|
||||
self.state = _FakeState(manifest)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_ephemeral_mounts_removed_restores_in_reverse_order() -> None:
|
||||
events: list[str] = []
|
||||
left = _FakeMount(_FakeMountStrategy(events, name="left"))
|
||||
right = _FakeMount(_FakeMountStrategy(events, name="right"))
|
||||
session = _FakeSession(
|
||||
_FakeManifest(
|
||||
[
|
||||
(left, Path("/workspace/left")),
|
||||
(right, Path("/workspace/right")),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
async def operation() -> str:
|
||||
events.append("operation")
|
||||
return "persisted"
|
||||
|
||||
result = await with_ephemeral_mounts_removed(
|
||||
cast(Any, session),
|
||||
operation,
|
||||
error_path=Path("/workspace"),
|
||||
error_cls=WorkspaceArchiveReadError,
|
||||
operation_error_context_key="snapshot_error_before_remount_corruption",
|
||||
)
|
||||
|
||||
assert result == "persisted"
|
||||
assert events == [
|
||||
"teardown:left",
|
||||
"teardown:right",
|
||||
"operation",
|
||||
"restore:right",
|
||||
"restore:left",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_ephemeral_mounts_removed_reports_restore_error_after_operation_error() -> None:
|
||||
events: list[str] = []
|
||||
mount = _FakeMount(_FakeMountStrategy(events, name="mount", fail_restore=True))
|
||||
session = _FakeSession(_FakeManifest([(mount, Path("/workspace/mount"))]))
|
||||
operation_error = WorkspaceArchiveReadError(
|
||||
path=Path("/workspace"),
|
||||
context={"reason": "persist_failed"},
|
||||
)
|
||||
|
||||
async def operation() -> bytes:
|
||||
events.append("operation")
|
||||
raise operation_error
|
||||
|
||||
with pytest.raises(WorkspaceArchiveReadError) as exc_info:
|
||||
await with_ephemeral_mounts_removed(
|
||||
cast(Any, session),
|
||||
operation,
|
||||
error_path=Path("/workspace"),
|
||||
error_cls=WorkspaceArchiveReadError,
|
||||
operation_error_context_key="snapshot_error_before_remount_corruption",
|
||||
)
|
||||
|
||||
assert events == ["teardown:mount", "operation", "restore:mount"]
|
||||
assert exc_info.value.context["snapshot_error_before_remount_corruption"] == {
|
||||
"message": operation_error.message,
|
||||
}
|
||||
assert isinstance(exc_info.value.cause, RuntimeError)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.files import EntryKind
|
||||
from agents.sandbox.types import FileMode
|
||||
from agents.sandbox.util.parse_utils import parse_ls_la
|
||||
|
||||
|
||||
def test_parse_ls_la_preserves_absolute_file_paths() -> None:
|
||||
output = "-rwxr-xr-x 1 root root 48915747 Jan 1 00:00 /workspace/bin/tool\n"
|
||||
|
||||
entries = parse_ls_la(output, base="/workspace/bin/tool")
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].path == "/workspace/bin/tool"
|
||||
assert entries[0].kind == EntryKind.FILE
|
||||
|
||||
|
||||
def test_parse_ls_la_prefixes_directory_entries_with_base() -> None:
|
||||
output = (
|
||||
"drwxr-xr-x 2 root root 4096 Jan 1 00:00 .\n"
|
||||
"drwxr-xr-x 3 root root 4096 Jan 1 00:00 ..\n"
|
||||
"-rw-r--r-- 1 root root 123 Jan 1 00:00 notes.md\n"
|
||||
)
|
||||
|
||||
entries = parse_ls_la(output, base="/workspace/docs")
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].path == "/workspace/docs/notes.md"
|
||||
assert entries[0].kind == EntryKind.FILE
|
||||
|
||||
|
||||
def test_parse_ls_la_keeps_arrow_in_regular_file_names() -> None:
|
||||
output = "-rw-r--r-- 1 root root 123 Jan 1 00:00 notes -> final.txt\n"
|
||||
|
||||
entries = parse_ls_la(output, base="/workspace/docs")
|
||||
|
||||
assert len(entries) == 1
|
||||
assert entries[0].path == "/workspace/docs/notes -> final.txt"
|
||||
assert entries[0].kind == EntryKind.FILE
|
||||
|
||||
|
||||
def test_parse_ls_la_accepts_special_permission_bits() -> None:
|
||||
output = (
|
||||
"drwxrwxrwt 2 root root 4096 Jan 1 00:00 tmp\n"
|
||||
"-rwsr-sr-t 1 root root 123 Jan 1 00:00 setuid-tool\n"
|
||||
"-rwSr-Sr-T 1 root root 456 Jan 1 00:00 special-no-exec\n"
|
||||
)
|
||||
|
||||
entries = parse_ls_la(output, base="/")
|
||||
|
||||
assert [entry.path for entry in entries] == [
|
||||
"/tmp",
|
||||
"/setuid-tool",
|
||||
"/special-no-exec",
|
||||
]
|
||||
assert entries[0].permissions.directory is True
|
||||
assert entries[0].permissions.other & FileMode.EXEC
|
||||
assert entries[1].permissions.owner & FileMode.EXEC
|
||||
assert entries[1].permissions.group & FileMode.EXEC
|
||||
assert entries[1].permissions.other & FileMode.EXEC
|
||||
assert not (entries[2].permissions.owner & FileMode.EXEC)
|
||||
assert not (entries[2].permissions.group & FileMode.EXEC)
|
||||
assert not (entries[2].permissions.other & FileMode.EXEC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permissions",
|
||||
[
|
||||
"-rwTr--r--",
|
||||
"-rwxrwTr--",
|
||||
"-rwxrwxr-S",
|
||||
"-rwtr--r--",
|
||||
"-rwxrwtr--",
|
||||
"-rwxrwxr-s",
|
||||
],
|
||||
)
|
||||
def test_parse_ls_la_rejects_special_permission_bits_in_wrong_position(
|
||||
permissions: str,
|
||||
) -> None:
|
||||
output = f"{permissions} 1 root root 123 Jan 1 00:00 invalid\n"
|
||||
|
||||
with pytest.raises(ValueError, match="invalid exec flag"):
|
||||
parse_ls_la(output, base="/")
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.session.pty_output import collect_pty_output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_pty_output_waits_for_notification() -> None:
|
||||
output_chunks: deque[bytes] = deque()
|
||||
output_lock = asyncio.Lock()
|
||||
output_notify = asyncio.Event()
|
||||
done = False
|
||||
|
||||
async def produce_output() -> None:
|
||||
nonlocal done
|
||||
await asyncio.sleep(0)
|
||||
async with output_lock:
|
||||
output_chunks.append(b"notified output")
|
||||
done = True
|
||||
output_notify.set()
|
||||
|
||||
producer_task = asyncio.create_task(produce_output())
|
||||
output, original_token_count = await collect_pty_output(
|
||||
output_chunks=output_chunks,
|
||||
output_lock=output_lock,
|
||||
output_notify=output_notify,
|
||||
is_done=lambda: done,
|
||||
yield_time_ms=500,
|
||||
max_output_tokens=None,
|
||||
)
|
||||
await producer_task
|
||||
|
||||
assert output == b"notified output"
|
||||
assert original_token_count is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_pty_output_drains_chunks_added_when_done() -> None:
|
||||
output_chunks = deque([b"before done"])
|
||||
|
||||
def mark_done() -> bool:
|
||||
output_chunks.append(b" after done")
|
||||
return True
|
||||
|
||||
output, original_token_count = await collect_pty_output(
|
||||
output_chunks=output_chunks,
|
||||
output_lock=asyncio.Lock(),
|
||||
output_notify=asyncio.Event(),
|
||||
is_done=mark_done,
|
||||
yield_time_ms=500,
|
||||
max_output_tokens=None,
|
||||
)
|
||||
|
||||
assert output == b"before done after done"
|
||||
assert original_token_count is None
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agents.sandbox.session.pty_types import (
|
||||
PTY_EMPTY_YIELD_TIME_MS_MIN,
|
||||
PTY_YIELD_TIME_MS_MIN,
|
||||
allocate_pty_process_id,
|
||||
clamp_pty_yield_time_ms,
|
||||
process_id_to_prune_from_meta,
|
||||
resolve_pty_write_yield_time_ms,
|
||||
)
|
||||
|
||||
|
||||
def test_clamp_pty_yield_time_ms_enforces_minimum() -> None:
|
||||
assert clamp_pty_yield_time_ms(0) == PTY_YIELD_TIME_MS_MIN
|
||||
|
||||
|
||||
def test_resolve_pty_write_yield_time_ms_uses_longer_poll_for_empty_input() -> None:
|
||||
assert (
|
||||
resolve_pty_write_yield_time_ms(yield_time_ms=PTY_YIELD_TIME_MS_MIN, input_empty=True)
|
||||
== PTY_EMPTY_YIELD_TIME_MS_MIN
|
||||
)
|
||||
assert (
|
||||
resolve_pty_write_yield_time_ms(yield_time_ms=PTY_YIELD_TIME_MS_MIN, input_empty=False)
|
||||
== PTY_YIELD_TIME_MS_MIN
|
||||
)
|
||||
|
||||
|
||||
def test_allocate_pty_process_id_avoids_used_ids() -> None:
|
||||
used = {1000, 1001, 1002}
|
||||
allocated = allocate_pty_process_id(used)
|
||||
assert allocated not in used
|
||||
|
||||
|
||||
def test_process_id_to_prune_from_meta_prefers_exited_unprotected_sessions() -> None:
|
||||
meta = [(1001 + i, float(100 - i), False) for i in range(8)]
|
||||
meta.append((2001, 1.0, True))
|
||||
meta.append((2002, 2.0, False))
|
||||
|
||||
assert process_id_to_prune_from_meta(meta) == 2001
|
||||
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agents.sandbox.entries import BaseEntry, Dir, DockerVolumeMountStrategy, S3Mount
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.remote_mount_policy import build_remote_mount_policy_instructions
|
||||
|
||||
|
||||
def _s3_mount(*, read_only: bool) -> S3Mount:
|
||||
return S3Mount(
|
||||
bucket="example-bucket",
|
||||
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
|
||||
def _policy_for(entries: dict[str | Path, BaseEntry]) -> str:
|
||||
policy = build_remote_mount_policy_instructions(Manifest(entries=entries))
|
||||
assert policy is not None
|
||||
return policy
|
||||
|
||||
|
||||
def test_remote_mount_policy_does_not_suggest_direct_edits_for_read_only_mounts() -> None:
|
||||
policy = _policy_for({"data": _s3_mount(read_only=True)})
|
||||
|
||||
assert "/workspace/data (mounted in read-only mode)" in policy
|
||||
assert "`apply_patch` directly" not in policy
|
||||
assert "copy it back" not in policy
|
||||
assert "Do not edit paths marked read-only in place" in policy
|
||||
assert "including with `apply_patch`" in policy
|
||||
assert "do not write edited files back" in policy
|
||||
|
||||
|
||||
def test_remote_mount_policy_keeps_direct_and_copy_back_guidance_for_read_write_mounts() -> None:
|
||||
policy = _policy_for({"data": _s3_mount(read_only=False)})
|
||||
|
||||
assert "/workspace/data (mounted in read+write mode)" in policy
|
||||
assert "Use `apply_patch` directly for text edits on read+write mounts." in policy
|
||||
assert "For shell-based edits on read+write mounts" in policy
|
||||
assert "copy it back" in policy
|
||||
assert "Do not edit paths marked read-only" not in policy
|
||||
|
||||
|
||||
def test_remote_mount_policy_handles_mixed_read_only_and_read_write_mounts() -> None:
|
||||
policy = _policy_for(
|
||||
{
|
||||
"input": _s3_mount(read_only=True),
|
||||
"output": _s3_mount(read_only=False),
|
||||
}
|
||||
)
|
||||
|
||||
assert "/workspace/input (mounted in read-only mode)" in policy
|
||||
assert "/workspace/output (mounted in read+write mode)" in policy
|
||||
assert "Use `apply_patch` directly for text edits on read+write mounts." in policy
|
||||
assert "For shell-based edits on read+write mounts" in policy
|
||||
assert "Do not edit paths marked read-only in place" in policy
|
||||
assert "including with `apply_patch`" in policy
|
||||
assert "do not write edited files back" in policy
|
||||
|
||||
|
||||
def test_remote_mount_policy_returns_none_without_remote_mounts() -> None:
|
||||
policy = build_remote_mount_policy_instructions(Manifest(entries={"local": Dir()}))
|
||||
|
||||
assert policy is None
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.util.retry import (
|
||||
BackoffStrategy,
|
||||
exception_chain_contains_type,
|
||||
exception_chain_has_status_code,
|
||||
iter_exception_chain,
|
||||
retry_async,
|
||||
)
|
||||
|
||||
|
||||
class _ErrorWithHttpMetadata(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
http_code: int | None = None,
|
||||
response_status_code: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.http_code = http_code
|
||||
if response_status_code is not None:
|
||||
self.response = type("_Response", (), {"status_code": response_status_code})()
|
||||
|
||||
|
||||
def test_iter_exception_chain_supports_context_and_stops_on_cycles() -> None:
|
||||
outer = RuntimeError("outer")
|
||||
inner = ValueError("inner")
|
||||
outer.__context__ = inner
|
||||
|
||||
assert list(iter_exception_chain(outer)) == [outer, inner]
|
||||
|
||||
cyclical_outer = RuntimeError("cyclical-outer")
|
||||
cyclical_inner = ValueError("cyclical-inner")
|
||||
cyclical_outer.__cause__ = cyclical_inner
|
||||
cyclical_inner.__cause__ = cyclical_outer
|
||||
|
||||
assert list(iter_exception_chain(cyclical_outer)) == [cyclical_outer, cyclical_inner]
|
||||
|
||||
|
||||
def test_exception_chain_helpers_detect_types_and_status_codes() -> None:
|
||||
outer = RuntimeError("outer")
|
||||
inner = _ErrorWithHttpMetadata("inner", response_status_code=504)
|
||||
outer.__cause__ = inner
|
||||
|
||||
assert exception_chain_contains_type(outer, ()) is False
|
||||
assert exception_chain_contains_type(outer, (_ErrorWithHttpMetadata,)) is True
|
||||
assert exception_chain_contains_type(outer, (LookupError,)) is False
|
||||
|
||||
assert exception_chain_has_status_code(
|
||||
_ErrorWithHttpMetadata("status", status_code=500),
|
||||
{500},
|
||||
)
|
||||
assert exception_chain_has_status_code(
|
||||
_ErrorWithHttpMetadata("http", http_code=502),
|
||||
{502},
|
||||
)
|
||||
assert exception_chain_has_status_code(outer, {504})
|
||||
assert exception_chain_has_status_code(outer, {503}) is False
|
||||
|
||||
|
||||
def test_retry_async_validates_configuration() -> None:
|
||||
with pytest.raises(ValueError, match="max_attempt must be >= 1"):
|
||||
retry_async(max_attempt=0, retry_if=lambda _exc: True)
|
||||
|
||||
with pytest.raises(ValueError, match="interval must be >= 0"):
|
||||
retry_async(interval=-1, retry_if=lambda _exc: True)
|
||||
|
||||
with pytest.raises(ValueError, match="backoff must be"):
|
||||
retry_async(
|
||||
backoff=cast(BackoffStrategy, "quadratic"),
|
||||
retry_if=lambda _exc: True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backoff", "expected_delays"),
|
||||
[
|
||||
(BackoffStrategy.FIXED, [0.5, 0.5]),
|
||||
(BackoffStrategy.LINEAR, [0.5, 1.0]),
|
||||
(BackoffStrategy.EXPONENTIAL, [0.5, 1.0]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_async_retries_with_expected_backoff_and_async_hook(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backoff: BackoffStrategy,
|
||||
expected_delays: list[float],
|
||||
) -> None:
|
||||
sleep_delays: list[float] = []
|
||||
hook_calls: list[tuple[int, int, float]] = []
|
||||
attempts = 0
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
sleep_delays.append(delay)
|
||||
|
||||
async def on_retry(
|
||||
_exc: Exception,
|
||||
attempt: int,
|
||||
max_attempt: int,
|
||||
delay_s: float,
|
||||
*_args: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
hook_calls.append((attempt, max_attempt, delay_s))
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
|
||||
@retry_async(
|
||||
interval=0.5,
|
||||
max_attempt=3,
|
||||
backoff=backoff,
|
||||
retry_if=lambda exc, *_args, **_kwargs: isinstance(exc, RuntimeError),
|
||||
on_retry=on_retry,
|
||||
)
|
||||
async def flaky(label: str) -> str:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 3:
|
||||
raise RuntimeError(label)
|
||||
return f"ok:{label}"
|
||||
|
||||
result = await flaky("sandbox")
|
||||
|
||||
assert result == "ok:sandbox"
|
||||
assert attempts == 3
|
||||
assert sleep_delays == expected_delays
|
||||
assert hook_calls == [(1, 3, expected_delays[0]), (2, 3, expected_delays[1])]
|
||||
assert str(backoff) == backoff.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_async_stops_without_sleep_when_retry_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempts = 0
|
||||
|
||||
async def fail_sleep(_delay: float) -> None:
|
||||
raise AssertionError("sleep should not be called")
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fail_sleep)
|
||||
|
||||
@retry_async(
|
||||
interval=0.5,
|
||||
max_attempt=3,
|
||||
backoff=BackoffStrategy.EXPONENTIAL,
|
||||
retry_if=lambda _exc, *_args, **_kwargs: False,
|
||||
on_retry=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
async def always_fail() -> None:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise RuntimeError("stop")
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop"):
|
||||
await always_fail()
|
||||
|
||||
assert attempts == 1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import UserError
|
||||
from agents.models.default_models import get_default_model
|
||||
from agents.run_context import RunContextWrapper
|
||||
from agents.sandbox import MemoryReadConfig, runtime_agent_preparation as sandbox_prep
|
||||
from agents.sandbox.capabilities import Capability, Compaction, Memory
|
||||
from agents.sandbox.entries import BaseEntry, File
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.sandbox_agent import SandboxAgent
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
|
||||
|
||||
class _Capability:
|
||||
def __init__(self, fragment: str | None, *, type: str = "test") -> None:
|
||||
self.type = type
|
||||
self.fragment = fragment
|
||||
self.manifests: list[Manifest] = []
|
||||
self.sampling_params_calls: list[dict[str, object]] = []
|
||||
|
||||
def tools(self) -> list[object]:
|
||||
return []
|
||||
|
||||
def sampling_params(self, sampling_params: dict[str, object]) -> dict[str, object]:
|
||||
self.sampling_params_calls.append(dict(sampling_params))
|
||||
return {}
|
||||
|
||||
def required_capability_types(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
async def instructions(self, manifest: Manifest) -> str | None:
|
||||
self.manifests.append(manifest)
|
||||
return self.fragment
|
||||
|
||||
|
||||
def _session_with_manifest(manifest: Manifest | None) -> object:
|
||||
return SimpleNamespace(state=SimpleNamespace(manifest=manifest))
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_passes_session_manifest_to_capability_instructions():
|
||||
manifest = Manifest(root="/workspace")
|
||||
capability = _Capability("capability fragment")
|
||||
prepared = sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
base_instructions="base instructions",
|
||||
instructions="additional instructions",
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=cast(list[Capability], [capability]),
|
||||
)
|
||||
instructions = cast(
|
||||
Callable[[RunContextWrapper[object], SandboxAgent[object]], Awaitable[str | None]],
|
||||
prepared.instructions,
|
||||
)
|
||||
|
||||
result: str | None = asyncio.run(
|
||||
cast(
|
||||
Coroutine[Any, Any, str | None],
|
||||
instructions(
|
||||
cast(RunContextWrapper[object], None),
|
||||
cast(SandboxAgent[object], prepared),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == (
|
||||
"base instructions\n\n"
|
||||
"# Agent instructions\n\n"
|
||||
"additional instructions\n\n"
|
||||
"# Sandbox capability instructions\n\n"
|
||||
"capability fragment\n\n"
|
||||
f"{sandbox_prep._filesystem_instructions(manifest)}"
|
||||
)
|
||||
assert capability.manifests == [manifest]
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_wraps_capabilities_without_agent_instructions():
|
||||
manifest = Manifest(root="/workspace")
|
||||
capability = _Capability("capability fragment")
|
||||
prepared = sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
base_instructions="base instructions",
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=cast(list[Capability], [capability]),
|
||||
)
|
||||
instructions = cast(
|
||||
Callable[[RunContextWrapper[object], SandboxAgent[object]], Awaitable[str | None]],
|
||||
prepared.instructions,
|
||||
)
|
||||
|
||||
result: str | None = asyncio.run(
|
||||
cast(
|
||||
Coroutine[Any, Any, str | None],
|
||||
instructions(
|
||||
cast(RunContextWrapper[object], None),
|
||||
cast(SandboxAgent[object], prepared),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert result == (
|
||||
"base instructions\n\n"
|
||||
"# Sandbox capability instructions\n\n"
|
||||
"capability fragment\n\n"
|
||||
f"{sandbox_prep._filesystem_instructions(manifest)}"
|
||||
)
|
||||
assert capability.manifests == [manifest]
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_passes_default_model_to_capability_sampling_params() -> None:
|
||||
manifest = Manifest(root="/workspace")
|
||||
capability = _Capability(None)
|
||||
|
||||
sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="base instructions",
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=cast(list[Capability], [capability]),
|
||||
)
|
||||
|
||||
assert capability.sampling_params_calls == [{"model": get_default_model()}]
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_prepares_default_compaction_policy() -> None:
|
||||
manifest = Manifest(root="/workspace")
|
||||
|
||||
prepared = sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="base instructions",
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=[Compaction()],
|
||||
)
|
||||
|
||||
extra_args = prepared.model_settings.extra_args
|
||||
assert extra_args is not None
|
||||
assert "context_management" in extra_args
|
||||
assert "model" not in extra_args
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_uses_default_sandbox_instructions_when_base_missing():
|
||||
manifest = Manifest(root="/workspace")
|
||||
capability = _Capability("capability fragment")
|
||||
prepared = sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="additional instructions",
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=cast(list[Capability], [capability]),
|
||||
)
|
||||
instructions = cast(
|
||||
Callable[[RunContextWrapper[object], SandboxAgent[object]], Awaitable[str | None]],
|
||||
prepared.instructions,
|
||||
)
|
||||
|
||||
result: str | None = asyncio.run(
|
||||
cast(
|
||||
Coroutine[Any, Any, str | None],
|
||||
instructions(
|
||||
cast(RunContextWrapper[object], None),
|
||||
cast(SandboxAgent[object], prepared),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
default_instructions = sandbox_prep.get_default_sandbox_instructions()
|
||||
assert default_instructions is not None
|
||||
assert result == (
|
||||
f"{default_instructions}\n\n"
|
||||
"# Agent instructions\n\n"
|
||||
"additional instructions\n\n"
|
||||
"# Sandbox capability instructions\n\n"
|
||||
"capability fragment\n\n"
|
||||
f"{sandbox_prep._filesystem_instructions(manifest)}"
|
||||
)
|
||||
assert capability.manifests == [manifest]
|
||||
|
||||
|
||||
def test_filesystem_instructions_tell_model_to_ls_when_manifest_tree_is_truncated() -> None:
|
||||
entries: dict[str | Path, BaseEntry] = {
|
||||
f"file_{index:03}.txt": File(content=b"", description="x" * 40) for index in range(200)
|
||||
}
|
||||
manifest = Manifest(root="/workspace", entries=entries)
|
||||
|
||||
result = sandbox_prep._filesystem_instructions(manifest)
|
||||
|
||||
assert "... (truncated " in result
|
||||
assert (
|
||||
"The filesystem layout above was truncated. "
|
||||
"Use `ls` to explore specific directories before relying on omitted paths."
|
||||
) in result
|
||||
|
||||
|
||||
def test_prepare_sandbox_agent_validates_required_capabilities() -> None:
|
||||
manifest = Manifest(root="/workspace")
|
||||
|
||||
with pytest.raises(UserError, match="Memory requires missing capabilities: filesystem, shell"):
|
||||
sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="base instructions",
|
||||
capabilities=[Memory()],
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=[Memory()],
|
||||
)
|
||||
|
||||
with pytest.raises(UserError, match="Memory requires missing capabilities: shell"):
|
||||
sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="base instructions",
|
||||
capabilities=[Memory(read=MemoryReadConfig(live_update=False), generate=None)],
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=[Memory(read=MemoryReadConfig(live_update=False), generate=None)],
|
||||
)
|
||||
|
||||
prepared = sandbox_prep.prepare_sandbox_agent(
|
||||
agent=SandboxAgent(
|
||||
name="sandbox",
|
||||
instructions="base instructions",
|
||||
capabilities=[Memory()],
|
||||
),
|
||||
session=cast(BaseSandboxSession, _session_with_manifest(manifest)),
|
||||
capabilities=cast(
|
||||
list[Capability],
|
||||
[
|
||||
Memory(),
|
||||
_Capability(None, type="filesystem"),
|
||||
_Capability(None, type="shell"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
assert prepared.name == "sandbox"
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.session.runtime_helpers import (
|
||||
RESOLVE_WORKSPACE_PATH_HELPER,
|
||||
RuntimeHelperScript,
|
||||
)
|
||||
|
||||
requires_posix_shell = pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="runtime helper shell script tests require a POSIX shell",
|
||||
)
|
||||
|
||||
|
||||
def _install_resolve_helper(tmp_path: Path) -> Path:
|
||||
helper_path = tmp_path / "resolve-workspace-path"
|
||||
helper_path.write_text(RESOLVE_WORKSPACE_PATH_HELPER.content, encoding="utf-8")
|
||||
helper_path.chmod(0o755)
|
||||
return helper_path
|
||||
|
||||
|
||||
def test_runtime_helper_from_content_uses_posix_install_path() -> None:
|
||||
helper = RuntimeHelperScript.from_content(
|
||||
name="test-helper",
|
||||
content="#!/bin/sh\nprintf 'ok\\n'",
|
||||
)
|
||||
|
||||
assert isinstance(helper.install_path, PurePosixPath)
|
||||
assert helper.install_path.as_posix().startswith("/tmp/openai-agents/bin/test-helper-")
|
||||
assert str(helper.install_path).startswith("/tmp/openai-agents/bin/test-helper-")
|
||||
|
||||
|
||||
@requires_posix_shell
|
||||
def test_resolve_workspace_path_helper_allows_extra_root_symlink_target(tmp_path: Path) -> None:
|
||||
helper_path = _install_resolve_helper(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
extra_root = tmp_path / "tmp"
|
||||
workspace.mkdir()
|
||||
extra_root.mkdir()
|
||||
target = extra_root / "result.txt"
|
||||
target.write_text("scratch output", encoding="utf-8")
|
||||
(workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(helper_path),
|
||||
str(workspace),
|
||||
str(workspace / "tmp-link" / "result.txt"),
|
||||
"0",
|
||||
str(extra_root),
|
||||
"0",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == f"{target.resolve(strict=False)}\n"
|
||||
assert result.stderr == ""
|
||||
|
||||
|
||||
@requires_posix_shell
|
||||
def test_resolve_workspace_path_helper_rejects_extra_root_when_not_allowed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
helper_path = _install_resolve_helper(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
extra_root = tmp_path / "tmp"
|
||||
workspace.mkdir()
|
||||
extra_root.mkdir()
|
||||
target = extra_root / "result.txt"
|
||||
target.write_text("scratch output", encoding="utf-8")
|
||||
(workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(helper_path),
|
||||
str(workspace),
|
||||
str(workspace / "tmp-link" / "result.txt"),
|
||||
"0",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 111
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == f"workspace escape: {target.resolve(strict=False)}\n"
|
||||
|
||||
|
||||
@requires_posix_shell
|
||||
def test_resolve_workspace_path_helper_rejects_extra_root_symlink_to_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
helper_path = _install_resolve_helper(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
root_alias = tmp_path / "root-alias"
|
||||
workspace.mkdir()
|
||||
root_alias.symlink_to(Path("/"), target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(helper_path),
|
||||
str(workspace),
|
||||
"/etc/passwd",
|
||||
"0",
|
||||
str(root_alias),
|
||||
"0",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 113
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == (
|
||||
f"extra path grant must not resolve to filesystem root: {root_alias}\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_posix_shell
|
||||
def test_resolve_workspace_path_helper_rejects_nested_read_only_extra_grant_on_write(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
helper_path = _install_resolve_helper(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
extra_root = tmp_path / "tmp"
|
||||
protected_root = extra_root / "protected"
|
||||
workspace.mkdir()
|
||||
protected_root.mkdir(parents=True)
|
||||
target = protected_root / "result.txt"
|
||||
target.write_text("scratch output", encoding="utf-8")
|
||||
(workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(helper_path),
|
||||
str(workspace),
|
||||
str(workspace / "tmp-link" / "protected" / "result.txt"),
|
||||
"1",
|
||||
str(extra_root),
|
||||
"0",
|
||||
str(protected_root),
|
||||
"1",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 114
|
||||
assert result.stdout == ""
|
||||
assert result.stderr == (
|
||||
f"read-only extra path grant: {protected_root}\n"
|
||||
f"resolved path: {target.resolve(strict=False)}\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_posix_shell
|
||||
def test_resolve_workspace_path_helper_allows_nested_read_only_extra_grant_on_read(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
helper_path = _install_resolve_helper(tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
extra_root = tmp_path / "tmp"
|
||||
protected_root = extra_root / "protected"
|
||||
workspace.mkdir()
|
||||
protected_root.mkdir(parents=True)
|
||||
target = protected_root / "result.txt"
|
||||
target.write_text("scratch output", encoding="utf-8")
|
||||
(workspace / "tmp-link").symlink_to(extra_root, target_is_directory=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(helper_path),
|
||||
str(workspace),
|
||||
str(workspace / "tmp-link" / "protected" / "result.txt"),
|
||||
"0",
|
||||
str(extra_root),
|
||||
"0",
|
||||
str(protected_root),
|
||||
"1",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout == f"{target.resolve(strict=False)}\n"
|
||||
assert result.stderr == ""
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _restore_module(name: str, original: ModuleType | None) -> None:
|
||||
sys.modules.pop(name, None)
|
||||
if original is not None:
|
||||
sys.modules[name] = original
|
||||
|
||||
|
||||
def _restore_attr(obj: Any, name: str, original: object, existed: bool) -> None:
|
||||
if existed:
|
||||
setattr(obj, name, original)
|
||||
else:
|
||||
try:
|
||||
delattr(obj, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def test_sandboxes_package_import_skips_unix_local_on_windows(monkeypatch) -> None:
|
||||
sandbox_package = importlib.import_module("agents.sandbox")
|
||||
original_sandboxes_module = sys.modules.pop("agents.sandbox.sandboxes", None)
|
||||
original_unix_local_module = sys.modules.pop("agents.sandbox.sandboxes.unix_local", None)
|
||||
original_sandboxes_attr = getattr(sandbox_package, "sandboxes", None)
|
||||
had_sandboxes_attr = hasattr(sandbox_package, "sandboxes")
|
||||
|
||||
if had_sandboxes_attr:
|
||||
delattr(sandbox_package, "sandboxes")
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
|
||||
try:
|
||||
sandboxes = importlib.import_module("agents.sandbox.sandboxes")
|
||||
|
||||
assert sandboxes.__name__ == "agents.sandbox.sandboxes"
|
||||
assert "UnixLocalSandboxClient" not in sandboxes.__all__
|
||||
assert "UnixLocalSandboxClient" not in sandboxes.__dict__
|
||||
assert "agents.sandbox.sandboxes.unix_local" not in sys.modules
|
||||
finally:
|
||||
_restore_module("agents.sandbox.sandboxes", original_sandboxes_module)
|
||||
_restore_module("agents.sandbox.sandboxes.unix_local", original_unix_local_module)
|
||||
_restore_attr(
|
||||
sandbox_package,
|
||||
"sandboxes",
|
||||
original_sandboxes_attr,
|
||||
had_sandboxes_attr,
|
||||
)
|
||||
|
||||
|
||||
def test_unix_local_backend_import_raises_clear_error_on_windows(monkeypatch) -> None:
|
||||
parent = importlib.import_module("agents.sandbox.sandboxes")
|
||||
original_unix_local_module = sys.modules.pop("agents.sandbox.sandboxes.unix_local", None)
|
||||
original_unix_local_attr = getattr(parent, "unix_local", None)
|
||||
had_unix_local_attr = hasattr(parent, "unix_local")
|
||||
|
||||
if had_unix_local_attr:
|
||||
delattr(parent, "unix_local")
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
|
||||
try:
|
||||
with pytest.raises(ImportError, match="not supported on Windows"):
|
||||
importlib.import_module("agents.sandbox.sandboxes.unix_local")
|
||||
finally:
|
||||
_restore_module("agents.sandbox.sandboxes.unix_local", original_unix_local_module)
|
||||
_restore_attr(
|
||||
parent,
|
||||
"unix_local",
|
||||
original_unix_local_attr,
|
||||
had_unix_local_attr,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="Unix local sandbox is unavailable on Windows")
|
||||
def test_sandboxes_package_exports_unix_local_on_supported_platforms() -> None:
|
||||
sandboxes = importlib.import_module("agents.sandbox.sandboxes")
|
||||
|
||||
assert "UnixLocalSandboxClient" in sandboxes.__all__
|
||||
assert sandboxes.UnixLocalSandboxClient.__name__ == "UnixLocalSandboxClient"
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.runtime_session_manager import SandboxRuntimeSessionManager
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.session import (
|
||||
CallbackSink,
|
||||
EventPayloadPolicy,
|
||||
Instrumentation,
|
||||
SandboxSessionEvent,
|
||||
SandboxSessionFinishEvent,
|
||||
)
|
||||
from agents.sandbox.session.sinks import ChainedSink, EventSink
|
||||
from agents.sandbox.snapshot import LocalSnapshot, LocalSnapshotSpec, NoopSnapshotSpec
|
||||
|
||||
|
||||
class _EventSink(EventSink):
|
||||
def __init__(self, *, mode: str, on_error: str = "raise") -> None:
|
||||
self.mode = mode # type: ignore[assignment]
|
||||
self.on_error = on_error # type: ignore[assignment]
|
||||
self.payload_policy = None
|
||||
|
||||
async def handle(self, event: SandboxSessionEvent) -> None: # pragma: no cover
|
||||
_ = event
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _build_session(tmp_path: Path) -> UnixLocalSandboxSession:
|
||||
state = UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(tmp_path / "workspace")),
|
||||
snapshot=LocalSnapshot(id="x", base_path=tmp_path),
|
||||
)
|
||||
return UnixLocalSandboxSession.from_state(state)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instrumentation_per_op_policy_overrides_default(tmp_path: Path) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
session = _build_session(tmp_path)
|
||||
sink = CallbackSink(lambda event, _session: events.append(event), mode="sync")
|
||||
sink.bind(session)
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[sink],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=False),
|
||||
payload_policy_by_op={"exec": EventPayloadPolicy(include_exec_output=True)},
|
||||
)
|
||||
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="exec",
|
||||
span_id="span_exec",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
event.stdout_bytes = b"hello"
|
||||
event.stderr_bytes = b""
|
||||
|
||||
await instrumentation.emit(event)
|
||||
|
||||
assert isinstance(events[0], SandboxSessionFinishEvent)
|
||||
assert events[0].stdout == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instrumentation_per_sink_policy_overrides_per_op(tmp_path: Path) -> None:
|
||||
first: list[SandboxSessionEvent] = []
|
||||
second: list[SandboxSessionEvent] = []
|
||||
session = _build_session(tmp_path)
|
||||
sink_a = CallbackSink(lambda event, _session: first.append(event), mode="sync")
|
||||
sink_b = CallbackSink(
|
||||
lambda event, _session: second.append(event),
|
||||
mode="sync",
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=True),
|
||||
)
|
||||
sink_a.bind(session)
|
||||
sink_b.bind(session)
|
||||
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[sink_a, sink_b],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=False),
|
||||
payload_policy_by_op={"exec": EventPayloadPolicy(include_exec_output=False)},
|
||||
)
|
||||
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="exec",
|
||||
span_id="span_exec",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
event.stdout_bytes = b"hello"
|
||||
event.stderr_bytes = b""
|
||||
|
||||
await instrumentation.emit(event)
|
||||
|
||||
assert isinstance(first[0], SandboxSessionFinishEvent)
|
||||
assert isinstance(second[0], SandboxSessionFinishEvent)
|
||||
assert first[0].stdout is None
|
||||
assert second[0].stdout == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instrumentation_redacts_raw_exec_bytes_when_output_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
session = _build_session(tmp_path)
|
||||
sink = CallbackSink(lambda event, _session: events.append(event), mode="sync")
|
||||
sink.bind(session)
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[sink],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=False),
|
||||
)
|
||||
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="exec",
|
||||
span_id="span_exec",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
event.stdout_bytes = b"secret"
|
||||
event.stderr_bytes = b"secret2"
|
||||
|
||||
await instrumentation.emit(event)
|
||||
|
||||
assert isinstance(events[0], SandboxSessionFinishEvent)
|
||||
assert events[0].stdout_bytes is None
|
||||
assert events[0].stderr_bytes is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chained_sink_preserves_completion_order_across_modes() -> None:
|
||||
completed = asyncio.Event()
|
||||
|
||||
class SlowBestEffortSink(_EventSink):
|
||||
async def handle(self, event: SandboxSessionEvent) -> None:
|
||||
_ = event
|
||||
await asyncio.sleep(0)
|
||||
completed.set()
|
||||
|
||||
class AssertAfterSink(_EventSink):
|
||||
async def handle(self, event: SandboxSessionEvent) -> None:
|
||||
_ = event
|
||||
assert completed.is_set(), "later sink ran before earlier sink completed"
|
||||
|
||||
sink_a = SlowBestEffortSink(mode="best_effort", on_error="raise")
|
||||
sink_b = AssertAfterSink(mode="sync", on_error="raise")
|
||||
instrumentation = Instrumentation(sinks=[ChainedSink(sink_a, sink_b)])
|
||||
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="running",
|
||||
span_id="span_running",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
await instrumentation.emit(event)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_sink_raise_propagates_to_emit() -> None:
|
||||
class _FailingAsyncSink(_EventSink):
|
||||
async def handle(self, event: SandboxSessionEvent) -> None:
|
||||
_ = event
|
||||
await asyncio.sleep(0)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
instrumentation = Instrumentation(sinks=[_FailingAsyncSink(mode="async", on_error="raise")])
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="running",
|
||||
span_id="span_running",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await instrumentation.emit(event)
|
||||
|
||||
|
||||
def test_session_manager_uses_custom_snapshot_spec_without_resolving_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
called = False
|
||||
|
||||
def _unexpected_default_resolution() -> LocalSnapshotSpec:
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("default snapshot resolution should not run")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.runtime_session_manager.resolve_default_local_snapshot_spec",
|
||||
_unexpected_default_resolution,
|
||||
)
|
||||
|
||||
custom = LocalSnapshotSpec(base_path=Path("/tmp/custom-sandbox-snapshots"))
|
||||
resolved = SandboxRuntimeSessionManager._resolve_snapshot_spec(custom)
|
||||
|
||||
assert resolved is custom
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_session_manager_falls_back_to_noop_when_default_snapshot_resolution_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_os_error() -> LocalSnapshotSpec:
|
||||
raise OSError("read-only home")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agents.sandbox.runtime_session_manager.resolve_default_local_snapshot_spec",
|
||||
_raise_os_error,
|
||||
)
|
||||
|
||||
resolved = SandboxRuntimeSessionManager._resolve_snapshot_spec(None)
|
||||
|
||||
assert isinstance(resolved, NoopSnapshotSpec)
|
||||
@@ -0,0 +1,769 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from agents.sandbox.entries import Dir, File
|
||||
from agents.sandbox.errors import WorkspaceReadNotFoundError
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.session import (
|
||||
CallbackSink,
|
||||
ChainedSink,
|
||||
EventPayloadPolicy,
|
||||
HttpProxySink,
|
||||
Instrumentation,
|
||||
JsonlOutboxSink,
|
||||
SandboxSession,
|
||||
SandboxSessionEvent,
|
||||
SandboxSessionFinishEvent,
|
||||
SandboxSessionStartEvent,
|
||||
WorkspaceJsonlSink,
|
||||
)
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.snapshot import LocalSnapshot
|
||||
from agents.tracing import custom_span, trace
|
||||
from tests.testing_processor import fetch_normalized_spans, fetch_ordered_spans
|
||||
|
||||
|
||||
def _build_unix_local_session(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
manifest: Manifest | None = None,
|
||||
exposed_ports: tuple[int, ...] = (),
|
||||
) -> UnixLocalSandboxSession:
|
||||
workspace = tmp_path / "workspace"
|
||||
snapshot = LocalSnapshot(id=str(uuid.uuid4()), base_path=tmp_path)
|
||||
session_manifest = (
|
||||
manifest.model_copy(update={"root": str(workspace)}, deep=True)
|
||||
if manifest is not None
|
||||
else Manifest(root=str(workspace))
|
||||
)
|
||||
state = UnixLocalSandboxSessionState(
|
||||
manifest=session_manifest,
|
||||
snapshot=snapshot,
|
||||
exposed_ports=exposed_ports,
|
||||
)
|
||||
return UnixLocalSandboxSession.from_state(state)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_exec_emits_stdout_when_enabled(tmp_path: Path) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=True),
|
||||
)
|
||||
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
async with SandboxSession(inner, instrumentation=instrumentation) as session:
|
||||
result = await session.exec("echo hi")
|
||||
assert result.ok()
|
||||
|
||||
exec_finish = [event for event in events if event.op == "exec" and event.phase == "finish"][0]
|
||||
assert isinstance(exec_finish, SandboxSessionFinishEvent)
|
||||
assert exec_finish.stdout is not None
|
||||
assert "hi" in exec_finish.stdout
|
||||
assert exec_finish.trace_id is None
|
||||
assert exec_finish.span_id.startswith("sandbox_op_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_write_does_not_include_bytes_when_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")],
|
||||
payload_policy=EventPayloadPolicy(include_write_len=False),
|
||||
)
|
||||
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
async with SandboxSession(inner, instrumentation=instrumentation) as session:
|
||||
await session.write(Path("x.txt"), io.BytesIO(b"hello"))
|
||||
|
||||
write_start = [event for event in events if event.op == "write" and event.phase == "start"][0]
|
||||
assert "bytes" not in write_start.data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jsonl_outbox_sink_appends_one_line_per_event(tmp_path: Path) -> None:
|
||||
outbox = tmp_path / "events.jsonl"
|
||||
sink = JsonlOutboxSink(outbox, mode="sync", on_error="raise")
|
||||
|
||||
start_event = SandboxSessionStartEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="write",
|
||||
span_id="span_write",
|
||||
)
|
||||
finish_event = SandboxSessionFinishEvent(
|
||||
session_id=start_event.session_id,
|
||||
seq=2,
|
||||
op="write",
|
||||
span_id=start_event.span_id,
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
|
||||
await sink.handle(start_event)
|
||||
await sink.handle(finish_event)
|
||||
|
||||
lines = outbox.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0])["phase"] == "start"
|
||||
assert json.loads(lines[1])["phase"] == "finish"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chained_sink_runs_in_order(tmp_path: Path) -> None:
|
||||
outbox = tmp_path / "events.jsonl"
|
||||
seen: list[int] = []
|
||||
|
||||
def _callback(_event: SandboxSessionEvent, _session: BaseSandboxSession) -> None:
|
||||
seen.append(len(outbox.read_text(encoding="utf-8").splitlines()))
|
||||
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
callback_sink = CallbackSink(_callback, mode="sync")
|
||||
callback_sink.bind(inner)
|
||||
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[
|
||||
ChainedSink(
|
||||
JsonlOutboxSink(outbox, mode="sync", on_error="raise"),
|
||||
callback_sink,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
start_event = SandboxSessionStartEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="write",
|
||||
span_id="span_write",
|
||||
)
|
||||
finish_event = SandboxSessionFinishEvent(
|
||||
session_id=start_event.session_id,
|
||||
seq=2,
|
||||
op="write",
|
||||
span_id=start_event.span_id,
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
|
||||
await instrumentation.emit(start_event)
|
||||
await instrumentation.emit(finish_event)
|
||||
|
||||
assert seen == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_writes_into_workspace_and_persists(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[WorkspaceJsonlSink(mode="sync", on_error="raise", ephemeral=False)]
|
||||
)
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
async with wrapped as session:
|
||||
await session.exec("echo hi")
|
||||
|
||||
outbox_stream = await inner.read(Path(f"logs/events-{inner.state.session_id}.jsonl"))
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
assert any(json.loads(line)["op"] == "exec" for line in lines)
|
||||
|
||||
snapshot_path = tmp_path / f"{inner.state.snapshot.id}.tar"
|
||||
with tarfile.open(snapshot_path, mode="r:*") as tar:
|
||||
names = [member.name for member in tar.getmembers()]
|
||||
assert any(f"logs/events-{inner.state.session_id}.jsonl" in name for name in names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_supports_session_id_template(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
relpath = Path("logs/events-{session_id}.jsonl")
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[
|
||||
WorkspaceJsonlSink(
|
||||
mode="sync",
|
||||
on_error="raise",
|
||||
ephemeral=False,
|
||||
workspace_relpath=relpath,
|
||||
)
|
||||
]
|
||||
)
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
async with wrapped as session:
|
||||
await session.exec("echo hi")
|
||||
|
||||
expected_path = Path(f"logs/events-{inner.state.session_id}.jsonl")
|
||||
outbox_stream = await inner.read(expected_path)
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
assert any(json.loads(line)["op"] == "exec" for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_preserves_preexisting_outbox_contents(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
relpath = Path(f"logs/events-{inner.state.session_id}.jsonl")
|
||||
old_line = b'{"old":true}\n'
|
||||
|
||||
async with inner:
|
||||
await inner.write(relpath, io.BytesIO(old_line))
|
||||
sink = WorkspaceJsonlSink(mode="sync", on_error="raise", ephemeral=False)
|
||||
sink.bind(inner)
|
||||
|
||||
start = SandboxSessionStartEvent(
|
||||
session_id=inner.state.session_id,
|
||||
seq=1,
|
||||
op="write",
|
||||
span_id=str(uuid.uuid4()),
|
||||
)
|
||||
finish = SandboxSessionFinishEvent(
|
||||
session_id=inner.state.session_id,
|
||||
seq=2,
|
||||
op="write",
|
||||
span_id=start.span_id,
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
|
||||
await sink.handle(start)
|
||||
await sink.handle(finish)
|
||||
|
||||
outbox_stream = await inner.read(relpath)
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
|
||||
assert len(lines) == 3
|
||||
assert json.loads(lines[0]) == {"old": True}
|
||||
assert json.loads(lines[1])["seq"] == 1
|
||||
assert json.loads(lines[2])["seq"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_does_not_duplicate_lines_across_flushes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
relpath = Path(f"logs/events-{inner.state.session_id}.jsonl")
|
||||
|
||||
async with inner:
|
||||
sink = WorkspaceJsonlSink(mode="sync", on_error="raise", ephemeral=False, flush_every=1)
|
||||
sink.bind(inner)
|
||||
|
||||
for seq in (1, 2, 3):
|
||||
await sink.handle(
|
||||
SandboxSessionStartEvent(
|
||||
session_id=inner.state.session_id,
|
||||
seq=seq,
|
||||
op="write",
|
||||
span_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
|
||||
outbox_stream = await inner.read(relpath)
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
|
||||
assert [json.loads(line)["seq"] for line in lines] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_clears_flushed_buffer(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
relpath = Path(f"logs/events-{inner.state.session_id}.jsonl")
|
||||
|
||||
async with inner:
|
||||
sink = WorkspaceJsonlSink(mode="sync", on_error="raise", ephemeral=False, flush_every=1)
|
||||
sink.bind(inner)
|
||||
|
||||
for seq in (1, 2):
|
||||
await sink.handle(
|
||||
SandboxSessionStartEvent(
|
||||
session_id=inner.state.session_id,
|
||||
seq=seq,
|
||||
op="write",
|
||||
span_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
assert sink._buf == bytearray()
|
||||
|
||||
outbox_stream = await inner.read(relpath)
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
|
||||
assert [json.loads(line)["seq"] for line in lines] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_ephemeral_excludes_runtime_outbox_with_existing_parent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
inner = _build_unix_local_session(
|
||||
tmp_path,
|
||||
manifest=Manifest(
|
||||
entries={
|
||||
"logs": Dir(
|
||||
children={
|
||||
"keep.txt": File(content=b"keep"),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[WorkspaceJsonlSink(mode="sync", on_error="raise", ephemeral=True)]
|
||||
)
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
async with wrapped as session:
|
||||
await session.exec("echo hi")
|
||||
relpath = Path(f"logs/events-{inner.state.session_id}.jsonl")
|
||||
outbox_stream = await inner.read(relpath)
|
||||
assert outbox_stream.read()
|
||||
|
||||
logs_entry = inner.state.manifest.entries["logs"]
|
||||
assert isinstance(logs_entry, Dir)
|
||||
assert {str(child) for child in logs_entry.children.keys()} == {"keep.txt"}
|
||||
|
||||
snapshot_path = tmp_path / f"{inner.state.snapshot.id}.tar"
|
||||
with tarfile.open(snapshot_path, mode="r:*") as tar:
|
||||
names = [member.name for member in tar.getmembers()]
|
||||
assert any(name.endswith("logs/keep.txt") for name in names)
|
||||
assert not any(f"logs/events-{inner.state.session_id}.jsonl" in name for name in names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_jsonl_sink_flushes_on_stop_when_flush_every_gt_one(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[
|
||||
WorkspaceJsonlSink(
|
||||
mode="sync",
|
||||
on_error="raise",
|
||||
ephemeral=False,
|
||||
flush_every=10,
|
||||
)
|
||||
]
|
||||
)
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
async with wrapped as session:
|
||||
await session.exec("echo hi")
|
||||
|
||||
outbox_stream = await inner.read(Path(f"logs/events-{inner.state.session_id}.jsonl"))
|
||||
lines = outbox_stream.read().decode("utf-8").splitlines()
|
||||
assert lines
|
||||
|
||||
snapshot_path = tmp_path / f"{inner.state.snapshot.id}.tar"
|
||||
with tarfile.open(snapshot_path, mode="r:*") as tar:
|
||||
names = [member.name for member in tar.getmembers()]
|
||||
assert any(f"logs/events-{inner.state.session_id}.jsonl" in name for name in names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_sink_receives_bound_inner_session(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
seen: list[tuple[str, BaseSandboxSession]] = []
|
||||
|
||||
def _callback(event: SandboxSessionEvent, session: BaseSandboxSession) -> None:
|
||||
seen.append((event.op, session))
|
||||
|
||||
instrumentation = Instrumentation(sinks=[CallbackSink(_callback, mode="sync")])
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
async with wrapped as session:
|
||||
await session.exec("echo hi")
|
||||
|
||||
assert seen
|
||||
assert all(session is inner for _op, session in seen)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_proxy_sink_spools_direct_timeout(tmp_path: Path) -> None:
|
||||
spool_path = tmp_path / "events.jsonl"
|
||||
sink = HttpProxySink(
|
||||
"http://127.0.0.1:9/events",
|
||||
mode="sync",
|
||||
on_error="raise",
|
||||
spool_path=spool_path,
|
||||
)
|
||||
event = SandboxSessionStartEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="write",
|
||||
span_id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with patch("agents.sandbox.session.sinks.urlopen", side_effect=TimeoutError("timed out")):
|
||||
with pytest.raises(RuntimeError, match="http proxy sink POST failed"):
|
||||
await sink.handle(event)
|
||||
|
||||
lines = spool_path.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 1
|
||||
assert json.loads(lines[0])["seq"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_error_events_and_traces_include_retryability(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")]
|
||||
)
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
|
||||
with trace("sandbox_retryability_test"):
|
||||
async with SandboxSession(inner, instrumentation=instrumentation) as session:
|
||||
with pytest.raises(WorkspaceReadNotFoundError):
|
||||
await session.read(Path("missing.txt"))
|
||||
|
||||
read_finish = [event for event in events if event.op == "read" and event.phase == "finish"][0]
|
||||
assert isinstance(read_finish, SandboxSessionFinishEvent)
|
||||
assert read_finish.error_retryable is False
|
||||
|
||||
spans = fetch_normalized_spans()
|
||||
read_span = next(
|
||||
child for child in spans[0]["children"] if child["data"]["name"] == "sandbox.read"
|
||||
)
|
||||
span_data = read_span["data"]
|
||||
assert isinstance(span_data, dict)
|
||||
span_payload = span_data["data"]
|
||||
assert isinstance(span_payload, dict)
|
||||
assert span_payload["error_retryable"] is False
|
||||
|
||||
raw_read_span = next(
|
||||
span for span in fetch_ordered_spans() if span.span_data.export()["name"] == "sandbox.read"
|
||||
)
|
||||
span_error = raw_read_span.error
|
||||
assert span_error is not None
|
||||
error_payload = span_error["data"]
|
||||
assert isinstance(error_payload, dict)
|
||||
assert error_payload["error_retryable"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_ops_nest_under_sdk_trace_and_events_carry_trace_ids(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=True),
|
||||
)
|
||||
inner = _build_unix_local_session(tmp_path, exposed_ports=(8765,))
|
||||
written_bytes = b"hello from sandbox tracing test\n"
|
||||
|
||||
with trace("sandbox_test"):
|
||||
with custom_span("sandbox_parent"):
|
||||
async with SandboxSession(inner, instrumentation=instrumentation) as session:
|
||||
running = await session.running()
|
||||
assert running
|
||||
|
||||
await session.write(Path("notes.txt"), io.BytesIO(written_bytes))
|
||||
read_handle = await session.read(Path("notes.txt"))
|
||||
try:
|
||||
assert read_handle.read() == written_bytes
|
||||
finally:
|
||||
read_handle.close()
|
||||
|
||||
endpoint = await session.resolve_exposed_port(8765)
|
||||
assert (endpoint.host, endpoint.port, endpoint.tls) == ("127.0.0.1", 8765, False)
|
||||
|
||||
persisted_workspace = await session.persist_workspace()
|
||||
try:
|
||||
persisted_workspace_bytes = persisted_workspace.read()
|
||||
finally:
|
||||
persisted_workspace.close()
|
||||
assert persisted_workspace_bytes
|
||||
|
||||
await session.hydrate_workspace(io.BytesIO(persisted_workspace_bytes))
|
||||
|
||||
slow_result = await session.exec("sleep 1 && echo slow span")
|
||||
assert slow_result.ok()
|
||||
|
||||
fast_result = await session.exec("echo hi")
|
||||
assert fast_result.ok()
|
||||
|
||||
failing_result = await session.exec("echo failing >&2; exit 7")
|
||||
assert failing_result.exit_code == 7
|
||||
assert failing_result.stderr.strip()
|
||||
|
||||
spans = fetch_normalized_spans()
|
||||
assert len(spans) == 1
|
||||
parent_span = spans[0]["children"][0]
|
||||
sandbox_children = parent_span["children"]
|
||||
|
||||
stable_span_tree = [
|
||||
{
|
||||
"workflow_name": spans[0]["workflow_name"],
|
||||
"children": [
|
||||
{
|
||||
"type": parent_span["type"],
|
||||
"data": parent_span["data"],
|
||||
"children": [
|
||||
{
|
||||
"type": child["type"],
|
||||
"data": {
|
||||
"name": child["data"]["name"],
|
||||
"data": {
|
||||
key: value
|
||||
for key, value in child["data"]["data"].items()
|
||||
if key
|
||||
in {
|
||||
"alive",
|
||||
"error.type",
|
||||
"exit_code",
|
||||
"process.exit.code",
|
||||
"sandbox.backend",
|
||||
"sandbox.operation",
|
||||
"server.address",
|
||||
"server.port",
|
||||
}
|
||||
},
|
||||
},
|
||||
**({"error": child["error"]} if "error" in child else {}),
|
||||
}
|
||||
for child in sandbox_children
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
assert stable_span_tree == snapshot(
|
||||
[
|
||||
{
|
||||
"workflow_name": "sandbox_test",
|
||||
"children": [
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {"name": "sandbox_parent", "data": {}},
|
||||
"children": [
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.start",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "start",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.running",
|
||||
"data": {
|
||||
"alive": True,
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "running",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.write",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "write",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.read",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "read",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.resolve_exposed_port",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "resolve_exposed_port",
|
||||
"server.address": "127.0.0.1",
|
||||
"server.port": 8765,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.persist_workspace",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "persist_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.hydrate_workspace",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "hydrate_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.exec",
|
||||
"data": {
|
||||
"exit_code": 0,
|
||||
"process.exit.code": 0,
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "exec",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.exec",
|
||||
"data": {
|
||||
"exit_code": 0,
|
||||
"process.exit.code": 0,
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "exec",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.exec",
|
||||
"data": {
|
||||
"error.type": "ExecNonZeroError",
|
||||
"exit_code": 7,
|
||||
"process.exit.code": 7,
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "exec",
|
||||
},
|
||||
},
|
||||
"error": {
|
||||
"message": "Sandbox operation returned an unsuccessful result.",
|
||||
"data": {"operation": "exec", "exit_code": 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.stop",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"data": {
|
||||
"name": "sandbox.shutdown",
|
||||
"data": {
|
||||
"sandbox.backend": "unix_local",
|
||||
"sandbox.operation": "shutdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
session_ids = {child["data"]["data"]["session_id"] for child in sandbox_children}
|
||||
sandbox_session_ids = {
|
||||
child["data"]["data"]["sandbox.session.id"] for child in sandbox_children
|
||||
}
|
||||
assert len(session_ids) == 1
|
||||
assert len(sandbox_session_ids) == 1
|
||||
session_id = session_ids.pop()
|
||||
sandbox_session_id = sandbox_session_ids.pop()
|
||||
assert isinstance(session_id, str)
|
||||
assert isinstance(sandbox_session_id, str)
|
||||
assert str(uuid.UUID(session_id)) == session_id
|
||||
assert sandbox_session_id == session_id
|
||||
|
||||
exec_spans = [child for child in sandbox_children if child["data"]["name"] == "sandbox.exec"]
|
||||
assert len(exec_spans) == 3
|
||||
|
||||
exec_finish = [event for event in events if event.op == "exec" and event.phase == "finish"][0]
|
||||
assert isinstance(exec_finish, SandboxSessionFinishEvent)
|
||||
assert exec_finish.trace_id is not None
|
||||
assert exec_finish.span_id.startswith("span_")
|
||||
assert exec_finish.parent_span_id is not None
|
||||
assert sum(1 for event in events if event.op == "exec" and event.phase == "finish") == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_events_fallback_to_audit_ids_under_disabled_parent_span(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[SandboxSessionEvent] = []
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(lambda e, _sess: events.append(e), mode="sync")],
|
||||
)
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
|
||||
with trace("sandbox_disabled_parent_test"):
|
||||
with custom_span("disabled_parent", disabled=True):
|
||||
async with SandboxSession(inner, instrumentation=instrumentation) as session:
|
||||
result = await session.exec("echo hi")
|
||||
assert result.ok()
|
||||
|
||||
exec_events = [event for event in events if event.op == "exec"]
|
||||
assert len(exec_events) == 2
|
||||
start_event, finish_event = exec_events
|
||||
assert isinstance(start_event, SandboxSessionStartEvent)
|
||||
assert isinstance(finish_event, SandboxSessionFinishEvent)
|
||||
assert start_event.trace_id is None
|
||||
assert finish_event.trace_id is None
|
||||
assert start_event.parent_span_id is None
|
||||
assert finish_event.parent_span_id is None
|
||||
assert start_event.span_id == finish_event.span_id
|
||||
assert start_event.span_id.startswith("sandbox_op_")
|
||||
assert start_event.span_id != "no-op"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_aclose_flushes_best_effort_sink_tasks(tmp_path: Path) -> None:
|
||||
inner = _build_unix_local_session(tmp_path)
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
async def _callback(event: SandboxSessionEvent, _session: BaseSandboxSession) -> None:
|
||||
await asyncio.sleep(0)
|
||||
seen.append((event.op, event.phase))
|
||||
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[CallbackSink(_callback, mode="best_effort", on_error="log")]
|
||||
)
|
||||
wrapped = SandboxSession(inner, instrumentation=instrumentation)
|
||||
|
||||
await wrapped.start()
|
||||
await wrapped.aclose()
|
||||
|
||||
assert ("stop", "finish") in seen
|
||||
assert ("shutdown", "finish") in seen
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for JSON round-trip safety of SandboxSessionState.
|
||||
|
||||
Verifies that SandboxSessionState can survive serialization to JSON and
|
||||
deserialization back without losing subclass identity, subclass-specific
|
||||
fields, or the ``type`` discriminator under ``exclude_unset``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.session import SandboxSessionState
|
||||
from agents.sandbox.snapshot import LocalSnapshot
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-only stubs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubSessionState(SandboxSessionState):
|
||||
__test__ = False
|
||||
type: Literal["stub-roundtrip"] = "stub-roundtrip"
|
||||
custom_field: str
|
||||
|
||||
|
||||
class _PlainTypeSessionState(SandboxSessionState):
|
||||
__test__ = False
|
||||
type: str = "plain-type"
|
||||
|
||||
|
||||
class _EmptyDefaultSessionState(SandboxSessionState):
|
||||
__test__ = False
|
||||
type: Literal[""] = ""
|
||||
|
||||
|
||||
class _SimpleSessionState(SandboxSessionState):
|
||||
__test__ = False
|
||||
type: Literal["simple-roundtrip"] = "simple-roundtrip"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_session_state() -> _StubSessionState:
|
||||
return _StubSessionState(
|
||||
session_id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
|
||||
manifest=Manifest(),
|
||||
custom_field="my-value",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSandboxSessionStateRoundTrip:
|
||||
def test_parse_reconstructs_subclass_from_json(self) -> None:
|
||||
"""SandboxSessionState.parse() must reconstruct the correct subclass from a dict."""
|
||||
original = _make_session_state()
|
||||
payload = json.loads(original.model_dump_json())
|
||||
|
||||
reconstructed = SandboxSessionState.parse(payload)
|
||||
|
||||
assert type(reconstructed) is _StubSessionState
|
||||
assert reconstructed.custom_field == "my-value"
|
||||
|
||||
def test_model_validate_json_loses_subclass(self) -> None:
|
||||
"""Pydantic's model_validate_json against the base class loses subclass identity.
|
||||
|
||||
This documents the limitation that parse() exists to solve.
|
||||
"""
|
||||
original = _make_session_state()
|
||||
json_str = original.model_dump_json()
|
||||
|
||||
base_instance = SandboxSessionState.model_validate_json(json_str)
|
||||
|
||||
assert type(base_instance) is SandboxSessionState
|
||||
assert not hasattr(base_instance, "custom_field")
|
||||
|
||||
def test_type_survives_exclude_unset(self) -> None:
|
||||
"""The ``type`` discriminator must survive model_dump(exclude_unset=True).
|
||||
|
||||
Since ``type`` is set via a class-level default it is not in
|
||||
model_fields_set. Without the model_serializer, exclude_unset=True
|
||||
drops it, making SandboxSessionState.parse() fail.
|
||||
"""
|
||||
state = _make_session_state()
|
||||
dumped = state.model_dump(exclude_unset=True)
|
||||
|
||||
assert "type" in dumped
|
||||
assert dumped["type"] == "stub-roundtrip"
|
||||
|
||||
def test_model_dump_preserves_snapshot_subclass_fields(self) -> None:
|
||||
"""model_dump() must preserve snapshot subclass fields (e.g. LocalSnapshot.base_path).
|
||||
|
||||
Without SerializeAsAny, Pydantic serializes using the declared field
|
||||
type (SnapshotBase), silently dropping subclass-specific fields.
|
||||
"""
|
||||
state = _make_session_state()
|
||||
dumped = state.model_dump()
|
||||
|
||||
assert "base_path" in dumped["snapshot"]
|
||||
|
||||
def test_parse_returns_subclass_instances_as_is(self) -> None:
|
||||
state = _make_session_state()
|
||||
|
||||
assert SandboxSessionState.parse(state) is state
|
||||
|
||||
def test_parse_upgrades_base_instance_through_registry(self) -> None:
|
||||
state = _SimpleSessionState(
|
||||
session_id=uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
|
||||
manifest=Manifest(),
|
||||
)
|
||||
base_instance = SandboxSessionState.model_validate(state.model_dump())
|
||||
|
||||
reconstructed = SandboxSessionState.parse(base_instance)
|
||||
|
||||
assert type(reconstructed) is _SimpleSessionState
|
||||
assert reconstructed.session_id == uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "error_type", "message"),
|
||||
[
|
||||
({}, ValueError, "must include a string `type`"),
|
||||
({"type": "missing"}, ValueError, "unknown sandbox session state type `missing`"),
|
||||
("not-a-state", TypeError, "session state payload must be"),
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_invalid_payloads(
|
||||
self,
|
||||
payload: object,
|
||||
error_type: type[Exception],
|
||||
message: str,
|
||||
) -> None:
|
||||
with pytest.raises(error_type, match=message):
|
||||
SandboxSessionState.parse(payload)
|
||||
|
||||
def test_subclass_registration_skips_non_literal_or_empty_type_defaults(self) -> None:
|
||||
assert "plain-type" not in SandboxSessionState._subclass_registry
|
||||
assert "" not in SandboxSessionState._subclass_registry
|
||||
|
||||
def test_subclass_registration_skips_missing_type_field(self) -> None:
|
||||
class _NoTypeFieldSessionState(SandboxSessionState):
|
||||
type: ClassVar[str] = "no-type-field" # type: ignore[misc]
|
||||
|
||||
assert "no-type-field" not in SandboxSessionState._subclass_registry
|
||||
assert "type" not in _NoTypeFieldSessionState.model_fields
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_ports", "expected"),
|
||||
[
|
||||
(None, ()),
|
||||
(8080, (8080,)),
|
||||
([8080, 9000, 8080], (8080, 9000)),
|
||||
],
|
||||
)
|
||||
def test_exposed_ports_are_normalized(
|
||||
self, raw_ports: object, expected: tuple[int, ...]
|
||||
) -> None:
|
||||
state = _StubSessionState(
|
||||
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
|
||||
manifest=Manifest(),
|
||||
custom_field="my-value",
|
||||
exposed_ports=raw_ports, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert state.exposed_ports == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_ports", "message"),
|
||||
[
|
||||
("8080", "exposed_ports must be an iterable"),
|
||||
([8080, "9000"], "exposed_ports must contain integers"),
|
||||
([0], "exposed_ports entries must be between 1 and 65535"),
|
||||
([65536], "exposed_ports entries must be between 1 and 65535"),
|
||||
],
|
||||
)
|
||||
def test_exposed_ports_reject_invalid_values(self, raw_ports: object, message: str) -> None:
|
||||
with pytest.raises((TypeError, ValidationError), match=message):
|
||||
_StubSessionState(
|
||||
snapshot=LocalSnapshot(id="snap-1", base_path=Path("/tmp/snapshots")),
|
||||
manifest=Manifest(),
|
||||
custom_field="my-value",
|
||||
exposed_ports=raw_ports, # type: ignore[arg-type]
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import shlex
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.entries import GCSMount, InContainerMountStrategy, MountpointMountPattern
|
||||
from agents.sandbox.errors import MountConfigError
|
||||
from agents.sandbox.files import EntryKind, FileEntry
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.session import SandboxSessionStartEvent
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.events import SandboxSessionFinishEvent, validate_sandbox_session_event
|
||||
from agents.sandbox.session.utils import (
|
||||
_best_effort_stream_len,
|
||||
_safe_decode,
|
||||
event_to_json_line,
|
||||
)
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, Permissions, User
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
class _CaptureExecSession(BaseSandboxSession):
|
||||
def __init__(self) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
self.last_command: tuple[str, ...] | None = None
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = timeout
|
||||
self.last_command = tuple(str(part) for part in command)
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
|
||||
_ = (path, user)
|
||||
raise AssertionError("read() should not be called in this test")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = (path, data, user)
|
||||
raise AssertionError("write() should not be called in this test")
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO()
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _ManifestSession(_CaptureExecSession):
|
||||
def __init__(self, manifest: Manifest) -> None:
|
||||
super().__init__()
|
||||
self.state = TestSessionState(
|
||||
manifest=manifest,
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
|
||||
|
||||
def test_safe_decode_truncates_and_appends_ellipsis() -> None:
|
||||
assert _safe_decode(b"abcdef", max_chars=3) == "abc…"
|
||||
|
||||
|
||||
def test_best_effort_stream_len_tracks_remaining_bytes_for_seekable_streams() -> None:
|
||||
buffer = io.BytesIO(b"hello")
|
||||
assert _best_effort_stream_len(buffer) == 5
|
||||
assert buffer.read(1) == b"h"
|
||||
assert _best_effort_stream_len(buffer) == 4
|
||||
|
||||
|
||||
class _NoSeekableMethodStream(io.IOBase):
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
self._buffer = io.BytesIO(payload)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._buffer.tell()
|
||||
|
||||
def seek(self, offset: int, whence: int = io.SEEK_SET) -> int:
|
||||
return self._buffer.seek(offset, whence)
|
||||
|
||||
|
||||
def test_best_effort_stream_len_handles_streams_without_seekable_method() -> None:
|
||||
stream = _NoSeekableMethodStream(b"hello")
|
||||
|
||||
assert _best_effort_stream_len(stream) == 5
|
||||
stream.seek(2)
|
||||
assert _best_effort_stream_len(stream) == 3
|
||||
|
||||
|
||||
def test_event_to_json_line_is_single_line() -> None:
|
||||
event = SandboxSessionStartEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="write",
|
||||
span_id="span_write",
|
||||
data={"x": 1},
|
||||
)
|
||||
|
||||
line = event_to_json_line(event)
|
||||
assert line.endswith("\n")
|
||||
assert "\n" not in line[:-1]
|
||||
|
||||
|
||||
def test_validate_sandbox_session_event_uses_phase_discriminator() -> None:
|
||||
event = SandboxSessionStartEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="read",
|
||||
span_id="span_read",
|
||||
)
|
||||
|
||||
restored = validate_sandbox_session_event(event.model_dump(mode="json"))
|
||||
|
||||
assert isinstance(restored, SandboxSessionStartEvent)
|
||||
assert restored.phase == "start"
|
||||
assert restored.op == "read"
|
||||
|
||||
|
||||
def test_sandbox_session_finish_event_excludes_raw_bytes_from_json_dump() -> None:
|
||||
event = SandboxSessionFinishEvent(
|
||||
session_id=uuid.uuid4(),
|
||||
seq=1,
|
||||
op="exec",
|
||||
span_id="span_exec",
|
||||
ok=True,
|
||||
duration_ms=0.0,
|
||||
)
|
||||
event.stdout_bytes = b"secret"
|
||||
event.stderr_bytes = b"secret2"
|
||||
|
||||
dumped = event.model_dump(mode="json")
|
||||
assert "stdout_bytes" not in dumped
|
||||
assert "stderr_bytes" not in dumped
|
||||
|
||||
|
||||
def test_file_entry_is_dir_uses_kind() -> None:
|
||||
directory_entry = FileEntry(
|
||||
path="/workspace/dir",
|
||||
permissions=Permissions.from_str("drwxr-xr-x"),
|
||||
owner="root",
|
||||
group="root",
|
||||
size=0,
|
||||
kind=EntryKind.DIRECTORY,
|
||||
)
|
||||
file_entry = FileEntry(
|
||||
path="/workspace/file.txt",
|
||||
permissions=Permissions.from_str("-rw-r--r--"),
|
||||
owner="root",
|
||||
group="root",
|
||||
size=3,
|
||||
kind=EntryKind.FILE,
|
||||
)
|
||||
|
||||
assert directory_entry.is_dir() is True
|
||||
assert file_entry.is_dir() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_shell_true_quotes_multi_arg_commands() -> None:
|
||||
session = _CaptureExecSession()
|
||||
|
||||
await session.exec("printf", "%s\n", "hello world", "$(whoami)", "semi;colon", shell=True)
|
||||
|
||||
assert session.last_command == (
|
||||
"sh",
|
||||
"-lc",
|
||||
shlex.join(["printf", "%s\n", "hello world", "$(whoami)", "semi;colon"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_shell_true_preserves_single_shell_snippet() -> None:
|
||||
session = _CaptureExecSession()
|
||||
|
||||
await session.exec("echo hello && echo goodbye", shell=True)
|
||||
|
||||
assert session.last_command == ("sh", "-lc", "echo hello && echo goodbye")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_mkdir_with_exec_runs_non_destructive_probe_as_user() -> None:
|
||||
session = _CaptureExecSession()
|
||||
|
||||
checked_path = await session._check_mkdir_with_exec(
|
||||
Path("nested/dir"),
|
||||
parents=True,
|
||||
user=User(name="sandbox-user"),
|
||||
)
|
||||
|
||||
assert checked_path == Path("/workspace/nested/dir")
|
||||
assert session.last_command is not None
|
||||
assert session.last_command[:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.last_command[4:6] == ("sh", "-lc")
|
||||
assert session.last_command[-2:] == ("/workspace/nested/dir", "1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_rm_with_exec_runs_parent_write_probe_as_user() -> None:
|
||||
session = _CaptureExecSession()
|
||||
|
||||
checked_path = await session._check_rm_with_exec(
|
||||
Path("stale.txt"),
|
||||
recursive=False,
|
||||
user=User(name="sandbox-user"),
|
||||
)
|
||||
|
||||
assert checked_path == Path("/workspace/stale.txt")
|
||||
assert session.last_command is not None
|
||||
assert session.last_command[:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.last_command[4:6] == ("sh", "-lc")
|
||||
assert session.last_command[-2:] == ("/workspace/stale.txt", "0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("skip_path", "mount_path"),
|
||||
[
|
||||
("data", "data"),
|
||||
("logs", "logs/remote"),
|
||||
("data/tmp", "data"),
|
||||
],
|
||||
)
|
||||
def test_register_persist_workspace_skip_path_rejects_mount_overlaps(
|
||||
skip_path: str,
|
||||
mount_path: str,
|
||||
) -> None:
|
||||
session = _ManifestSession(
|
||||
Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"remote": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path(mount_path),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(MountConfigError) as exc_info:
|
||||
session.register_persist_workspace_skip_path(skip_path)
|
||||
|
||||
assert str(exc_info.value) == "persist workspace skip path must not overlap mount path"
|
||||
|
||||
|
||||
def test_register_persist_workspace_skip_path_allows_non_overlapping_path() -> None:
|
||||
session = _ManifestSession(
|
||||
Manifest(
|
||||
root="/workspace",
|
||||
entries={
|
||||
"remote": GCSMount(
|
||||
bucket="bucket",
|
||||
mount_path=Path("data"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
registered = session.register_persist_workspace_skip_path("logs/events.jsonl")
|
||||
|
||||
assert registered == Path("logs/events.jsonl")
|
||||
@@ -0,0 +1,823 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from pydantic import PrivateAttr, ValidationError
|
||||
|
||||
from agents.sandbox import Manifest, RemoteSnapshot, RemoteSnapshotSpec, resolve_snapshot
|
||||
from agents.sandbox.entries import File
|
||||
from agents.sandbox.errors import SnapshotPersistError
|
||||
from agents.sandbox.materialization import MaterializationResult
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxSessionState
|
||||
from agents.sandbox.session import Dependencies, SandboxSessionState
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
from agents.sandbox.snapshot import LocalSnapshot, NoopSnapshot, SnapshotBase
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
from tests.utils.factories import TestSessionState
|
||||
|
||||
|
||||
class TestNoopSnapshot(SnapshotBase):
|
||||
__test__ = False
|
||||
type: Literal["test-noop"] = "test-noop"
|
||||
|
||||
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
raise FileNotFoundError(Path("<test-noop>"))
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return False
|
||||
|
||||
|
||||
class TestRestorableSnapshot(SnapshotBase):
|
||||
__test__ = False
|
||||
type: Literal["test-restorable"] = "test-restorable"
|
||||
payload: bytes = b"restored-workspace"
|
||||
|
||||
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
return io.BytesIO(self.payload)
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return True
|
||||
|
||||
|
||||
class _TrackingBytesIO(io.BytesIO):
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
super().__init__(payload)
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
super().close()
|
||||
|
||||
|
||||
class TestClosingRestoreSnapshot(SnapshotBase):
|
||||
__test__ = False
|
||||
type: Literal["test-closing-restore"] = "test-closing-restore"
|
||||
payload: bytes = b"restored-workspace"
|
||||
_stream: _TrackingBytesIO = PrivateAttr()
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
del __context
|
||||
self._stream = _TrackingBytesIO(self.payload)
|
||||
|
||||
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
return self._stream
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return True
|
||||
|
||||
|
||||
def test_sandbox_session_state_roundtrip_preserves_custom_snapshot_type() -> None:
|
||||
state = TestSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=TestNoopSnapshot(id="custom-snapshot"),
|
||||
snapshot_fingerprint="deadbeef",
|
||||
snapshot_fingerprint_version="workspace_tar_sha256_v1",
|
||||
)
|
||||
|
||||
payload = state.model_dump_json()
|
||||
restored = SandboxSessionState.model_validate_json(payload)
|
||||
|
||||
assert isinstance(restored.snapshot, TestNoopSnapshot)
|
||||
assert restored.snapshot.id == "custom-snapshot"
|
||||
assert restored.snapshot_fingerprint == "deadbeef"
|
||||
assert restored.snapshot_fingerprint_version == "workspace_tar_sha256_v1"
|
||||
|
||||
|
||||
def test_sandbox_session_state_model_dump_preserves_snapshot_subclass_fields() -> None:
|
||||
state = TestSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")),
|
||||
)
|
||||
|
||||
payload = state.model_dump()
|
||||
|
||||
assert payload["snapshot"] == {
|
||||
"type": "local",
|
||||
"id": "local-snapshot",
|
||||
"base_path": Path("/tmp/snapshots"),
|
||||
}
|
||||
|
||||
|
||||
def test_sandbox_session_state_model_dump_exclude_unset_preserves_snapshot_fields() -> None:
|
||||
state = TestSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")),
|
||||
)
|
||||
|
||||
payload = state.model_dump(exclude_unset=True)
|
||||
|
||||
assert payload["snapshot"] == {
|
||||
"type": "local",
|
||||
"id": "local-snapshot",
|
||||
"base_path": Path("/tmp/snapshots"),
|
||||
}
|
||||
|
||||
|
||||
def test_backend_session_state_model_dump_roundtrip_preserves_local_snapshot_fields() -> None:
|
||||
state = UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(),
|
||||
snapshot=LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")),
|
||||
)
|
||||
|
||||
payload = state.model_dump()
|
||||
restored = UnixLocalSandboxSessionState.model_validate(payload)
|
||||
|
||||
assert isinstance(restored.snapshot, LocalSnapshot)
|
||||
assert restored.snapshot.base_path == Path("/tmp/snapshots")
|
||||
|
||||
|
||||
def test_snapshot_exclude_unset_preserves_type_discriminator() -> None:
|
||||
payload = LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots")).model_dump(
|
||||
exclude_unset=True
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"type": "local",
|
||||
"id": "local-snapshot",
|
||||
"base_path": Path("/tmp/snapshots"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_snapshot_restorable_requires_file(tmp_path: Path) -> None:
|
||||
snapshot = LocalSnapshot(id="local-snapshot", base_path=tmp_path)
|
||||
snapshot_path = tmp_path / "local-snapshot.tar"
|
||||
|
||||
assert await snapshot.restorable() is False
|
||||
|
||||
snapshot_path.mkdir()
|
||||
|
||||
assert await snapshot.restorable() is False
|
||||
|
||||
snapshot_path.rmdir()
|
||||
snapshot_path.write_bytes(b"workspace")
|
||||
|
||||
assert await snapshot.restorable() is True
|
||||
|
||||
|
||||
def test_snapshot_parse_uses_registered_custom_snapshot_type() -> None:
|
||||
parsed = SnapshotBase.parse({"type": "test-noop", "id": "registered"})
|
||||
|
||||
assert isinstance(parsed, TestNoopSnapshot)
|
||||
assert parsed.id == "registered"
|
||||
|
||||
|
||||
def test_snapshot_models_are_frozen() -> None:
|
||||
snapshot = LocalSnapshot(id="local-snapshot", base_path=Path("/tmp/snapshots"))
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
snapshot.id = "changed"
|
||||
|
||||
assert exc_info.value.errors(include_url=False) == [
|
||||
{
|
||||
"type": "frozen_instance",
|
||||
"loc": ("id",),
|
||||
"msg": "Instance is frozen",
|
||||
"input": "changed",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_duplicate_snapshot_type_registration_raises() -> None:
|
||||
class TestDuplicateSnapshotA(SnapshotBase):
|
||||
__test__ = False
|
||||
type: Literal["test-duplicate"] = "test-duplicate"
|
||||
|
||||
async def persist(
|
||||
self, data: io.IOBase, *, dependencies: Dependencies | None = None
|
||||
) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
raise FileNotFoundError(Path("<test-duplicate-a>"))
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return False
|
||||
|
||||
_ = TestDuplicateSnapshotA
|
||||
|
||||
with pytest.raises(TypeError, match="already registered"):
|
||||
|
||||
class TestDuplicateSnapshotB(SnapshotBase):
|
||||
__test__ = False
|
||||
type: Literal["test-duplicate"] = "test-duplicate"
|
||||
|
||||
async def persist(
|
||||
self, data: io.IOBase, *, dependencies: Dependencies | None = None
|
||||
) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
raise FileNotFoundError(Path("<test-duplicate-b>"))
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return False
|
||||
|
||||
|
||||
def test_snapshot_subclasses_require_type_discriminator_default() -> None:
|
||||
with pytest.raises(TypeError, match="must define a non-empty string default for `type`"):
|
||||
|
||||
class TestMissingTypeSnapshot(SnapshotBase):
|
||||
__test__ = False
|
||||
|
||||
async def persist(
|
||||
self, data: io.IOBase, *, dependencies: Dependencies | None = None
|
||||
) -> None:
|
||||
_ = (data, dependencies)
|
||||
|
||||
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
||||
_ = dependencies
|
||||
raise FileNotFoundError(Path("<test-missing-type>"))
|
||||
|
||||
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
||||
_ = dependencies
|
||||
return False
|
||||
|
||||
|
||||
class _PersistTrackingSession(BaseSandboxSession):
|
||||
def __init__(self, snapshot: SnapshotBase, *, workspace_root: Path) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=Manifest(root=str(workspace_root)),
|
||||
snapshot=snapshot,
|
||||
)
|
||||
self.persist_workspace_calls = 0
|
||||
self.persist_payload = b"tracked"
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*(str(part) for part in command),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
return ExecResult(
|
||||
stdout=stdout or b"",
|
||||
stderr=stderr or b"",
|
||||
exit_code=process.returncode or 0,
|
||||
)
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
|
||||
_ = (path, user)
|
||||
raise AssertionError("read() should not be called in this test")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = (path, data, user)
|
||||
raise AssertionError("write() should not be called in this test")
|
||||
|
||||
async def running(self) -> bool:
|
||||
return True
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
self.persist_workspace_calls += 1
|
||||
return io.BytesIO(self.persist_payload)
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_ = data
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class _ResumeTrackingSession(BaseSandboxSession):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
snapshot: SnapshotBase | None = None,
|
||||
running: bool = True,
|
||||
workspace_root: Path,
|
||||
workspace_state_preserved: bool = True,
|
||||
system_state_preserved: bool = False,
|
||||
workspace_root_ready: bool | None = None,
|
||||
) -> None:
|
||||
self.state = TestSessionState(
|
||||
manifest=Manifest(root=str(workspace_root)),
|
||||
snapshot=snapshot or TestRestorableSnapshot(id="resume-snapshot"),
|
||||
)
|
||||
self.state.workspace_root_ready = (
|
||||
workspace_state_preserved if workspace_root_ready is None else workspace_root_ready
|
||||
)
|
||||
self._running = running
|
||||
self._set_start_state_preserved(
|
||||
workspace_state_preserved,
|
||||
system=system_state_preserved,
|
||||
)
|
||||
self.clear_calls = 0
|
||||
self.hydrate_payloads: list[bytes] = []
|
||||
self.apply_manifest_calls: list[bool] = []
|
||||
self.apply_manifest_provision_accounts_calls: list[bool] = []
|
||||
self.provision_manifest_accounts_calls = 0
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*(str(part) for part in command),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
return ExecResult(
|
||||
stdout=stdout or b"",
|
||||
stderr=stderr or b"",
|
||||
exit_code=process.returncode or 0,
|
||||
)
|
||||
|
||||
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
|
||||
_ = (path, user)
|
||||
raise AssertionError("read() should not be called in this test")
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
||||
_ = (path, data, user)
|
||||
raise AssertionError("write() should not be called in this test")
|
||||
|
||||
async def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
return io.BytesIO(b"persisted-workspace")
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
payload = data.read()
|
||||
assert isinstance(payload, bytes)
|
||||
self.hydrate_payloads.append(payload)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
return
|
||||
|
||||
async def _apply_manifest(
|
||||
self,
|
||||
*,
|
||||
only_ephemeral: bool = False,
|
||||
provision_accounts: bool = True,
|
||||
) -> MaterializationResult:
|
||||
self.apply_manifest_calls.append(only_ephemeral)
|
||||
self.apply_manifest_provision_accounts_calls.append(provision_accounts)
|
||||
return MaterializationResult(files=[])
|
||||
|
||||
async def apply_manifest(self, *, only_ephemeral: bool = False) -> MaterializationResult:
|
||||
return await self._apply_manifest(
|
||||
only_ephemeral=only_ephemeral,
|
||||
provision_accounts=not only_ephemeral,
|
||||
)
|
||||
|
||||
async def provision_manifest_accounts(self) -> None:
|
||||
self.provision_manifest_accounts_calls += 1
|
||||
|
||||
async def _clear_workspace_root_on_resume(self) -> None:
|
||||
self.clear_calls += 1
|
||||
|
||||
|
||||
class _ClosingPersistTrackingSession(_PersistTrackingSession):
|
||||
def __init__(self, snapshot: SnapshotBase, *, workspace_root: Path) -> None:
|
||||
super().__init__(snapshot, workspace_root=workspace_root)
|
||||
self.archive = _TrackingBytesIO(self.persist_payload)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
self.persist_workspace_calls += 1
|
||||
return self.archive
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_snapshot_stop_skips_workspace_persist(tmp_path: Path) -> None:
|
||||
session = _PersistTrackingSession(NoopSnapshot(id="noop"), workspace_root=tmp_path)
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert session.persist_workspace_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_noop_snapshot_stop_persists_workspace(tmp_path: Path) -> None:
|
||||
snapshot = TestNoopSnapshot(id="custom-snapshot")
|
||||
session = _PersistTrackingSession(snapshot, workspace_root=tmp_path)
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert session.persist_workspace_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_closes_persisted_workspace_archive(tmp_path: Path) -> None:
|
||||
snapshot = TestNoopSnapshot(id="custom-snapshot")
|
||||
session = _ClosingPersistTrackingSession(snapshot, workspace_root=tmp_path)
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert session.archive.close_calls == 1
|
||||
assert session.archive.closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_noop_snapshot_stop_records_snapshot_fingerprint(tmp_path: Path) -> None:
|
||||
(tmp_path / "tracked.txt").write_bytes(b"tracked")
|
||||
snapshot = TestNoopSnapshot(id="custom-snapshot")
|
||||
session = _PersistTrackingSession(snapshot, workspace_root=tmp_path)
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert session.state.snapshot_fingerprint is not None
|
||||
assert session.state.snapshot_fingerprint_version == "workspace_tar_sha256_v1"
|
||||
cache_payload = session._parse_snapshot_fingerprint_record(
|
||||
session._snapshot_fingerprint_cache_path().read_text()
|
||||
)
|
||||
assert cache_payload["fingerprint"] == session.state.snapshot_fingerprint
|
||||
assert cache_payload["version"] == session.state.snapshot_fingerprint_version
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_snapshot_restore_when_live_workspace_fingerprint_matches(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(workspace_root=tmp_path)
|
||||
(tmp_path / "tracked.txt").write_bytes(b"tracked")
|
||||
|
||||
await session.stop()
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_closes_restored_workspace_archive(tmp_path: Path) -> None:
|
||||
snapshot = TestClosingRestoreSnapshot(id="resume-snapshot")
|
||||
session = _ResumeTrackingSession(snapshot=snapshot, running=False, workspace_root=tmp_path)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert snapshot._stream.close_calls == 1
|
||||
assert snapshot._stream.closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_restores_snapshot_when_live_workspace_fingerprint_mismatches(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(workspace_root=tmp_path)
|
||||
tracked = tmp_path / "tracked.txt"
|
||||
tracked.write_bytes(b"tracked")
|
||||
|
||||
await session.stop()
|
||||
tracked.write_bytes(b"drifted")
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 1
|
||||
assert session.hydrate_payloads == [b"restored-workspace"]
|
||||
assert session.provision_manifest_accounts_calls == 1
|
||||
assert session.apply_manifest_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("manifest_mutation", ["ephemeral_entry", "user"])
|
||||
async def test_start_restores_snapshot_when_resume_manifest_changes(
|
||||
tmp_path: Path,
|
||||
manifest_mutation: str,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(workspace_root=tmp_path)
|
||||
(tmp_path / "tracked.txt").write_bytes(b"tracked")
|
||||
|
||||
await session.stop()
|
||||
|
||||
if manifest_mutation == "ephemeral_entry":
|
||||
session.state.manifest.entries["ephemeral.txt"] = File(content=b"temp", ephemeral=True)
|
||||
else:
|
||||
session.state.manifest.users.append(User(name="sandbox-user"))
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 1
|
||||
assert session.hydrate_payloads == [b"restored-workspace"]
|
||||
assert session.provision_manifest_accounts_calls == 1
|
||||
assert session.apply_manifest_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_applies_full_manifest_for_fresh_non_restorable_backend(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(
|
||||
snapshot=NoopSnapshot(id="fresh"),
|
||||
workspace_root=tmp_path,
|
||||
workspace_state_preserved=False,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [False]
|
||||
assert session.apply_manifest_provision_accounts_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_reapplies_only_ephemeral_manifest_for_preserved_non_restorable_backend(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(
|
||||
snapshot=NoopSnapshot(id="preserved"),
|
||||
workspace_root=tmp_path,
|
||||
workspace_state_preserved=True,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [True]
|
||||
assert session.apply_manifest_provision_accounts_calls == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_reapplies_only_ephemeral_manifest_when_preserved_probe_succeeds(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(
|
||||
snapshot=NoopSnapshot(id="preserved-probed"),
|
||||
workspace_root=tmp_path,
|
||||
workspace_state_preserved=True,
|
||||
workspace_root_ready=False,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [True]
|
||||
assert session.apply_manifest_provision_accounts_calls == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_applies_full_manifest_when_preserved_non_restorable_workspace_unproven(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(
|
||||
snapshot=NoopSnapshot(id="unproven"),
|
||||
workspace_root=tmp_path / "missing-workspace",
|
||||
workspace_state_preserved=True,
|
||||
workspace_root_ready=False,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [False]
|
||||
assert session.apply_manifest_provision_accounts_calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_applies_full_manifest_without_accounts_when_system_state_preserved(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _ResumeTrackingSession(
|
||||
snapshot=NoopSnapshot(id="system-preserved"),
|
||||
workspace_root=tmp_path / "missing-workspace",
|
||||
workspace_state_preserved=True,
|
||||
system_state_preserved=True,
|
||||
workspace_root_ready=False,
|
||||
)
|
||||
|
||||
await session.start()
|
||||
|
||||
assert session.clear_calls == 0
|
||||
assert session.hydrate_payloads == []
|
||||
assert session.provision_manifest_accounts_calls == 0
|
||||
assert session.apply_manifest_calls == [False]
|
||||
assert session.apply_manifest_provision_accounts_calls == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"snapshot_id",
|
||||
[
|
||||
"../escape",
|
||||
"..\\escape",
|
||||
"nested/escape",
|
||||
"../",
|
||||
"..//",
|
||||
"..\\",
|
||||
"nested/",
|
||||
"nested//",
|
||||
"nested\\",
|
||||
],
|
||||
)
|
||||
async def test_local_snapshot_rejects_non_basename_ids(
|
||||
tmp_path: Path,
|
||||
snapshot_id: str,
|
||||
) -> None:
|
||||
snapshot = LocalSnapshot(id=snapshot_id, base_path=tmp_path / "snapshots")
|
||||
|
||||
with pytest.raises(ValueError, match="single path segment"):
|
||||
await snapshot.persist(io.BytesIO(b"payload"))
|
||||
|
||||
with pytest.raises(ValueError, match="single path segment"):
|
||||
await snapshot.restore()
|
||||
|
||||
assert list(tmp_path.rglob("*.tar")) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_snapshot_persist_is_atomic_on_copy_failure(tmp_path: Path) -> None:
|
||||
class _FailingSnapshotSource(io.BytesIO):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(b"new-snapshot")
|
||||
self._reads = 0
|
||||
|
||||
def read(self, size: int | None = -1) -> bytes:
|
||||
self._reads += 1
|
||||
if self._reads == 1:
|
||||
return b"new"
|
||||
raise OSError("copy failed")
|
||||
|
||||
snapshot = LocalSnapshot(id="atomic", base_path=tmp_path)
|
||||
path = tmp_path / "atomic.tar"
|
||||
path.write_bytes(b"previous-snapshot")
|
||||
|
||||
with pytest.raises(SnapshotPersistError):
|
||||
await snapshot.persist(_FailingSnapshotSource())
|
||||
|
||||
assert path.read_bytes() == b"previous-snapshot"
|
||||
assert {p.name for p in tmp_path.iterdir()} == {"atomic.tar"}
|
||||
|
||||
|
||||
class _FakeRemoteSnapshotClient:
|
||||
def __init__(self) -> None:
|
||||
self.uploads: list[tuple[str, bytes]] = []
|
||||
self.downloads: list[str] = []
|
||||
self.exists_calls: list[str] = []
|
||||
self._stored: dict[str, bytes] = {}
|
||||
|
||||
async def upload(self, snapshot_id: str, data: io.IOBase) -> None:
|
||||
payload = data.read()
|
||||
assert isinstance(payload, bytes)
|
||||
self.uploads.append((snapshot_id, payload))
|
||||
self._stored[snapshot_id] = payload
|
||||
|
||||
async def download(self, snapshot_id: str) -> io.IOBase:
|
||||
self.downloads.append(snapshot_id)
|
||||
return io.BytesIO(self._stored[snapshot_id])
|
||||
|
||||
async def exists(self, snapshot_id: str) -> bool:
|
||||
self.exists_calls.append(snapshot_id)
|
||||
return snapshot_id in self._stored
|
||||
|
||||
|
||||
class _UploadDownloadOnlyRemoteSnapshotClient:
|
||||
def __init__(self) -> None:
|
||||
self.uploads: list[tuple[str, bytes]] = []
|
||||
|
||||
async def upload(self, snapshot_id: str, data: io.IOBase) -> None:
|
||||
payload = data.read()
|
||||
assert isinstance(payload, bytes)
|
||||
self.uploads.append((snapshot_id, payload))
|
||||
|
||||
async def download(self, snapshot_id: str) -> io.IOBase:
|
||||
return io.BytesIO(b"downloaded")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_snapshot_persist_restore_and_restorable_use_injected_dependency() -> None:
|
||||
client = _FakeRemoteSnapshotClient()
|
||||
dependencies = Dependencies().bind_value("tests.remote_snapshot_client", client)
|
||||
snapshot = RemoteSnapshot(id="snap-123", client_dependency_key="tests.remote_snapshot_client")
|
||||
|
||||
assert await snapshot.restorable(dependencies=dependencies) is False
|
||||
|
||||
await snapshot.persist(io.BytesIO(b"workspace-tar"), dependencies=dependencies)
|
||||
|
||||
assert client.uploads == [("snap-123", b"workspace-tar")]
|
||||
assert await snapshot.restorable(dependencies=dependencies) is True
|
||||
assert client.exists_calls == ["snap-123", "snap-123"]
|
||||
|
||||
restored = await snapshot.restore(dependencies=dependencies)
|
||||
|
||||
assert client.downloads == ["snap-123"]
|
||||
assert restored.read() == b"workspace-tar"
|
||||
|
||||
|
||||
def test_remote_snapshot_spec_builds_remote_snapshot() -> None:
|
||||
snapshot = resolve_snapshot(
|
||||
RemoteSnapshotSpec(client_dependency_key="tests.remote_snapshot_client"),
|
||||
"snap-123",
|
||||
)
|
||||
|
||||
assert isinstance(snapshot, RemoteSnapshot)
|
||||
assert snapshot.id == "snap-123"
|
||||
assert snapshot.client_dependency_key == "tests.remote_snapshot_client"
|
||||
|
||||
|
||||
def test_remote_snapshot_serializes_through_session_state_without_dependencies() -> None:
|
||||
state = TestSessionState(
|
||||
manifest=Manifest(root="/workspace"),
|
||||
snapshot=RemoteSnapshot(
|
||||
id="snap-123", client_dependency_key="tests.remote_snapshot_client"
|
||||
),
|
||||
)
|
||||
|
||||
payload = state.model_dump(mode="json")
|
||||
|
||||
assert payload["snapshot"] == {
|
||||
"type": "remote",
|
||||
"id": "snap-123",
|
||||
"client_dependency_key": "tests.remote_snapshot_client",
|
||||
}
|
||||
|
||||
restored = SandboxSessionState.model_validate(payload)
|
||||
|
||||
assert isinstance(restored.snapshot, RemoteSnapshot)
|
||||
assert restored.snapshot.id == "snap-123"
|
||||
assert restored.snapshot.client_dependency_key == "tests.remote_snapshot_client"
|
||||
assert not hasattr(restored.snapshot, "persisted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_snapshot_without_exists_requires_check_method() -> None:
|
||||
client = _UploadDownloadOnlyRemoteSnapshotClient()
|
||||
dependencies = Dependencies().bind_value("tests.remote_snapshot_client", client)
|
||||
snapshot = RemoteSnapshot(id="snap-123", client_dependency_key="tests.remote_snapshot_client")
|
||||
expected_error = "Remote snapshot client must implement `exists(snapshot_id, ...)`"
|
||||
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
await snapshot.restorable(dependencies=dependencies)
|
||||
|
||||
assert str(exc_info.value) == expected_error
|
||||
|
||||
await snapshot.persist(io.BytesIO(b"workspace-tar"), dependencies=dependencies)
|
||||
|
||||
assert client.uploads == [("snap-123", b"workspace-tar")]
|
||||
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
await snapshot.restorable(dependencies=dependencies)
|
||||
|
||||
assert str(exc_info.value) == expected_error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_set_dependencies_passes_remote_snapshot_client() -> None:
|
||||
client = _FakeRemoteSnapshotClient()
|
||||
session = _PersistTrackingSession(
|
||||
RemoteSnapshot(id="snap-123", client_dependency_key="tests.remote_snapshot_client"),
|
||||
workspace_root=Path("/tmp/test-session-deps"),
|
||||
)
|
||||
|
||||
session.set_dependencies(Dependencies().bind_value("tests.remote_snapshot_client", client))
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert client.uploads == [("snap-123", b"tracked")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_session_set_dependencies_delegates_to_inner_session() -> None:
|
||||
client = _FakeRemoteSnapshotClient()
|
||||
inner = _PersistTrackingSession(
|
||||
RemoteSnapshot(id="snap-123", client_dependency_key="tests.remote_snapshot_client"),
|
||||
workspace_root=Path("/tmp/test-session-wrapper-deps"),
|
||||
)
|
||||
session = SandboxSession(inner)
|
||||
|
||||
session.set_dependencies(Dependencies().bind_value("tests.remote_snapshot_client", client))
|
||||
|
||||
await session.stop()
|
||||
|
||||
assert client.uploads == [("snap-123", b"tracked")]
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agents.sandbox.snapshot import LocalSnapshotSpec
|
||||
from agents.sandbox.snapshot_defaults import (
|
||||
_DEFAULT_LOCAL_SNAPSHOT_TTL_SECONDS,
|
||||
cleanup_stale_default_local_snapshots,
|
||||
default_local_snapshot_base_dir,
|
||||
resolve_default_local_snapshot_spec,
|
||||
)
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_uses_xdg_state_home(tmp_path: Path) -> None:
|
||||
state_home = tmp_path / "state"
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=tmp_path / "home",
|
||||
env={"XDG_STATE_HOME": str(state_home)},
|
||||
platform="linux",
|
||||
os_name="posix",
|
||||
)
|
||||
|
||||
assert result == state_home / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_uses_macos_application_support(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=home,
|
||||
env={},
|
||||
platform="darwin",
|
||||
os_name="posix",
|
||||
)
|
||||
|
||||
assert (
|
||||
result
|
||||
== home
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "openai-agents-python"
|
||||
/ "sandbox"
|
||||
/ "snapshots"
|
||||
)
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_uses_localappdata_on_windows(tmp_path: Path) -> None:
|
||||
local_app_data = Path(r"C:\Users\me\AppData\Local")
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=tmp_path / "home",
|
||||
env={"LOCALAPPDATA": str(local_app_data)},
|
||||
platform="win32",
|
||||
os_name="nt",
|
||||
)
|
||||
|
||||
assert result == local_app_data / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_uses_absolute_appdata_when_localappdata_is_relative(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
app_data = Path(r"C:\Users\me\AppData\Roaming")
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=tmp_path / "home",
|
||||
env={"LOCALAPPDATA": "relative-local", "APPDATA": str(app_data)},
|
||||
platform="win32",
|
||||
os_name="nt",
|
||||
)
|
||||
|
||||
assert result == app_data / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_ignores_relative_windows_env_paths(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=home,
|
||||
env={"LOCALAPPDATA": "relative-local", "APPDATA": "relative-roaming"},
|
||||
platform="win32",
|
||||
os_name="nt",
|
||||
)
|
||||
|
||||
assert result == home / "AppData" / "Local" / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
|
||||
|
||||
def test_default_local_snapshot_base_dir_ignores_posix_absolute_localappdata_on_windows(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
result = default_local_snapshot_base_dir(
|
||||
home=home,
|
||||
env={"LOCALAPPDATA": "/tmp/localappdata"},
|
||||
platform="win32",
|
||||
os_name="nt",
|
||||
)
|
||||
|
||||
assert result == home / "AppData" / "Local" / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
|
||||
|
||||
def test_cleanup_stale_default_local_snapshots_removes_only_old_tar_files(tmp_path: Path) -> None:
|
||||
managed_dir = tmp_path / "snapshots"
|
||||
managed_dir.mkdir()
|
||||
stale = managed_dir / "stale.tar"
|
||||
fresh = managed_dir / "fresh.tar"
|
||||
keep = managed_dir / "keep.txt"
|
||||
stale.write_bytes(b"stale")
|
||||
fresh.write_bytes(b"fresh")
|
||||
keep.write_text("keep")
|
||||
|
||||
now = 2_000_000_000.0
|
||||
stale_mtime = now - (_DEFAULT_LOCAL_SNAPSHOT_TTL_SECONDS + 60)
|
||||
fresh_mtime = now - 60
|
||||
os.utime(stale, (stale_mtime, stale_mtime))
|
||||
os.utime(fresh, (fresh_mtime, fresh_mtime))
|
||||
|
||||
cleanup_stale_default_local_snapshots(managed_dir, now=now)
|
||||
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert keep.exists()
|
||||
|
||||
|
||||
def test_resolve_default_local_snapshot_spec_keeps_existing_stale_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state_home = tmp_path / "state"
|
||||
managed_dir = state_home / "openai-agents-python" / "sandbox" / "snapshots"
|
||||
managed_dir.mkdir(parents=True)
|
||||
stale = managed_dir / "stale.tar"
|
||||
stale.write_bytes(b"stale")
|
||||
now = 2_000_000_000.0
|
||||
stale_mtime = now - (_DEFAULT_LOCAL_SNAPSHOT_TTL_SECONDS + 60)
|
||||
os.utime(stale, (stale_mtime, stale_mtime))
|
||||
|
||||
spec = resolve_default_local_snapshot_spec(
|
||||
home=tmp_path / "home",
|
||||
env={"XDG_STATE_HOME": str(state_home)},
|
||||
platform="linux",
|
||||
os_name="posix",
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert isinstance(spec, LocalSnapshotSpec)
|
||||
assert spec.base_path == managed_dir
|
||||
assert managed_dir.exists()
|
||||
assert stale.exists()
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.util.tar_utils import (
|
||||
UnsafeTarMemberError,
|
||||
safe_extract_tarfile,
|
||||
safe_tar_member_rel_path,
|
||||
strip_tar_member_prefix,
|
||||
validate_tar_bytes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Member:
|
||||
info: tarfile.TarInfo
|
||||
payload: bytes | None = None
|
||||
|
||||
|
||||
def _tar_bytes(*members: _Member) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
for member in members:
|
||||
if member.payload is None:
|
||||
tar.addfile(member.info)
|
||||
else:
|
||||
tar.addfile(member.info, io.BytesIO(member.payload))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _dir(name: str) -> _Member:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.type = tarfile.DIRTYPE
|
||||
return _Member(member)
|
||||
|
||||
|
||||
def _file(name: str, payload: bytes = b"payload") -> _Member:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(payload)
|
||||
return _Member(member, payload)
|
||||
|
||||
|
||||
def _symlink(name: str, target: str) -> _Member:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = target
|
||||
return _Member(member)
|
||||
|
||||
|
||||
def _hardlink(name: str, target: str) -> _Member:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.type = tarfile.LNKTYPE
|
||||
member.linkname = target
|
||||
return _Member(member)
|
||||
|
||||
|
||||
def _fifo(name: str) -> _Member:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.type = tarfile.FIFOTYPE
|
||||
return _Member(member)
|
||||
|
||||
|
||||
def _safe_extract(raw: bytes, root: Path) -> None:
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
|
||||
safe_extract_tarfile(tar, root=root)
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_preserves_venv_style_symlinks(tmp_path: Path) -> None:
|
||||
raw = _tar_bytes(
|
||||
_dir("."),
|
||||
_dir("./uv-project"),
|
||||
_dir("./uv-project/.venv"),
|
||||
_dir("./uv-project/.venv/bin"),
|
||||
_dir("./uv-project/.venv/lib"),
|
||||
_file("./uv-project/main.py", b'print("snapshot smoke")\n'),
|
||||
_symlink("./uv-project/.venv/lib64", "lib"),
|
||||
_symlink("./uv-project/.venv/bin/python3", "/usr/local/bin/python3"),
|
||||
_symlink("./uv-project/.venv/bin/python", "python3"),
|
||||
)
|
||||
|
||||
validate_tar_bytes(raw)
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
assert (tmp_path / "uv-project" / "main.py").read_text() == 'print("snapshot smoke")\n'
|
||||
assert os.readlink(tmp_path / "uv-project" / ".venv" / "lib64") == "lib"
|
||||
assert (
|
||||
os.readlink(tmp_path / "uv-project" / ".venv" / "bin" / "python3")
|
||||
== "/usr/local/bin/python3"
|
||||
)
|
||||
assert os.readlink(tmp_path / "uv-project" / ".venv" / "bin" / "python") == "python3"
|
||||
|
||||
|
||||
def test_safe_tar_member_rel_path_requires_symlink_opt_in() -> None:
|
||||
symlink = _symlink("link.txt", "target.txt").info
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="symlink member not allowed"):
|
||||
safe_tar_member_rel_path(symlink)
|
||||
|
||||
assert safe_tar_member_rel_path(symlink, allow_symlinks=True) == Path("link.txt")
|
||||
|
||||
|
||||
def test_validate_tar_bytes_rejects_root_symlink() -> None:
|
||||
raw = _tar_bytes(_symlink(".", "/tmp/outside"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="archive root symlink"):
|
||||
validate_tar_bytes(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member_name", ["C:/tmp/evil.txt", r"C:\tmp\evil.txt"])
|
||||
def test_validate_tar_bytes_rejects_windows_drive_member_paths(member_name: str) -> None:
|
||||
raw = _tar_bytes(_file(member_name, b"evil"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="windows drive path"):
|
||||
validate_tar_bytes(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member_name", [r"..\evil.txt", r"\evil.txt", r"nested\evil.txt"])
|
||||
def test_validate_tar_bytes_rejects_windows_separator_member_paths(member_name: str) -> None:
|
||||
raw = _tar_bytes(_file(member_name, b"evil"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="windows path separator"):
|
||||
validate_tar_bytes(raw)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_rejects_member_under_non_directory_member() -> None:
|
||||
raw = _tar_bytes(
|
||||
_file("nested/hello.txt", b"hello"),
|
||||
_file("nested", b"not a directory"),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
UnsafeTarMemberError,
|
||||
match="archive path descends through non-directory: nested",
|
||||
):
|
||||
validate_tar_bytes(raw)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_rejects_absolute_symlink_target_in_strict_mode() -> None:
|
||||
raw = _tar_bytes(_symlink("leak", "/etc/passwd"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="absolute symlink target not allowed"):
|
||||
validate_tar_bytes(raw, allow_external_symlink_targets=False)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_rejects_parent_escape_symlink_target_in_strict_mode() -> None:
|
||||
raw = _tar_bytes(_dir("nested"), _symlink("nested/leak", "../../etc/passwd"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="symlink target escapes archive root"):
|
||||
validate_tar_bytes(raw, allow_external_symlink_targets=False)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_allows_internal_symlink_target_in_strict_mode() -> None:
|
||||
raw = _tar_bytes(_dir("nested"), _symlink("nested/python", "../bin/python3"))
|
||||
|
||||
validate_tar_bytes(raw, allow_external_symlink_targets=False)
|
||||
|
||||
|
||||
def test_strip_tar_member_prefix_returns_workspace_relative_archive() -> None:
|
||||
raw = _tar_bytes(
|
||||
_dir("workspace"),
|
||||
_dir("workspace/pkg"),
|
||||
_file("workspace/pkg/main.py", b"print('hello')\n"),
|
||||
_symlink("workspace/pkg/python", "python3"),
|
||||
)
|
||||
|
||||
normalized = strip_tar_member_prefix(io.BytesIO(raw), prefix="workspace")
|
||||
|
||||
with tarfile.open(fileobj=normalized, mode="r:*") as tar:
|
||||
assert tar.getnames() == [".", "pkg", "pkg/main.py", "pkg/python"]
|
||||
|
||||
|
||||
def test_strip_tar_member_prefix_rewrites_pax_path_headers() -> None:
|
||||
long_name = "workspace/" + ("a" * 120) + ".txt"
|
||||
payload = b"payload"
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
|
||||
member = tarfile.TarInfo(long_name)
|
||||
member.size = len(payload)
|
||||
tar.addfile(member, io.BytesIO(payload))
|
||||
raw.seek(0)
|
||||
|
||||
normalized = strip_tar_member_prefix(raw, prefix="workspace")
|
||||
|
||||
with tarfile.open(fileobj=normalized, mode="r:*") as tar:
|
||||
[member] = tar.getmembers()
|
||||
assert member.name == ("a" * 120) + ".txt"
|
||||
assert member.pax_headers["path"] == ("a" * 120) + ".txt"
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_can_rehydrate_existing_leaf_symlink(tmp_path: Path) -> None:
|
||||
raw = _tar_bytes(_symlink("link.txt", "/usr/local/bin/python3"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert os.readlink(tmp_path / "link.txt") == "/usr/local/bin/python3"
|
||||
|
||||
raw = _tar_bytes(_symlink("link.txt", "target-v2.txt"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert os.readlink(tmp_path / "link.txt") == "target-v2.txt"
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_rejects_external_symlink_target_in_strict_mode(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
raw = _tar_bytes(_symlink("link.txt", "/etc/passwd"))
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
|
||||
with pytest.raises(UnsafeTarMemberError, match="absolute symlink target not allowed"):
|
||||
safe_extract_tarfile(
|
||||
tar,
|
||||
root=tmp_path,
|
||||
allow_external_symlink_targets=False,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_can_replace_existing_leaf_file_with_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
raw = _tar_bytes(_file("link.txt", b"not a link"))
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
raw = _tar_bytes(_symlink("link.txt", "target.txt"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert os.readlink(tmp_path / "link.txt") == "target.txt"
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_can_replace_existing_leaf_symlink_with_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
raw = _tar_bytes(_symlink("python", "/usr/local/bin/python3"))
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
raw = _tar_bytes(_file("python", b"real file"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert (tmp_path / "python").read_bytes() == b"real file"
|
||||
assert not (tmp_path / "python").is_symlink()
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_can_replace_existing_leaf_symlink_with_directory(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
raw = _tar_bytes(_symlink("bin", "/usr/local/bin"))
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
raw = _tar_bytes(_dir("bin"), _file("bin/python", b"real file"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert (tmp_path / "bin").is_dir()
|
||||
assert not (tmp_path / "bin").is_symlink()
|
||||
assert (tmp_path / "bin" / "python").read_bytes() == b"real file"
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_can_replace_existing_leaf_file_with_directory(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
raw = _tar_bytes(_file("bin", b"not a directory"))
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
raw = _tar_bytes(_dir("bin"), _file("bin/python", b"real file"))
|
||||
|
||||
_safe_extract(raw, tmp_path)
|
||||
assert (tmp_path / "bin").is_dir()
|
||||
assert (tmp_path / "bin" / "python").read_bytes() == b"real file"
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_rejects_existing_leaf_directory_for_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "link.txt").mkdir()
|
||||
raw = _tar_bytes(_symlink("link.txt", "target.txt"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="destination directory already exists"):
|
||||
_safe_extract(raw, tmp_path)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_rejects_members_under_archive_symlink() -> None:
|
||||
raw = _tar_bytes(
|
||||
_symlink("escape", "/tmp/outside"),
|
||||
_file("escape/pwned.txt", b"pwned"),
|
||||
)
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="descends through symlink"):
|
||||
validate_tar_bytes(raw)
|
||||
|
||||
|
||||
def test_validate_tar_bytes_can_reject_specific_symlink_path() -> None:
|
||||
raw = _tar_bytes(_symlink("workspace", "/tmp/outside"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="symlink member not allowed: workspace"):
|
||||
validate_tar_bytes(raw, reject_symlink_rel_paths={Path("workspace")})
|
||||
|
||||
|
||||
def test_validate_tar_bytes_specific_symlink_rejection_normalizes_dot_prefix() -> None:
|
||||
raw = _tar_bytes(_symlink("./workspace", "/tmp/outside"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="symlink member not allowed: workspace"):
|
||||
validate_tar_bytes(raw, reject_symlink_rel_paths={"workspace"})
|
||||
|
||||
|
||||
def test_validate_tar_bytes_specific_symlink_rejection_does_not_reject_children() -> None:
|
||||
validate_tar_bytes(
|
||||
_tar_bytes(_dir("workspace"), _symlink("workspace/link", "/tmp/outside")),
|
||||
reject_symlink_rel_paths={"workspace"},
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_rejects_preexisting_symlink_parent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
os.symlink(outside, root / "escape", target_is_directory=True)
|
||||
raw = _tar_bytes(_file("escape/pwned.txt", b"pwned"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="path escapes root|symlink in parent path"):
|
||||
_safe_extract(raw, root)
|
||||
|
||||
assert not (outside / "pwned.txt").exists()
|
||||
|
||||
|
||||
def test_safe_extract_tarfile_rejects_symlink_under_preexisting_symlink_parent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
os.symlink(outside, root / "escape", target_is_directory=True)
|
||||
raw = _tar_bytes(_symlink("escape/nested/link.txt", "target.txt"))
|
||||
|
||||
with pytest.raises(UnsafeTarMemberError, match="path escapes root|symlink in parent path"):
|
||||
_safe_extract(raw, root)
|
||||
|
||||
assert not (outside / "nested").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member",
|
||||
[
|
||||
_hardlink("hardlink", "target.txt"),
|
||||
_fifo("pipe"),
|
||||
],
|
||||
)
|
||||
def test_validate_tar_bytes_rejects_unsupported_tar_member_types(
|
||||
member: _Member,
|
||||
) -> None:
|
||||
with pytest.raises(UnsafeTarMemberError):
|
||||
validate_tar_bytes(_tar_bytes(member))
|
||||
|
||||
|
||||
def test_validate_tar_bytes_ignores_skipped_unsafe_member() -> None:
|
||||
validate_tar_bytes(
|
||||
_tar_bytes(_symlink(".runtime/escape", "/tmp/outside")),
|
||||
skip_rel_paths=[Path(".runtime")],
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agents.sandbox.session.tar_workspace import shell_tar_exclude_args
|
||||
|
||||
|
||||
def test_shell_tar_exclude_args_skips_empty_and_dot_paths() -> None:
|
||||
assert shell_tar_exclude_args([Path(""), Path("."), Path("/")]) == []
|
||||
|
||||
|
||||
def test_shell_tar_exclude_args_sorts_and_adds_plain_and_dot_prefixed_patterns() -> None:
|
||||
assert shell_tar_exclude_args(
|
||||
[
|
||||
Path("logs/events.jsonl"),
|
||||
Path("cache dir/file.txt"),
|
||||
]
|
||||
) == [
|
||||
"--exclude='cache dir/file.txt'",
|
||||
"--exclude='./cache dir/file.txt'",
|
||||
"--exclude=logs/events.jsonl",
|
||||
"--exclude=./logs/events.jsonl",
|
||||
]
|
||||
|
||||
|
||||
def test_shell_tar_exclude_args_normalizes_absolute_paths() -> None:
|
||||
assert shell_tar_exclude_args([Path("/tmp/workspace/cache")]) == [
|
||||
"--exclude=tmp/workspace/cache",
|
||||
"--exclude=./tmp/workspace/cache",
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agents.sandbox.util.token_truncation import (
|
||||
TruncationPolicy,
|
||||
approx_bytes_for_tokens,
|
||||
approx_token_count,
|
||||
approx_tokens_from_byte_count,
|
||||
format_truncation_marker,
|
||||
formatted_truncate_text,
|
||||
formatted_truncate_text_with_token_count,
|
||||
removed_units_for_source,
|
||||
split_budget,
|
||||
split_string,
|
||||
truncate_text,
|
||||
truncate_with_byte_estimate,
|
||||
truncate_with_token_budget,
|
||||
)
|
||||
|
||||
|
||||
def test_truncation_policy_clamps_negative_limits_and_converts_budgets() -> None:
|
||||
byte_policy = TruncationPolicy.bytes(-10)
|
||||
token_policy = TruncationPolicy.tokens(-2)
|
||||
|
||||
assert byte_policy.limit == 0
|
||||
assert byte_policy.token_budget() == 0
|
||||
assert byte_policy.byte_budget() == 0
|
||||
assert token_policy.limit == 0
|
||||
assert token_policy.token_budget() == 0
|
||||
assert token_policy.byte_budget() == 0
|
||||
|
||||
|
||||
def test_formatted_truncate_text_returns_short_content_unchanged() -> None:
|
||||
assert formatted_truncate_text("short", TruncationPolicy.bytes(20)) == "short"
|
||||
|
||||
|
||||
def test_formatted_truncate_text_adds_line_count_when_truncated() -> None:
|
||||
result = formatted_truncate_text("alpha\nbeta\ngamma", TruncationPolicy.bytes(8))
|
||||
|
||||
assert result.startswith("Total output lines: 3\n\n")
|
||||
assert "chars truncated" in result
|
||||
|
||||
|
||||
def test_formatted_truncate_text_with_token_count_handles_none_and_short_content() -> None:
|
||||
assert formatted_truncate_text_with_token_count("short", None) == ("short", None)
|
||||
assert formatted_truncate_text_with_token_count("short", 10) == ("short", None)
|
||||
|
||||
|
||||
def test_formatted_truncate_text_with_token_count_reports_original_count() -> None:
|
||||
result, original_token_count = formatted_truncate_text_with_token_count("abcdefghi", 1)
|
||||
|
||||
assert result.startswith("Total output lines: 1\n\n")
|
||||
assert "tokens truncated" in result
|
||||
assert original_token_count == approx_token_count("abcdefghi")
|
||||
|
||||
|
||||
def test_truncate_text_dispatches_byte_and_token_modes() -> None:
|
||||
assert truncate_text("abcdef", TruncationPolicy.bytes(4)).startswith("a")
|
||||
assert "tokens truncated" in truncate_text("abcdefghi", TruncationPolicy.tokens(1))
|
||||
|
||||
|
||||
def test_truncate_with_token_budget_handles_empty_and_short_content() -> None:
|
||||
assert truncate_with_token_budget("", TruncationPolicy.tokens(1)) == ("", None)
|
||||
assert truncate_with_token_budget("abc", TruncationPolicy.tokens(1)) == ("abc", None)
|
||||
|
||||
|
||||
def test_truncate_with_byte_estimate_handles_empty_zero_and_short_content() -> None:
|
||||
assert truncate_with_byte_estimate("", TruncationPolicy.bytes(0)) == ""
|
||||
assert "chars truncated" in truncate_with_byte_estimate("abc", TruncationPolicy.bytes(0))
|
||||
assert truncate_with_byte_estimate("abc", TruncationPolicy.bytes(10)) == "abc"
|
||||
|
||||
|
||||
def test_split_string_preserves_utf8_boundaries() -> None:
|
||||
removed_chars, prefix, suffix = split_string("aあbいc", 2, 4)
|
||||
|
||||
assert prefix == "a"
|
||||
assert suffix == "いc"
|
||||
assert removed_chars == 2
|
||||
|
||||
|
||||
def test_split_string_handles_empty_content() -> None:
|
||||
assert split_string("", 10, 10) == (0, "", "")
|
||||
|
||||
|
||||
def test_formatting_and_estimate_helpers() -> None:
|
||||
byte_policy = TruncationPolicy.bytes(8)
|
||||
token_policy = TruncationPolicy.tokens(2)
|
||||
|
||||
assert "chars truncated" in format_truncation_marker(byte_policy, 3)
|
||||
assert "tokens truncated" in format_truncation_marker(token_policy, 2)
|
||||
assert split_budget(5) == (2, 3)
|
||||
assert removed_units_for_source(byte_policy, removed_bytes=10, removed_chars=4) == 4
|
||||
assert removed_units_for_source(token_policy, removed_bytes=9, removed_chars=4) == 3
|
||||
assert approx_token_count("abcde") == 2
|
||||
assert approx_bytes_for_tokens(-1) == 0
|
||||
assert approx_tokens_from_byte_count(0) == 0
|
||||
assert approx_tokens_from_byte_count(5) == 2
|
||||
@@ -0,0 +1,22 @@
|
||||
from agents.sandbox.types import Group, Permissions, User
|
||||
|
||||
|
||||
def test_permissions_is_hashable() -> None:
|
||||
# ``Permissions`` overrides ``__eq__``; without a matching ``__hash__`` Pydantic v2
|
||||
# would set ``__hash__ = None``, breaking sets and dict keys for what is otherwise
|
||||
# a value-like type. Sibling classes ``User`` and ``Group`` already define both.
|
||||
perms = Permissions.from_mode(0o755)
|
||||
other = Permissions.from_mode(0o755)
|
||||
different = Permissions.from_mode(0o644)
|
||||
|
||||
assert hash(perms) == hash(other)
|
||||
assert hash(perms) != hash(different)
|
||||
assert {perms, other, different} == {perms, different}
|
||||
assert {perms: "value"}[other] == "value"
|
||||
|
||||
|
||||
def test_user_and_group_remain_hashable() -> None:
|
||||
# Regression guard for the sibling classes whose hashability the Permissions fix
|
||||
# mirrors.
|
||||
assert hash(User(name="alice")) == hash(User(name="alice"))
|
||||
assert hash(Group(name="admin", users=[])) == hash(Group(name="admin", users=[]))
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.errors import PtySessionNotFoundError
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxClient,
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
_UnixPtyProcessEntry,
|
||||
)
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
|
||||
|
||||
class _RecordingUnixLocalSession(UnixLocalSandboxSession):
|
||||
def __init__(self, root: Path) -> None:
|
||||
super().__init__(
|
||||
state=UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(root)),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
)
|
||||
self.exec_commands: list[tuple[str, ...]] = []
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = timeout
|
||||
self.exec_commands.append(tuple(str(part) for part in command))
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
|
||||
class TestUnixLocalPty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tty_fd_close_is_owned_without_blocking_termination(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _RecordingUnixLocalSession(tmp_path)
|
||||
close_started = asyncio.Event()
|
||||
release_close = asyncio.Event()
|
||||
|
||||
async def blocked_to_thread(*args: object, **kwargs: object) -> None:
|
||||
_ = (args, kwargs)
|
||||
close_started.set()
|
||||
await release_close.wait()
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", blocked_to_thread)
|
||||
process = cast(
|
||||
asyncio.subprocess.Process,
|
||||
SimpleNamespace(returncode=0, pid=None),
|
||||
)
|
||||
entry = _UnixPtyProcessEntry(process=process, tty=True, primary_fd=123)
|
||||
|
||||
await asyncio.wait_for(session._terminate_pty_entry(entry), timeout=0.5)
|
||||
await close_started.wait()
|
||||
|
||||
assert len(session._fd_close_tasks) == 1
|
||||
await asyncio.wait_for(session._after_stop(), timeout=0.5)
|
||||
assert len(session._fd_close_tasks) == 1
|
||||
|
||||
release_close.set()
|
||||
await asyncio.gather(*session._fd_close_tasks)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert session._fd_close_tasks == set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_exec_write_poll_and_unknown_session_errors(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"IFS= read -r line; printf '%s\\n' \"$line\"",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
|
||||
written = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="hello from pty\n",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
assert written.process_id is None
|
||||
assert written.exit_code == 0
|
||||
assert "hello from pty" in written.output.decode("utf-8", errors="replace")
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=999_999, chars="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_ctrl_c_interrupts_long_running_process(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sleep",
|
||||
"30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
|
||||
first_interrupt = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="\x03",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
if first_interrupt.process_id is None:
|
||||
interrupted = first_interrupt
|
||||
else:
|
||||
interrupted = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="",
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
|
||||
assert interrupted.process_id is None
|
||||
assert interrupted.exit_code is not None
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signum", "chars"),
|
||||
[
|
||||
pytest.param(signal.SIGINT, "\x03", id="sigint"),
|
||||
pytest.param(signal.SIGQUIT, "\x1c", id="sigquit"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_terminal_signals_interrupt_even_if_parent_ignores_signal(
|
||||
self, tmp_path: Path, signum: signal.Signals, chars: str
|
||||
) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
previous_handler = signal.getsignal(signum)
|
||||
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
try:
|
||||
async with await client.create(
|
||||
manifest=manifest, snapshot=None, options=None
|
||||
) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sleep",
|
||||
"30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
assert started.process_id is not None
|
||||
|
||||
interrupted = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars=chars,
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
|
||||
assert interrupted.process_id is None
|
||||
assert interrupted.exit_code == -signum
|
||||
finally:
|
||||
signal.signal(signum, previous_handler)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_tty_pty_session_rejects_stdin_and_can_still_be_polled(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'stdout\\n'; printf 'stderr\\n' >&2; sleep 1",
|
||||
shell=False,
|
||||
tty=False,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
started_text = started.output.decode("utf-8", errors="replace")
|
||||
assert "stdout" in started_text
|
||||
assert "stderr" in started_text
|
||||
|
||||
with pytest.raises(RuntimeError, match="stdin is not available for this process"):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="hello")
|
||||
|
||||
finished = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="",
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
text = finished.output.decode("utf-8", errors="replace")
|
||||
assert finished.process_id is None
|
||||
assert finished.exit_code == 0
|
||||
assert text == ""
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_terminates_active_pty_sessions(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
session = await client.create(manifest=manifest, snapshot=None, options=None)
|
||||
await session.start()
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'ready\\n'; sleep 30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert "ready" in started.output.decode("utf-8", errors="replace")
|
||||
|
||||
await session.stop()
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
|
||||
class TestUnixLocalUserScopedFilesystem:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_as_user_checks_permissions_then_uses_local_fs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
session = _RecordingUnixLocalSession(workspace)
|
||||
|
||||
await session.mkdir("nested", user=User(name="sandbox-user"))
|
||||
|
||||
assert (workspace / "nested").is_dir()
|
||||
assert len(session.exec_commands) == 1
|
||||
assert session.exec_commands[0][:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.exec_commands[0][4:6] == ("sh", "-lc")
|
||||
assert session.exec_commands[0][-2:] == (str(workspace / "nested"), "0")
|
||||
assert not any(part.startswith("mkdir ") for part in session.exec_commands[0])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_as_user_checks_permissions_then_uses_local_fs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
target = workspace / "stale.txt"
|
||||
target.write_text("stale", encoding="utf-8")
|
||||
session = _RecordingUnixLocalSession(workspace)
|
||||
|
||||
await session.rm("stale.txt", user=User(name="sandbox-user"))
|
||||
|
||||
assert not target.exists()
|
||||
assert len(session.exec_commands) == 1
|
||||
assert session.exec_commands[0][:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.exec_commands[0][4:6] == ("sh", "-lc")
|
||||
assert session.exec_commands[0][-2:] == (str(target), "0")
|
||||
assert not any(part.startswith("rm ") for part in session.exec_commands[0])
|
||||
@@ -0,0 +1,594 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from agents.sandbox import Manifest, SandboxPathGrant
|
||||
from agents.sandbox.errors import InvalidManifestPathError, WorkspaceArchiveWriteError
|
||||
from agents.sandbox.workspace_paths import (
|
||||
WorkspacePathPolicy,
|
||||
coerce_posix_path,
|
||||
posix_path_as_path,
|
||||
)
|
||||
|
||||
PathInput = str | PurePath
|
||||
PathPolicyMethod = Callable[[WorkspacePathPolicy, PathInput], Path]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePathCase:
|
||||
name: str
|
||||
path: PathInput
|
||||
expected: Path | None = None
|
||||
error_message: str | None = None
|
||||
error_context: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _policy(root: Path | str = "/workspace") -> WorkspacePathPolicy:
|
||||
return WorkspacePathPolicy(root=root)
|
||||
|
||||
|
||||
def test_workspace_path_policy_rejects_relative_root() -> None:
|
||||
with pytest.raises(ValueError, match="sandbox workspace root must be absolute"):
|
||||
WorkspacePathPolicy(root="workspace")
|
||||
|
||||
|
||||
def _assert_workspace_path_case(
|
||||
*,
|
||||
method: PathPolicyMethod,
|
||||
test_case: WorkspacePathCase,
|
||||
root: Path | str = "/workspace",
|
||||
) -> None:
|
||||
if test_case.error_message is None:
|
||||
assert method(_policy(root), test_case.path) == test_case.expected
|
||||
return
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
method(_policy(root), test_case.path)
|
||||
|
||||
assert str(exc_info.value) == test_case.error_message
|
||||
assert exc_info.value.context == test_case.error_context
|
||||
|
||||
|
||||
ABSOLUTE_WORKSPACE_PATH_CASES = [
|
||||
WorkspacePathCase(
|
||||
name="relative path anchors under root",
|
||||
path="pkg/file.py",
|
||||
expected=Path("/workspace/pkg/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="Path input anchors under root",
|
||||
path=Path("pkg/file.py"),
|
||||
expected=Path("/workspace/pkg/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path inside root is accepted",
|
||||
path="/workspace/pkg/file.py",
|
||||
expected=Path("/workspace/pkg/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path inside root is normalized",
|
||||
path="/workspace/pkg/../file.py",
|
||||
expected=Path("/workspace/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative parent segment inside root is normalized",
|
||||
path="pkg/../secret.txt",
|
||||
expected=Path("/workspace/secret.txt"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path outside root is rejected",
|
||||
path="/tmp/secret.txt",
|
||||
error_message="manifest path must be relative: /tmp/secret.txt",
|
||||
error_context={"rel": "/tmp/secret.txt", "reason": "absolute"},
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative parent traversal is rejected",
|
||||
path="../secret.txt",
|
||||
error_message="manifest path must not escape root: ../secret.txt",
|
||||
error_context={"rel": "../secret.txt", "reason": "escape_root"},
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="nested relative parent traversal outside root is rejected",
|
||||
path="pkg/../../secret.txt",
|
||||
error_message="manifest path must not escape root: pkg/../../secret.txt",
|
||||
error_context={"rel": "pkg/../../secret.txt", "reason": "escape_root"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_case",
|
||||
ABSOLUTE_WORKSPACE_PATH_CASES,
|
||||
ids=lambda test_case: test_case.name,
|
||||
)
|
||||
def test_absolute_workspace_path(test_case: WorkspacePathCase) -> None:
|
||||
_assert_workspace_path_case(
|
||||
method=lambda policy, path: policy.absolute_workspace_path(path),
|
||||
test_case=test_case,
|
||||
)
|
||||
|
||||
|
||||
RELATIVE_PATH_CASES = [
|
||||
WorkspacePathCase(
|
||||
name="relative path stays relative",
|
||||
path="pkg/file.py",
|
||||
expected=Path("pkg/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path inside root becomes relative",
|
||||
path="/workspace/pkg/file.py",
|
||||
expected=Path("pkg/file.py"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative parent segment inside root is normalized",
|
||||
path="pkg/../secret.txt",
|
||||
expected=Path("secret.txt"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="workspace root becomes dot",
|
||||
path="/workspace",
|
||||
expected=Path("."),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="provider root is not exposed",
|
||||
path="/provider/private/root/images/dot.png",
|
||||
expected=Path("images/dot.png"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative provider path stays relative",
|
||||
path="images/dot.png",
|
||||
expected=Path("images/dot.png"),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path outside root is rejected",
|
||||
path="/tmp/secret.txt",
|
||||
error_message="manifest path must be relative: /tmp/secret.txt",
|
||||
error_context={"rel": "/tmp/secret.txt", "reason": "absolute"},
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative parent traversal is rejected",
|
||||
path="../secret.txt",
|
||||
error_message="manifest path must not escape root: ../secret.txt",
|
||||
error_context={"rel": "../secret.txt", "reason": "escape_root"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_case",
|
||||
RELATIVE_PATH_CASES,
|
||||
ids=lambda test_case: test_case.name,
|
||||
)
|
||||
def test_relative_path(test_case: WorkspacePathCase) -> None:
|
||||
root = "/provider/private/root" if "provider" in test_case.name else "/workspace"
|
||||
_assert_workspace_path_case(
|
||||
method=lambda policy, path: policy.relative_path(path),
|
||||
test_case=test_case,
|
||||
root=root,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_path_with_symlink_resolution(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
outside = tmp_path / "outside"
|
||||
workspace.mkdir()
|
||||
outside.mkdir()
|
||||
|
||||
target = workspace / "target.txt"
|
||||
target.write_text("hello", encoding="utf-8")
|
||||
os.symlink(target, workspace / "link.txt")
|
||||
os.symlink(outside, workspace / "outside-link", target_is_directory=True)
|
||||
|
||||
alias = tmp_path / "workspace-alias"
|
||||
os.symlink(workspace, alias, target_is_directory=True)
|
||||
|
||||
test_cases = [
|
||||
WorkspacePathCase(
|
||||
name="relative path resolves under host root",
|
||||
path="target.txt",
|
||||
expected=target.resolve(),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="relative parent segment inside root resolves under host root",
|
||||
path="nested/../target.txt",
|
||||
expected=target.resolve(),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="safe internal leaf symlink resolves to target",
|
||||
path="link.txt",
|
||||
expected=target.resolve(),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path through root alias is accepted",
|
||||
path=alias / "target.txt",
|
||||
expected=target.resolve(),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute resolved root path is accepted",
|
||||
path=target,
|
||||
expected=target.resolve(),
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="symlink parent escape is rejected",
|
||||
path="outside-link/secret.txt",
|
||||
error_message="manifest path must not escape root: outside-link/secret.txt",
|
||||
error_context={"rel": "outside-link/secret.txt", "reason": "escape_root"},
|
||||
),
|
||||
WorkspacePathCase(
|
||||
name="absolute path outside root is rejected",
|
||||
path=outside / "secret.txt",
|
||||
error_message=f"manifest path must be relative: {(outside / 'secret.txt').as_posix()}",
|
||||
error_context={"rel": (outside / "secret.txt").as_posix(), "reason": "absolute"},
|
||||
),
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
_assert_workspace_path_case(
|
||||
method=lambda policy, path: policy.normalize_path(path, resolve_symlinks=True),
|
||||
test_case=test_case,
|
||||
root=alias,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_sandbox_path_uses_posix_paths_for_windows_inputs() -> None:
|
||||
policy = WorkspacePathPolicy(root="/workspace")
|
||||
|
||||
assert policy.sandbox_root() == PurePosixPath("/workspace")
|
||||
assert policy.normalize_sandbox_path(PureWindowsPath("/workspace/pkg/file.py")) == (
|
||||
PurePosixPath("/workspace/pkg/file.py")
|
||||
)
|
||||
assert policy.normalize_sandbox_path(PureWindowsPath("pkg/file.py")) == (
|
||||
PurePosixPath("/workspace/pkg/file.py")
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_path_uses_posix_paths_for_windows_inputs() -> None:
|
||||
policy = WorkspacePathPolicy(root="/workspace")
|
||||
|
||||
assert policy.normalize_path(PureWindowsPath("/workspace/pkg/file.py")).as_posix() == (
|
||||
"/workspace/pkg/file.py"
|
||||
)
|
||||
assert policy.absolute_workspace_path(PureWindowsPath("pkg/file.py")).as_posix() == (
|
||||
"/workspace/pkg/file.py"
|
||||
)
|
||||
|
||||
|
||||
def test_inaccessible_root_is_treated_as_remote_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = PurePosixPath("/root/project")
|
||||
|
||||
def raise_for_root(path: Path) -> bool:
|
||||
if path.as_posix() == root.as_posix():
|
||||
raise PermissionError("permission denied")
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(Path, "exists", raise_for_root)
|
||||
|
||||
policy = WorkspacePathPolicy(root=root)
|
||||
|
||||
assert policy.root_is_existing_host_path() is False
|
||||
assert policy.normalize_path("pkg/file.py").as_posix() == "/root/project/pkg/file.py"
|
||||
|
||||
|
||||
def test_absolute_workspace_path_rejects_windows_rooted_escape_as_absolute() -> None:
|
||||
policy = WorkspacePathPolicy(root="/workspace")
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.absolute_workspace_path(PureWindowsPath("/tmp/secret.txt"))
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: /tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_windows_drive_absolute_path_is_rejected_before_posix_coercion() -> None:
|
||||
policy = WorkspacePathPolicy(root="/workspace")
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.normalize_path(PureWindowsPath("C:/tmp/secret.txt"))
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.absolute_workspace_path("C:\\tmp\\secret.txt")
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.normalize_path(coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")))
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_existing_host_root_rejects_windows_drive_absolute_paths(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
policy = WorkspacePathPolicy(root=workspace)
|
||||
methods: tuple[PathPolicyMethod, ...] = (
|
||||
lambda policy, path: policy.absolute_workspace_path(path),
|
||||
lambda policy, path: policy.normalize_path(path),
|
||||
lambda policy, path: policy.normalize_path(path, resolve_symlinks=True),
|
||||
)
|
||||
|
||||
for method in methods:
|
||||
for path in (
|
||||
PureWindowsPath("C:/tmp/secret.txt"),
|
||||
"C:\\tmp\\secret.txt",
|
||||
coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")),
|
||||
):
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
method(policy, path)
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_relative_path_rejects_windows_drive_absolute_path_for_host_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
policy = WorkspacePathPolicy(root=workspace)
|
||||
|
||||
for path in (
|
||||
PureWindowsPath("C:/tmp/secret.txt"),
|
||||
"C:\\tmp\\secret.txt",
|
||||
coerce_posix_path(PureWindowsPath("C:/tmp/secret.txt")),
|
||||
):
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.relative_path(path)
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
|
||||
assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_posix_path_as_path_returns_native_path() -> None:
|
||||
path = posix_path_as_path(PurePosixPath("/workspace/file.txt"))
|
||||
|
||||
assert isinstance(path, Path)
|
||||
assert path.as_posix() == "/workspace/file.txt"
|
||||
|
||||
|
||||
def test_sandbox_extra_path_grant_rules_use_posix_paths() -> None:
|
||||
policy = WorkspacePathPolicy(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
||||
)
|
||||
|
||||
assert policy.extra_path_grant_rules() == ((PurePosixPath("/tmp"), False),)
|
||||
assert policy.normalize_sandbox_path(PureWindowsPath("/tmp/result.txt")) == (
|
||||
PurePosixPath("/tmp/result.txt")
|
||||
)
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_non_native_windows_drive_absolute_path() -> None:
|
||||
if Path(PureWindowsPath("C:/tmp")).is_absolute():
|
||||
pytest.skip("Windows drive paths are native absolute paths on this host")
|
||||
|
||||
for path in (
|
||||
PureWindowsPath("C:/tmp"),
|
||||
"C:\\tmp",
|
||||
coerce_posix_path(PureWindowsPath("C:/tmp")),
|
||||
):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SandboxPathGrant(path=cast(Any, path))
|
||||
|
||||
errors = exc_info.value.errors(include_url=False)
|
||||
assert len(errors) == 1
|
||||
error = dict(errors[0])
|
||||
ctx = cast(dict[str, Any], error["ctx"])
|
||||
error["ctx"] = {"error": str(ctx["error"])}
|
||||
assert error == {
|
||||
"type": "value_error",
|
||||
"loc": ("path",),
|
||||
"msg": "Value error, sandbox path grant path must be POSIX absolute",
|
||||
"input": path,
|
||||
"ctx": {"error": "sandbox path grant path must be POSIX absolute"},
|
||||
}
|
||||
|
||||
|
||||
def test_extra_path_grant_accepts_native_windows_drive_absolute_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
if not Path(PureWindowsPath("C:/tmp")).is_absolute():
|
||||
pytest.skip("Windows drive paths are not native absolute paths on this host")
|
||||
|
||||
grant = SandboxPathGrant(path=str(tmp_path))
|
||||
|
||||
assert Path(grant.path).is_absolute()
|
||||
|
||||
|
||||
def test_extra_path_grant_rules_reject_windows_drive_absolute_path() -> None:
|
||||
grant = SandboxPathGrant.model_construct(
|
||||
path="C:/tmp",
|
||||
read_only=False,
|
||||
description=None,
|
||||
)
|
||||
policy = WorkspacePathPolicy(root="/workspace", extra_path_grants=(grant,))
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
policy.extra_path_grant_rules()
|
||||
|
||||
assert str(exc_info.value) == "sandbox path grant path must be POSIX absolute"
|
||||
|
||||
|
||||
def test_manifest_serializes_extra_path_grants() -> None:
|
||||
manifest = Manifest(
|
||||
extra_path_grants=(
|
||||
SandboxPathGrant(
|
||||
path="/tmp",
|
||||
description="temporary files",
|
||||
),
|
||||
SandboxPathGrant(
|
||||
path="/opt/toolchain",
|
||||
read_only=True,
|
||||
description="compiler runtime",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert manifest.model_dump(mode="json")["extra_path_grants"] == [
|
||||
{
|
||||
"path": "/tmp",
|
||||
"read_only": False,
|
||||
"description": "temporary files",
|
||||
},
|
||||
{
|
||||
"path": "/opt/toolchain",
|
||||
"read_only": True,
|
||||
"description": "compiler runtime",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_extra_path_grant_accepts_absolute_path() -> None:
|
||||
policy = WorkspacePathPolicy(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
||||
)
|
||||
|
||||
assert policy.normalize_path("/tmp/result.txt") == Path("/tmp/result.txt")
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_ungranted_absolute_path() -> None:
|
||||
policy = WorkspacePathPolicy(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/tmp"),),
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidManifestPathError) as exc_info:
|
||||
policy.normalize_path("/var/result.txt")
|
||||
|
||||
assert str(exc_info.value) == "manifest path must be relative: /var/result.txt"
|
||||
assert exc_info.value.context == {"rel": "/var/result.txt", "reason": "absolute"}
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_write_under_read_only_grant() -> None:
|
||||
policy = WorkspacePathPolicy(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/opt/toolchain", read_only=True),),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
policy.normalize_path("/opt/toolchain/cache.db", for_write=True)
|
||||
|
||||
assert str(exc_info.value) == "failed to write archive for path: /opt/toolchain/cache.db"
|
||||
assert exc_info.value.context == {
|
||||
"path": "/opt/toolchain/cache.db",
|
||||
"reason": "read_only_extra_path_grant",
|
||||
"grant_path": "/opt/toolchain",
|
||||
}
|
||||
|
||||
|
||||
def test_extra_path_grant_allows_read_under_read_only_grant() -> None:
|
||||
policy = WorkspacePathPolicy(
|
||||
root="/workspace",
|
||||
extra_path_grants=(SandboxPathGrant(path="/opt/toolchain", read_only=True),),
|
||||
)
|
||||
|
||||
assert policy.normalize_path("/opt/toolchain/cache.db") == Path("/opt/toolchain/cache.db")
|
||||
|
||||
|
||||
def test_host_io_rejects_write_under_resolved_read_only_extra_path_grant(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
allowed = tmp_path / "allowed"
|
||||
grant_alias = tmp_path / "allowed-alias"
|
||||
workspace.mkdir()
|
||||
allowed.mkdir()
|
||||
os.symlink(allowed, grant_alias, target_is_directory=True)
|
||||
target = allowed / "cache.db"
|
||||
grant = SandboxPathGrant(path=str(grant_alias), read_only=True)
|
||||
policy = WorkspacePathPolicy(
|
||||
root=workspace,
|
||||
extra_path_grants=(grant,),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceArchiveWriteError) as exc_info:
|
||||
policy.normalize_path(target, for_write=True, resolve_symlinks=True)
|
||||
|
||||
assert str(exc_info.value) == f"failed to write archive for path: {target}"
|
||||
assert exc_info.value.context == {
|
||||
"path": str(target),
|
||||
"reason": "read_only_extra_path_grant",
|
||||
"grant_path": grant.path,
|
||||
}
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_relative_path() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SandboxPathGrant(path="tmp")
|
||||
|
||||
errors = exc_info.value.errors(include_url=False)
|
||||
assert len(errors) == 1
|
||||
error = dict(errors[0])
|
||||
ctx = cast(dict[str, Any], error["ctx"])
|
||||
error["ctx"] = {"error": str(ctx["error"])}
|
||||
assert error == {
|
||||
"type": "value_error",
|
||||
"loc": ("path",),
|
||||
"msg": "Value error, sandbox path grant path must be absolute",
|
||||
"input": "tmp",
|
||||
"ctx": {"error": "sandbox path grant path must be absolute"},
|
||||
}
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_root_path() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SandboxPathGrant(path="/")
|
||||
|
||||
errors = exc_info.value.errors(include_url=False)
|
||||
assert len(errors) == 1
|
||||
error = dict(errors[0])
|
||||
ctx = cast(dict[str, Any], error["ctx"])
|
||||
error["ctx"] = {"error": str(ctx["error"])}
|
||||
assert error == {
|
||||
"type": "value_error",
|
||||
"loc": ("path",),
|
||||
"msg": "Value error, sandbox path grant path must not be filesystem root",
|
||||
"input": "/",
|
||||
"ctx": {"error": "sandbox path grant path must not be filesystem root"},
|
||||
}
|
||||
|
||||
|
||||
def test_extra_path_grant_rejects_root_alias_path() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SandboxPathGrant(path="//")
|
||||
|
||||
errors = exc_info.value.errors(include_url=False)
|
||||
assert len(errors) == 1
|
||||
error = dict(errors[0])
|
||||
ctx = cast(dict[str, Any], error["ctx"])
|
||||
error["ctx"] = {"error": str(ctx["error"])}
|
||||
assert error == {
|
||||
"type": "value_error",
|
||||
"loc": ("path",),
|
||||
"msg": "Value error, sandbox path grant path must not be filesystem root",
|
||||
"input": "//",
|
||||
"ctx": {"error": "sandbox path grant path must not be filesystem root"},
|
||||
}
|
||||
|
||||
|
||||
def test_host_io_rejects_extra_path_grant_symlink_to_root(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
root_alias = tmp_path / "root-alias"
|
||||
workspace.mkdir()
|
||||
os.symlink(Path("/"), root_alias, target_is_directory=True)
|
||||
policy = WorkspacePathPolicy(
|
||||
root=workspace,
|
||||
extra_path_grants=(SandboxPathGrant(path=str(root_alias)),),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
policy.normalize_path(root_alias / "etc" / "passwd", resolve_symlinks=True)
|
||||
|
||||
assert str(exc_info.value) == "sandbox path grant path must not resolve to filesystem root"
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.errors import ErrorCode, WorkspaceWriteTypeError
|
||||
from agents.sandbox.session.workspace_payloads import coerce_write_payload
|
||||
|
||||
|
||||
class _Headers:
|
||||
def __init__(self, value: str | None) -> None:
|
||||
self._value = value
|
||||
|
||||
def get(self, name: str) -> str | None:
|
||||
assert name == "Content-Length"
|
||||
return self._value
|
||||
|
||||
|
||||
class _HeaderStream(io.BytesIO):
|
||||
def __init__(self, data: bytes, content_length: str | None) -> None:
|
||||
super().__init__(data)
|
||||
self.headers = _Headers(content_length)
|
||||
|
||||
|
||||
class _LengthStream(io.BytesIO):
|
||||
def __init__(self, data: bytes, length: int) -> None:
|
||||
super().__init__(data)
|
||||
self.length = length
|
||||
|
||||
|
||||
class _NoneReadStream:
|
||||
def read(self, size: int = -1) -> Any:
|
||||
_ = size
|
||||
return None
|
||||
|
||||
|
||||
class _BytearrayReadStream:
|
||||
def read(self, size: int = -1) -> Any:
|
||||
_ = size
|
||||
return bytearray(b"abc")
|
||||
|
||||
|
||||
class _TextReadStream:
|
||||
def read(self, size: int = -1) -> Any:
|
||||
_ = size
|
||||
return "not-bytes"
|
||||
|
||||
|
||||
class _UnseekableStream(io.BytesIO):
|
||||
def tell(self) -> int:
|
||||
raise OSError("not seekable")
|
||||
|
||||
|
||||
def test_coerce_write_payload_adapts_binary_reads() -> None:
|
||||
payload = coerce_write_payload(path=Path("/workspace/file.bin"), data=io.BytesIO(b"abc"))
|
||||
|
||||
assert payload.content_length == 3
|
||||
assert payload.stream.readable() is True
|
||||
assert payload.stream.read(1) == b"a"
|
||||
assert payload.stream.read() == b"bc"
|
||||
|
||||
|
||||
def test_coerce_write_payload_adapts_bytearray_and_none_reads() -> None:
|
||||
bytearray_payload = coerce_write_payload(
|
||||
path=Path("/workspace/file.bin"),
|
||||
data=cast(io.IOBase, _BytearrayReadStream()),
|
||||
)
|
||||
none_payload = coerce_write_payload(
|
||||
path=Path("/workspace/empty.bin"),
|
||||
data=cast(io.IOBase, _NoneReadStream()),
|
||||
)
|
||||
|
||||
assert bytearray_payload.stream.read() == b"abc"
|
||||
assert none_payload.stream.read() == b""
|
||||
|
||||
|
||||
def test_coerce_write_payload_supports_readinto_seek_and_tell() -> None:
|
||||
payload = coerce_write_payload(path=Path("/workspace/file.bin"), data=io.BytesIO(b"abcdef"))
|
||||
buffer = bytearray(3)
|
||||
|
||||
assert cast(Any, payload.stream).readinto(buffer) == 3
|
||||
assert bytes(buffer) == b"abc"
|
||||
assert payload.stream.tell() == 3
|
||||
assert payload.stream.seek(1) == 1
|
||||
assert payload.stream.read(2) == b"bc"
|
||||
|
||||
|
||||
def test_coerce_write_payload_rejects_text_chunks() -> None:
|
||||
path = Path("/workspace/file.txt")
|
||||
payload = coerce_write_payload(
|
||||
path=path,
|
||||
data=cast(io.IOBase, _TextReadStream()),
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceWriteTypeError) as exc_info:
|
||||
payload.stream.read()
|
||||
|
||||
assert exc_info.value.error_code is ErrorCode.WORKSPACE_WRITE_TYPE_ERROR
|
||||
assert exc_info.value.context == {
|
||||
"path": str(path),
|
||||
"actual_type": "str",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stream", "expected"),
|
||||
[
|
||||
(_LengthStream(b"abc", 5), 5),
|
||||
(_HeaderStream(b"abc", "7"), 7),
|
||||
(_HeaderStream(b"abc", "-1"), 3),
|
||||
(_HeaderStream(b"abc", "invalid"), 3),
|
||||
(_UnseekableStream(b"abc"), None),
|
||||
],
|
||||
)
|
||||
def test_coerce_write_payload_uses_best_effort_content_length(
|
||||
stream: io.IOBase,
|
||||
expected: int | None,
|
||||
) -> None:
|
||||
payload = coerce_write_payload(path=Path("/workspace/file.bin"), data=stream)
|
||||
|
||||
assert payload.content_length == expected
|
||||
Reference in New Issue
Block a user