chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import function_tool
|
||||
from agents.editor import ApplyPatchOperation
|
||||
from agents.sandbox.capabilities import Capability
|
||||
from agents.sandbox.entries import (
|
||||
AzureBlobMount,
|
||||
Dir,
|
||||
File,
|
||||
GCSMount,
|
||||
GitRepo,
|
||||
InContainerMountStrategy,
|
||||
LocalDir,
|
||||
LocalFile,
|
||||
R2Mount,
|
||||
RcloneMountPattern,
|
||||
S3Mount,
|
||||
)
|
||||
from agents.sandbox.errors import (
|
||||
ApplyPatchPathError,
|
||||
InvalidManifestPathError,
|
||||
WorkspaceReadNotFoundError,
|
||||
)
|
||||
from agents.sandbox.files import EntryKind
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.workspace_paths import SandboxPathGrant
|
||||
from agents.tool import Tool
|
||||
|
||||
BUILTIN_MANIFEST_ENTRY_TYPES = {
|
||||
"azure_blob_mount",
|
||||
"dir",
|
||||
"file",
|
||||
"gcs_mount",
|
||||
"git_repo",
|
||||
"local_dir",
|
||||
"local_file",
|
||||
"r2_mount",
|
||||
"s3_mount",
|
||||
}
|
||||
|
||||
DURABLE_WORKSPACE_TEXTS = {
|
||||
"inline.txt": "inline file v1\n",
|
||||
"delete_me.txt": "delete me v1\n",
|
||||
"tree/nested.txt": "nested file v1\n",
|
||||
"copied_file.txt": "local file source v1\n",
|
||||
"copied_dir/child.txt": "local dir child v1\n",
|
||||
"copied_dir/nested/grandchild.txt": "local dir grandchild v1\n",
|
||||
"repo/README.md": "mock git repo readme v1\n",
|
||||
"repo/pkg/module.py": "VALUE = 'mock git module v1'\n",
|
||||
}
|
||||
|
||||
EPHEMERAL_WORKSPACE_TEXTS = {
|
||||
"tree/ephemeral.txt": "ephemeral file v1\n",
|
||||
}
|
||||
|
||||
MOUNT_WORKSPACE_TEXTS = {
|
||||
"mounts/s3/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/gcs/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/r2/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
"mounts/azure/.mock-rclone-mounted": "mock rclone mount\n",
|
||||
}
|
||||
|
||||
ARCHIVE_WORKSPACE_TEXTS = {
|
||||
"archive_dir/hello.txt": "hello from tar archive\n",
|
||||
}
|
||||
|
||||
RUNTIME_WORKSPACE_TEXTS = {
|
||||
"runtime_note.txt": "runtime note v1\n",
|
||||
}
|
||||
|
||||
PATCHED_WORKSPACE_TEXTS = {
|
||||
"inline.txt": "inline file v2\n",
|
||||
"created_by_patch.txt": "created by patch",
|
||||
}
|
||||
|
||||
RESTORED_WORKSPACE_DIRS = {
|
||||
"archive_dir",
|
||||
"copied_dir",
|
||||
"copied_dir/nested",
|
||||
"mounts",
|
||||
"mounts/azure",
|
||||
"mounts/gcs",
|
||||
"mounts/r2",
|
||||
"mounts/s3",
|
||||
"repo",
|
||||
"repo/pkg",
|
||||
"tree",
|
||||
}
|
||||
|
||||
RESTORED_WORKSPACE_FILES = {
|
||||
"archive_dir/hello.txt",
|
||||
"bundle.tar",
|
||||
"copied_dir/child.txt",
|
||||
"copied_dir/nested/grandchild.txt",
|
||||
"copied_file.txt",
|
||||
"created_by_patch.txt",
|
||||
"inline.txt",
|
||||
"mounts/azure/.mock-rclone-mounted",
|
||||
"mounts/gcs/.mock-rclone-mounted",
|
||||
"mounts/r2/.mock-rclone-mounted",
|
||||
"mounts/s3/.mock-rclone-mounted",
|
||||
"repo/README.md",
|
||||
"repo/pkg/module.py",
|
||||
"runtime_note.txt",
|
||||
"tree/ephemeral.txt",
|
||||
"tree/nested.txt",
|
||||
}
|
||||
|
||||
SANDBOX_INTERNAL_WORKSPACE_DIR_PREFIXES = (".sandbox-rclone-config",)
|
||||
|
||||
MOCK_TOOL_NAMES = (
|
||||
"blobfuse2",
|
||||
"cp",
|
||||
"fusermount3",
|
||||
"git",
|
||||
"mount-s3",
|
||||
"pkill",
|
||||
"rclone",
|
||||
"rm",
|
||||
"umount",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MockExternalTools:
|
||||
bin_dir: Path
|
||||
log_path: Path
|
||||
|
||||
def calls(self) -> list[str]:
|
||||
if not self.log_path.exists():
|
||||
return []
|
||||
return self.log_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def install_mock_external_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> MockExternalTools:
|
||||
bin_dir = tmp_path / "mock-bin"
|
||||
bin_dir.mkdir()
|
||||
log_path = tmp_path / "mock-tool-calls.tsv"
|
||||
log_path.write_text("", encoding="utf-8")
|
||||
|
||||
for name in MOCK_TOOL_NAMES:
|
||||
tool_path = bin_dir / name
|
||||
tool_path.write_text(_mock_tool_script(), encoding="utf-8")
|
||||
tool_path.chmod(0o755)
|
||||
|
||||
existing_path = os.environ.get("PATH", "")
|
||||
monkeypatch.setenv("SANDBOX_INTEGRATION_TOOL_LOG", str(log_path))
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{existing_path}")
|
||||
return MockExternalTools(bin_dir=bin_dir, log_path=log_path)
|
||||
|
||||
|
||||
def create_local_sources(tmp_path: Path) -> Path:
|
||||
source_root = tmp_path / "manifest-sources"
|
||||
local_dir = source_root / "local-dir"
|
||||
nested_dir = local_dir / "nested"
|
||||
nested_dir.mkdir(parents=True)
|
||||
(source_root / "local-file.txt").write_text("local file source v1\n", encoding="utf-8")
|
||||
(local_dir / "child.txt").write_text("local dir child v1\n", encoding="utf-8")
|
||||
(nested_dir / "grandchild.txt").write_text("local dir grandchild v1\n", encoding="utf-8")
|
||||
return source_root
|
||||
|
||||
|
||||
def build_manifest_with_all_entry_types(*, workspace_root: Path, source_root: Path) -> Manifest:
|
||||
return Manifest(
|
||||
root=str(workspace_root),
|
||||
extra_path_grants=(SandboxPathGrant(path=str(source_root)),),
|
||||
entries={
|
||||
"inline.txt": File(content=DURABLE_WORKSPACE_TEXTS["inline.txt"].encode("utf-8")),
|
||||
"delete_me.txt": File(content=DURABLE_WORKSPACE_TEXTS["delete_me.txt"].encode("utf-8")),
|
||||
"tree": Dir(
|
||||
children={
|
||||
"nested.txt": File(
|
||||
content=DURABLE_WORKSPACE_TEXTS["tree/nested.txt"].encode("utf-8")
|
||||
),
|
||||
"ephemeral.txt": File(
|
||||
content=EPHEMERAL_WORKSPACE_TEXTS["tree/ephemeral.txt"].encode("utf-8"),
|
||||
ephemeral=True,
|
||||
),
|
||||
}
|
||||
),
|
||||
"copied_file.txt": LocalFile(
|
||||
src=source_root / "local-file.txt",
|
||||
),
|
||||
"copied_dir": LocalDir(
|
||||
src=source_root / "local-dir",
|
||||
),
|
||||
"repo": GitRepo(repo="openai/mock-sandbox-fixture", ref="main"),
|
||||
"mounts/s3": S3Mount(
|
||||
bucket="s3-bucket",
|
||||
access_key_id="s3-access-key-id",
|
||||
secret_access_key="s3-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/gcs": GCSMount(
|
||||
bucket="gcs-bucket",
|
||||
access_id="gcs-access-id",
|
||||
secret_access_key="gcs-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/r2": R2Mount(
|
||||
bucket="r2-bucket",
|
||||
account_id="r2-account-id",
|
||||
access_key_id="r2-access-key-id",
|
||||
secret_access_key="r2-secret-access-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
"mounts/azure": AzureBlobMount(
|
||||
account="azure-account",
|
||||
container="azure-container",
|
||||
account_key="azure-account-key",
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def manifest_entry_types(manifest: Manifest) -> set[str]:
|
||||
return {entry.type for _path, entry in manifest.iter_entries()}
|
||||
|
||||
|
||||
async def read_workspace_text(session: BaseSandboxSession, path: str | Path) -> str:
|
||||
handle = await session.read(Path(path))
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8")
|
||||
raise TypeError(f"Unexpected workspace read payload type: {type(payload).__name__}")
|
||||
|
||||
|
||||
async def write_workspace_text(session: BaseSandboxSession, path: str | Path, text: str) -> None:
|
||||
await session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
|
||||
|
||||
async def assert_workspace_texts(
|
||||
session: BaseSandboxSession,
|
||||
expected: Mapping[str, str],
|
||||
) -> None:
|
||||
actual = {path: await read_workspace_text(session, path) for path in expected}
|
||||
assert actual == dict(expected)
|
||||
|
||||
|
||||
async def assert_manifest_materialized(session: BaseSandboxSession) -> None:
|
||||
assert manifest_entry_types(session.state.manifest) == BUILTIN_MANIFEST_ENTRY_TYPES
|
||||
await assert_workspace_texts(session, DURABLE_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, EPHEMERAL_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, MOUNT_WORKSPACE_TEXTS)
|
||||
|
||||
|
||||
async def assert_lifecycle_patch_state(session: BaseSandboxSession) -> None:
|
||||
await assert_workspace_texts(
|
||||
session,
|
||||
{
|
||||
**{
|
||||
path: text
|
||||
for path, text in DURABLE_WORKSPACE_TEXTS.items()
|
||||
if path != "delete_me.txt"
|
||||
},
|
||||
**RUNTIME_WORKSPACE_TEXTS,
|
||||
**PATCHED_WORKSPACE_TEXTS,
|
||||
},
|
||||
)
|
||||
await assert_workspace_missing(session, "delete_me.txt")
|
||||
|
||||
|
||||
async def assert_restored_lifecycle_state(session: BaseSandboxSession) -> None:
|
||||
assert manifest_entry_types(session.state.manifest) == BUILTIN_MANIFEST_ENTRY_TYPES
|
||||
await assert_lifecycle_patch_state(session)
|
||||
await assert_workspace_texts(session, ARCHIVE_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, EPHEMERAL_WORKSPACE_TEXTS)
|
||||
await assert_workspace_texts(session, MOUNT_WORKSPACE_TEXTS)
|
||||
await assert_restored_workspace_tree(session)
|
||||
|
||||
|
||||
async def assert_workspace_missing(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await read_workspace_text(session, path)
|
||||
except WorkspaceReadNotFoundError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace path to be missing: {path}")
|
||||
|
||||
|
||||
async def assert_workspace_escape_blocked(session: BaseSandboxSession) -> None:
|
||||
for path in ("../outside.txt", "/tmp/sandbox-outside.txt"):
|
||||
await _assert_read_blocked(session, path)
|
||||
await _assert_write_blocked(session, path)
|
||||
await _assert_patch_blocked(session, path)
|
||||
await _assert_symlink_escape_blocked(session)
|
||||
|
||||
|
||||
async def assert_restored_workspace_tree(session: BaseSandboxSession) -> None:
|
||||
actual_dirs, actual_files = await _workspace_tree(session)
|
||||
assert actual_dirs == RESTORED_WORKSPACE_DIRS, {
|
||||
"actual_dirs": sorted(actual_dirs),
|
||||
"expected_dirs": sorted(RESTORED_WORKSPACE_DIRS),
|
||||
}
|
||||
assert actual_files == RESTORED_WORKSPACE_FILES, {
|
||||
"actual_files": sorted(actual_files),
|
||||
"expected_files": sorted(RESTORED_WORKSPACE_FILES),
|
||||
}
|
||||
|
||||
|
||||
def lifecycle_patch_operations() -> list[ApplyPatchOperation | dict[str, object]]:
|
||||
return [
|
||||
ApplyPatchOperation(
|
||||
type="update_file",
|
||||
path="inline.txt",
|
||||
diff="@@\n-inline file v1\n+inline file v2\n",
|
||||
),
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path="created_by_patch.txt",
|
||||
diff="+created by patch\n",
|
||||
),
|
||||
ApplyPatchOperation(
|
||||
type="delete_file",
|
||||
path="delete_me.txt",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SandboxFileCapability(Capability):
|
||||
type: str = "sandbox-file"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="sandbox-file")
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
@function_tool(name_override="write_file", failure_error_function=None)
|
||||
async def write_file(path: str, content: str) -> str:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxFileCapability is not bound to a session.")
|
||||
await write_workspace_text(self.session, path, content)
|
||||
return f"wrote {path}"
|
||||
|
||||
@function_tool(name_override="read_file", failure_error_function=None)
|
||||
async def read_file(path: str) -> str:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxFileCapability is not bound to a session.")
|
||||
return await read_workspace_text(self.session, path)
|
||||
|
||||
return [write_file, read_file]
|
||||
|
||||
|
||||
class SandboxLifecycleProbeCapability(Capability):
|
||||
type: str = "sandbox-lifecycle-probe"
|
||||
pty_process_id: int | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="sandbox-lifecycle-probe")
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
@function_tool(name_override="assert_manifest_materialized", failure_error_function=None)
|
||||
async def assert_manifest_materialized_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_manifest_materialized(session)
|
||||
return "manifest materialized"
|
||||
|
||||
@function_tool(name_override="apply_lifecycle_patch", failure_error_function=None)
|
||||
async def apply_lifecycle_patch() -> str:
|
||||
session = self._require_session()
|
||||
result = await session.apply_patch(lifecycle_patch_operations())
|
||||
assert result == "Done!"
|
||||
await assert_lifecycle_patch_state(session)
|
||||
return "lifecycle patch applied"
|
||||
|
||||
@function_tool(name_override="assert_workspace_escape_blocked", failure_error_function=None)
|
||||
async def assert_workspace_escape_blocked_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_workspace_escape_blocked(session)
|
||||
return "workspace escape blocked"
|
||||
|
||||
@function_tool(name_override="extract_lifecycle_archive", failure_error_function=None)
|
||||
async def extract_lifecycle_archive() -> str:
|
||||
session = self._require_session()
|
||||
await session.extract("bundle.tar", _tar_bytes(ARCHIVE_WORKSPACE_TEXTS))
|
||||
await assert_workspace_texts(session, ARCHIVE_WORKSPACE_TEXTS)
|
||||
return "archive extracted"
|
||||
|
||||
@function_tool(name_override="start_lifecycle_pty", failure_error_function=None)
|
||||
async def start_lifecycle_pty() -> str:
|
||||
session = self._require_session()
|
||||
pty = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'ready\\n'; while IFS= read -r line; do printf 'got:%s\\n' \"$line\"; done",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
assert pty.process_id is not None
|
||||
output = pty.output.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
||||
assert output == "ready\n"
|
||||
self.pty_process_id = pty.process_id
|
||||
update = await session.pty_write_stdin(
|
||||
session_id=pty.process_id,
|
||||
chars="hello pty\n",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
write_output = update.output.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
||||
assert write_output == "hello pty\ngot:hello pty\n"
|
||||
assert update.process_id == pty.process_id
|
||||
assert update.exit_code is None
|
||||
return "pty started and echoed stdin"
|
||||
|
||||
@function_tool(name_override="assert_restored_lifecycle_state", failure_error_function=None)
|
||||
async def assert_restored_lifecycle_state_tool() -> str:
|
||||
session = self._require_session()
|
||||
await assert_restored_lifecycle_state(session)
|
||||
return "restored lifecycle state verified"
|
||||
|
||||
return [
|
||||
assert_manifest_materialized_tool,
|
||||
apply_lifecycle_patch,
|
||||
assert_workspace_escape_blocked_tool,
|
||||
extract_lifecycle_archive,
|
||||
start_lifecycle_pty,
|
||||
assert_restored_lifecycle_state_tool,
|
||||
]
|
||||
|
||||
def _require_session(self) -> BaseSandboxSession:
|
||||
if self.session is None:
|
||||
raise AssertionError("SandboxLifecycleProbeCapability is not bound to a session.")
|
||||
return self.session
|
||||
|
||||
|
||||
async def _assert_read_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await read_workspace_text(session, path)
|
||||
except InvalidManifestPathError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace read to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_write_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await write_workspace_text(session, path, "outside write\n")
|
||||
except InvalidManifestPathError:
|
||||
return
|
||||
raise AssertionError(f"Expected workspace write to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_patch_blocked(session: BaseSandboxSession, path: str) -> None:
|
||||
try:
|
||||
await session.apply_patch(
|
||||
ApplyPatchOperation(
|
||||
type="create_file",
|
||||
path=path,
|
||||
diff="+outside patch\n",
|
||||
)
|
||||
)
|
||||
except (ApplyPatchPathError, InvalidManifestPathError):
|
||||
return
|
||||
raise AssertionError(f"Expected workspace patch to be blocked: {path}")
|
||||
|
||||
|
||||
async def _assert_symlink_escape_blocked(session: BaseSandboxSession) -> None:
|
||||
workspace_root = Path(session.state.manifest.root)
|
||||
outside_path = workspace_root.parent / "symlink-outside.txt"
|
||||
symlink_path = workspace_root / "symlink_escape.txt"
|
||||
outside_path.write_text("outside symlink target\n", encoding="utf-8")
|
||||
symlink_path.symlink_to(outside_path)
|
||||
try:
|
||||
await _assert_read_blocked(session, "symlink_escape.txt")
|
||||
await _assert_write_blocked(session, "symlink_escape.txt")
|
||||
await _assert_patch_blocked(session, "symlink_escape.txt")
|
||||
finally:
|
||||
symlink_path.unlink(missing_ok=True)
|
||||
outside_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _tar_bytes(members: Mapping[str, str]) -> io.BytesIO:
|
||||
archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive, mode="w") as tar:
|
||||
for name, text in members.items():
|
||||
payload = text.encode("utf-8")
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(payload)
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
archive.seek(0)
|
||||
return archive
|
||||
|
||||
|
||||
async def _workspace_tree(session: BaseSandboxSession) -> tuple[set[str], set[str]]:
|
||||
root = Path(session.state.manifest.root).resolve(strict=False)
|
||||
dirs: set[str] = set()
|
||||
files: set[str] = set()
|
||||
|
||||
async def collect(path: Path) -> None:
|
||||
for entry in await session.ls(path):
|
||||
rel_path = _entry_workspace_rel_path(entry.path, root)
|
||||
if entry.kind == EntryKind.DIRECTORY:
|
||||
if _is_sandbox_internal_workspace_dir(rel_path):
|
||||
continue
|
||||
dirs.add(rel_path)
|
||||
await collect(Path(rel_path))
|
||||
elif entry.kind == EntryKind.FILE:
|
||||
files.add(rel_path)
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Unexpected workspace entry kind for {rel_path}: {entry.kind}"
|
||||
)
|
||||
|
||||
await collect(Path("."))
|
||||
return dirs, files
|
||||
|
||||
|
||||
def _entry_workspace_rel_path(entry_path: str, root: Path) -> str:
|
||||
path = Path(entry_path)
|
||||
if path.is_absolute():
|
||||
path = path.resolve(strict=False).relative_to(root)
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def _is_sandbox_internal_workspace_dir(path: str) -> bool:
|
||||
return any(
|
||||
path == prefix or path.startswith(f"{prefix}/")
|
||||
for prefix in SANDBOX_INTERNAL_WORKSPACE_DIR_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def _mock_tool_script() -> str:
|
||||
return """#!/bin/sh
|
||||
set -eu
|
||||
|
||||
tool=$(basename "$0")
|
||||
log_path="${SANDBOX_INTEGRATION_TOOL_LOG:-}"
|
||||
if [ -n "$log_path" ]; then
|
||||
{
|
||||
printf "%s" "$tool"
|
||||
for arg in "$@"; do
|
||||
printf "\\t%s" "$arg"
|
||||
done
|
||||
printf "\\n"
|
||||
} >> "$log_path"
|
||||
fi
|
||||
|
||||
case "$tool" in
|
||||
git)
|
||||
exit 0
|
||||
;;
|
||||
cp)
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest/pkg"
|
||||
printf "mock git repo readme v1\\n" > "$dest/README.md"
|
||||
printf "VALUE = 'mock git module v1'\\n" > "$dest/pkg/module.py"
|
||||
exit 0
|
||||
;;
|
||||
rclone)
|
||||
if [ "${1:-}" = "mount" ] && [ -n "${3:-}" ]; then
|
||||
mkdir -p "$3"
|
||||
printf "mock rclone mount\\n" > "$3/.mock-rclone-mounted"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
blobfuse2)
|
||||
if [ "${1:-}" = "mount" ]; then
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest"
|
||||
printf "mock blobfuse mount\\n" > "$dest/.mock-blobfuse-mounted"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
mount-s3)
|
||||
dest=""
|
||||
for arg in "$@"; do
|
||||
dest="$arg"
|
||||
done
|
||||
mkdir -p "$dest"
|
||||
printf "mock mount-s3 mount\\n" > "$dest/.mock-mount-s3-mounted"
|
||||
exit 0
|
||||
;;
|
||||
rm)
|
||||
recursive=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-rf|-fr|-r|-f|--)
|
||||
if [ "$arg" = "-rf" ] || [ "$arg" = "-fr" ] || [ "$arg" = "-r" ]; then
|
||||
recursive="-r"
|
||||
fi
|
||||
;;
|
||||
"$HOME"|"$HOME"/*)
|
||||
if [ -n "$recursive" ]; then
|
||||
/bin/rm -rf -- "$arg"
|
||||
else
|
||||
/bin/rm -f -- "$arg"
|
||||
fi
|
||||
;;
|
||||
/*)
|
||||
;;
|
||||
*..*)
|
||||
;;
|
||||
*)
|
||||
if [ -n "$recursive" ]; then
|
||||
/bin/rm -rf -- "$arg"
|
||||
else
|
||||
/bin/rm -f -- "$arg"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 0
|
||||
;;
|
||||
fusermount3|umount|pkill)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
"""
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agents.items import TResponseOutputItem
|
||||
from tests.fake_model import FakeModel
|
||||
from tests.test_responses import get_final_output_message, get_function_tool_call
|
||||
|
||||
__test__ = False
|
||||
|
||||
|
||||
class TestModel(FakeModel):
|
||||
"""Reusable queued model for sandbox integration tests."""
|
||||
|
||||
__test__ = False
|
||||
|
||||
def queue_turn(self, *items: TResponseOutputItem) -> None:
|
||||
self.set_next_output(list(items))
|
||||
|
||||
def queue_function_call(
|
||||
self,
|
||||
name: str,
|
||||
arguments: Mapping[str, Any] | str | None = None,
|
||||
*,
|
||||
call_id: str | None = None,
|
||||
namespace: str | None = None,
|
||||
) -> None:
|
||||
self.queue_turn(
|
||||
get_function_tool_call(
|
||||
name,
|
||||
_serialize_arguments(arguments),
|
||||
call_id=call_id,
|
||||
namespace=namespace,
|
||||
)
|
||||
)
|
||||
|
||||
def queue_function_calls(
|
||||
self,
|
||||
calls: Sequence[tuple[str, Mapping[str, Any] | str | None, str | None]],
|
||||
) -> None:
|
||||
self.queue_turn(
|
||||
*[
|
||||
get_function_tool_call(name, _serialize_arguments(arguments), call_id=call_id)
|
||||
for name, arguments, call_id in calls
|
||||
]
|
||||
)
|
||||
|
||||
def queue_final_output(self, output: str) -> None:
|
||||
self.queue_turn(get_final_output_message(output))
|
||||
|
||||
|
||||
def _serialize_arguments(arguments: Mapping[str, Any] | str | None) -> str:
|
||||
if arguments is None:
|
||||
return "{}"
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
return json.dumps(arguments)
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agents import RunConfig, Runner, function_tool
|
||||
from agents.items import RunItem, ToolCallOutputItem
|
||||
from agents.run_state import RunState
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session import CallbackSink, Instrumentation, SandboxSessionEvent
|
||||
from tests.sandbox.integration_tests._helpers import (
|
||||
SandboxFileCapability,
|
||||
SandboxLifecycleProbeCapability,
|
||||
build_manifest_with_all_entry_types,
|
||||
create_local_sources,
|
||||
install_mock_external_tools,
|
||||
)
|
||||
from tests.sandbox.integration_tests.test_model import TestModel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_preserves_unix_local_lifecycle_state_across_pause_and_resume(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
install_mock_external_tools(monkeypatch, tmp_path)
|
||||
source_root = create_local_sources(tmp_path)
|
||||
manifest = build_manifest_with_all_entry_types(
|
||||
workspace_root=Path("/workspace"),
|
||||
source_root=source_root,
|
||||
)
|
||||
events: list[SandboxSessionEvent] = []
|
||||
client = UnixLocalSandboxClient(
|
||||
instrumentation=Instrumentation(
|
||||
sinks=[CallbackSink(lambda event, _session: events.append(event), mode="sync")]
|
||||
)
|
||||
)
|
||||
model = TestModel()
|
||||
model.queue_function_call(
|
||||
"assert_manifest_materialized",
|
||||
{},
|
||||
call_id="call_manifest_materialized",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"write_file",
|
||||
{"path": "runtime_note.txt", "content": "runtime note v1\n"},
|
||||
call_id="call_write_runtime_note",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"apply_lifecycle_patch",
|
||||
{},
|
||||
call_id="call_apply_lifecycle_patch",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"assert_workspace_escape_blocked",
|
||||
{},
|
||||
call_id="call_assert_workspace_escape_blocked",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"extract_lifecycle_archive",
|
||||
{},
|
||||
call_id="call_extract_lifecycle_archive",
|
||||
)
|
||||
model.queue_function_call(
|
||||
"start_lifecycle_pty",
|
||||
{},
|
||||
call_id="call_start_lifecycle_pty",
|
||||
)
|
||||
model.queue_function_call("approval_tool", {}, call_id="call_approval")
|
||||
|
||||
@function_tool(name_override="approval_tool", needs_approval=True)
|
||||
def approval_tool() -> str:
|
||||
return "approved"
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="sandbox",
|
||||
model=model,
|
||||
instructions="Use the sandbox lifecycle tools.",
|
||||
default_manifest=manifest,
|
||||
tools=[approval_tool],
|
||||
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
||||
)
|
||||
|
||||
first_run = await Runner.run(
|
||||
agent,
|
||||
"verify the UnixLocal sandbox lifecycle and wait for approval",
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
||||
)
|
||||
|
||||
assert _tool_outputs(first_run.new_items, agent=agent) == [
|
||||
"manifest materialized",
|
||||
"wrote runtime_note.txt",
|
||||
"lifecycle patch applied",
|
||||
"workspace escape blocked",
|
||||
"archive extracted",
|
||||
"pty started and echoed stdin",
|
||||
]
|
||||
assert len(first_run.interruptions) == 1
|
||||
state = first_run.to_state()
|
||||
assert state._sandbox is not None
|
||||
assert state._sandbox["backend_id"] == "unix_local"
|
||||
assert state._sandbox["current_agent_name"] == "sandbox"
|
||||
session_state = state._sandbox["session_state"]
|
||||
assert isinstance(session_state, dict)
|
||||
snapshot = session_state["snapshot"]
|
||||
assert isinstance(snapshot, dict)
|
||||
assert snapshot["type"] == "local"
|
||||
assert session_state["workspace_root_owned"] is True
|
||||
assert session_state["workspace_root_ready"] is True
|
||||
workspace_root = _session_state_manifest_root(session_state)
|
||||
assert not workspace_root.exists()
|
||||
assert _successful_event_count(events, op="stop") == 1
|
||||
assert _successful_event_count(events, op="shutdown") == 1
|
||||
|
||||
resumed_model = TestModel()
|
||||
resumed_model.queue_function_call(
|
||||
"assert_restored_lifecycle_state",
|
||||
{},
|
||||
call_id="call_assert_restored_lifecycle_state",
|
||||
)
|
||||
resumed_model.queue_function_call(
|
||||
"read_file",
|
||||
{"path": "runtime_note.txt"},
|
||||
call_id="call_read_runtime_note",
|
||||
)
|
||||
resumed_model.queue_final_output("done")
|
||||
resumed_agent = SandboxAgent(
|
||||
name="sandbox",
|
||||
model=resumed_model,
|
||||
instructions="Use the sandbox lifecycle tools.",
|
||||
default_manifest=manifest,
|
||||
tools=[approval_tool],
|
||||
capabilities=[SandboxFileCapability(), SandboxLifecycleProbeCapability()],
|
||||
)
|
||||
|
||||
restored_state = await RunState.from_json(resumed_agent, state.to_json())
|
||||
restored_interruptions = restored_state.get_interruptions()
|
||||
assert len(restored_interruptions) == 1
|
||||
restored_state.approve(restored_interruptions[0])
|
||||
|
||||
resumed = await Runner.run(
|
||||
resumed_agent,
|
||||
restored_state,
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
|
||||
)
|
||||
|
||||
assert resumed.final_output == "done"
|
||||
assert not workspace_root.exists()
|
||||
assert _successful_event_count(events, op="stop") == 2
|
||||
assert _successful_event_count(events, op="shutdown") == 2
|
||||
assert _tool_outputs(resumed.new_items, agent=resumed_agent)[-3:] == [
|
||||
"approved",
|
||||
"restored lifecycle state verified",
|
||||
"runtime note v1\n",
|
||||
]
|
||||
|
||||
|
||||
def _session_state_manifest_root(session_state: dict[str, object]) -> Path:
|
||||
manifest = session_state["manifest"]
|
||||
assert isinstance(manifest, dict)
|
||||
root = manifest["root"]
|
||||
assert isinstance(root, str)
|
||||
return Path(root)
|
||||
|
||||
|
||||
def _successful_event_count(events: list[SandboxSessionEvent], *, op: str) -> int:
|
||||
return sum(
|
||||
1
|
||||
for event in events
|
||||
if event.op == op and event.phase == "finish" and getattr(event, "ok", False) is True
|
||||
)
|
||||
|
||||
|
||||
def _tool_outputs(items: Sequence[RunItem], *, agent: SandboxAgent) -> list[str]:
|
||||
outputs: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, ToolCallOutputItem) and item.agent is agent:
|
||||
assert isinstance(item.output, str)
|
||||
outputs.append(item.output)
|
||||
return outputs
|
||||
Reference in New Issue
Block a user