chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+152
View File
@@ -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 @@
+632
View File
@@ -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
+264
View File
@@ -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"
+109
View File
@@ -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
+46
View File
@@ -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
+169
View File
@@ -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
+62
View File
@@ -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
+81
View File
@@ -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]
+857
View File
@@ -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()
+214
View File
@@ -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
+453
View File
@@ -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
+54
View File
@@ -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
+135
View File
@@ -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
+83
View File
@@ -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="/")
+59
View File
@@ -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
+39
View File
@@ -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
+63
View File
@@ -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
+165
View File
@@ -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"
+199
View File
@@ -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 == ""
+84
View File
@@ -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"
+231
View File
@@ -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)
+769
View File
@@ -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]
)
+275
View File
@@ -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")
+823
View File
@@ -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")]
+147
View File
@@ -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()
+365
View File
@@ -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")],
)
+28
View File
@@ -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",
]
+96
View File
@@ -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
+22
View File
@@ -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=[]))
+290
View File
@@ -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])
+594
View File
@@ -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"
+124
View File
@@ -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