- 将代码库中所有对 /mnt/user-data 的引用更改为 /home/yuxi/user-data。 - 更新测试用例以反映新的路径结构。 - 调整 Docker 配置以使用新路径。 - 修改文档以与新的路径约定保持一致。 - 增强沙盒配置器中文件读取操作的错误处理。 - 改进代理文件管理系统中的附件处理。
277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""Tests for sandbox backend components."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from pathlib import Path
|
|
from types import MethodType
|
|
|
|
import pytest
|
|
|
|
from deepagents.backends.protocol import ExecuteResponse, FileInfo
|
|
|
|
from yuxi.agents.backends.sandbox import (
|
|
RemoteSandboxBackend,
|
|
SandboxInfo,
|
|
YuxiSandboxBackend,
|
|
YuxiSandboxProvider,
|
|
get_sandbox_security_opts,
|
|
normalize_virtual_path,
|
|
)
|
|
from yuxi.agents.backends.sandbox.backend import ProvisionerSandboxBackend
|
|
from yuxi.agents.backends.sandbox import sandbox_remote as remote_sandbox_backend
|
|
from yuxi.agents.backends.composite import (
|
|
create_agent_composite_backend,
|
|
resolve_sandbox_backend,
|
|
resolve_sandbox_backend_async,
|
|
)
|
|
from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware
|
|
|
|
|
|
# ==================== Provider lifecycle tests ====================
|
|
|
|
|
|
def test_create_agent_composite_backend_uses_sandbox_default():
|
|
runtime = type("RuntimeStub", (), {"context": None})()
|
|
sandbox_backend = object()
|
|
|
|
backend = create_agent_composite_backend(runtime, sandbox_backend=sandbox_backend)
|
|
|
|
assert backend.default is sandbox_backend
|
|
|
|
|
|
def test_resolve_sandbox_backend_returns_none_without_thread_id():
|
|
assert resolve_sandbox_backend("") is None
|
|
|
|
|
|
def test_resolve_sandbox_backend_returns_none_on_provider_error(monkeypatch):
|
|
provider = type("ProviderStub", (), {"acquire": lambda self, thread_id: (_ for _ in ()).throw(RuntimeError)})()
|
|
monkeypatch.setattr("yuxi.agents.backends.composite.get_sandbox_provider", lambda: provider)
|
|
|
|
assert resolve_sandbox_backend("thread-1") is None
|
|
|
|
|
|
def test_resolve_sandbox_backend_async_uses_provider(monkeypatch):
|
|
backend = object()
|
|
provider = type("ProviderStub", (), {"acquire": lambda self, thread_id: backend})()
|
|
monkeypatch.setattr("yuxi.agents.backends.composite.get_sandbox_provider", lambda: provider)
|
|
|
|
assert asyncio.run(resolve_sandbox_backend_async("thread-1")) is backend
|
|
|
|
|
|
def test_destroy_stops_warm_pool_container_after_release(monkeypatch):
|
|
provider = object.__new__(YuxiSandboxProvider)
|
|
provider._lock = threading.Lock()
|
|
provider._sandboxes = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._thread_locks = {}
|
|
provider._last_activity = {}
|
|
sandbox_key = "sandbox-1"
|
|
thread_id = "thread-1"
|
|
info = SandboxInfo(
|
|
sandbox_id=sandbox_key,
|
|
container_name="container-1",
|
|
container_id="cid-1",
|
|
sandbox_url="http://sandbox",
|
|
)
|
|
provider._warm_pool = {sandbox_key: (info, 0.0)}
|
|
|
|
stopped = []
|
|
provider._backend = type(
|
|
"BackendStub",
|
|
(),
|
|
{"destroy": lambda self, sandbox_info: stopped.append(sandbox_info.container_id)},
|
|
)()
|
|
monkeypatch.setattr(provider, "_deterministic_sandbox_id", lambda _: sandbox_key)
|
|
|
|
provider.destroy(thread_id)
|
|
|
|
assert stopped == ["cid-1"]
|
|
assert sandbox_key not in provider._warm_pool
|
|
|
|
|
|
def test_resolve_mount_source_maps_app_paths_under_host_project_dir(monkeypatch):
|
|
monkeypatch.setenv("YUXI_HOST_PROJECT_DIR", "/host/project")
|
|
|
|
resolved = YuxiSandboxProvider._resolve_mount_source(Path("/app/saves/threads/t1/user-data"))
|
|
|
|
assert resolved == "/host/project/saves/threads/t1/user-data"
|
|
|
|
|
|
def test_remote_sandbox_backend_rejects_non_http_urls():
|
|
with pytest.raises(ValueError):
|
|
RemoteSandboxBackend("file:///tmp/provisioner")
|
|
|
|
|
|
def test_remote_sandbox_backend_destroy_logs_request_errors(monkeypatch):
|
|
backend = RemoteSandboxBackend("https://provisioner.example.com")
|
|
info = SandboxInfo(sandbox_id="sandbox-1", sandbox_url="https://sandbox.example.com")
|
|
warnings = []
|
|
|
|
def fake_delete(*args, **kwargs):
|
|
raise remote_sandbox_backend.requests.RequestException("boom")
|
|
|
|
monkeypatch.setattr(remote_sandbox_backend.requests, "delete", fake_delete)
|
|
monkeypatch.setattr(remote_sandbox_backend.logger, "warning", warnings.append)
|
|
|
|
backend.destroy(info)
|
|
|
|
assert warnings
|
|
|
|
|
|
# ==================== Path compatibility tests ====================
|
|
|
|
|
|
def test_normalize_virtual_path_supports_legacy_skills_alias() -> None:
|
|
assert normalize_virtual_path("/skills", "t-1") == "/home/yuxi/skills"
|
|
assert normalize_virtual_path("/skills/demo/SKILL.md", "t-1") == "/home/yuxi/skills/demo/SKILL.md"
|
|
|
|
|
|
def test_normalize_virtual_path_supports_attachments_alias() -> None:
|
|
assert normalize_virtual_path("/attachments", "thread-1") == "/home/yuxi/user-data/uploads/attachments"
|
|
assert normalize_virtual_path("/attachments/a.md", "thread-1") == "/home/yuxi/user-data/uploads/attachments/a.md"
|
|
|
|
|
|
def test_normalize_virtual_path_supports_thread_scoped_aliases() -> None:
|
|
assert (
|
|
normalize_virtual_path("/outputs/thread-1/result.txt", "thread-1")
|
|
== "/home/yuxi/user-data/outputs/result.txt"
|
|
)
|
|
assert normalize_virtual_path("/uploads/thread-1/demo.txt", "thread-1") == "/home/yuxi/user-data/uploads/demo.txt"
|
|
assert (
|
|
normalize_virtual_path("/large_tool_results/thread-1/result.json", "thread-1")
|
|
== "/home/yuxi/user-data/large_tool_results/result.json"
|
|
)
|
|
|
|
|
|
def test_skills_middleware_extracts_slug_for_new_and_legacy_paths() -> None:
|
|
middleware = SkillsMiddleware()
|
|
assert middleware.skills_sources_for_prompt == ["/home/yuxi/skills/"]
|
|
assert middleware._extract_skill_slug_from_skill_md_path("/home/yuxi/skills/demo-skill/SKILL.md") == "demo-skill"
|
|
assert not hasattr(middleware, "_dependency_map_cache")
|
|
assert not hasattr(middleware, "_prompt_metadata_cache")
|
|
|
|
|
|
def test_get_sandbox_security_opts_supports_empty_and_multiple_values(monkeypatch) -> None:
|
|
monkeypatch.setenv("YUXI_SANDBOX_SECURITY_OPTS", "")
|
|
assert get_sandbox_security_opts() == []
|
|
|
|
monkeypatch.setenv("YUXI_SANDBOX_SECURITY_OPTS", "seccomp=unconfined,apparmor=unconfined")
|
|
assert get_sandbox_security_opts() == ["seccomp=unconfined", "apparmor=unconfined"]
|
|
|
|
|
|
def test_sandbox_glob_info_rebuilds_missing_path(monkeypatch) -> None:
|
|
backend = YuxiSandboxBackend(
|
|
sandbox_key="s-1",
|
|
container_name="dummy",
|
|
thread_id="t-1",
|
|
host_user_data_dir=Path("/tmp"),
|
|
)
|
|
|
|
def _fake_scan_dir_info(self, path: str):
|
|
if path.endswith("outputs"):
|
|
return [FileInfo(path=f"{path}/result.txt", is_dir=False, size=3, modified_at="")]
|
|
return []
|
|
|
|
backend._scan_dir_info = MethodType(_fake_scan_dir_info, backend)
|
|
infos = backend.glob_info("*.txt", "/home/yuxi/user-data/outputs")
|
|
assert infos
|
|
assert "path" in infos[0]
|
|
assert infos[0]["path"].endswith("/result.txt")
|
|
|
|
|
|
def test_sandbox_ls_info_ignores_malformed_json_lines() -> None:
|
|
backend = YuxiSandboxBackend(
|
|
sandbox_key="s-1",
|
|
container_name="dummy",
|
|
thread_id="t-1",
|
|
host_user_data_dir=Path("/tmp"),
|
|
)
|
|
|
|
def _fake_execute(self, command: str, *, timeout: int = 60):
|
|
return ExecuteResponse(
|
|
output="\n".join(
|
|
[
|
|
'{"path": "/home/yuxi/user-data/workspace/demo.txt", "is_dir": false, "size": 3, "modified_at": ""}',
|
|
'{"is_dir": true}',
|
|
'not-json',
|
|
]
|
|
),
|
|
exit_code=0,
|
|
truncated=False,
|
|
)
|
|
|
|
backend.execute = MethodType(_fake_execute, backend)
|
|
infos = backend.ls_info("/home/yuxi/user-data/workspace")
|
|
assert len(infos) == 1
|
|
assert infos[0]["path"] == "/home/yuxi/user-data/workspace/demo.txt"
|
|
|
|
|
|
def test_sandbox_user_data_root_lists_extra_files() -> None:
|
|
backend = YuxiSandboxBackend(
|
|
sandbox_key="s-1",
|
|
container_name="dummy",
|
|
thread_id="t-1",
|
|
host_user_data_dir=Path("/tmp"),
|
|
)
|
|
|
|
def _fake_scan_dir_info(self, path: str):
|
|
if path == "/home/yuxi/user-data":
|
|
return [
|
|
FileInfo(path="/home/yuxi/user-data/workspace", is_dir=True, size=0, modified_at=""),
|
|
FileInfo(path="/home/yuxi/user-data/uploads", is_dir=True, size=0, modified_at=""),
|
|
FileInfo(path="/home/yuxi/user-data/outputs", is_dir=True, size=0, modified_at=""),
|
|
FileInfo(path="/home/yuxi/user-data/bubble_sort.py", is_dir=False, size=24, modified_at=""),
|
|
]
|
|
return []
|
|
|
|
backend._scan_dir_info = MethodType(_fake_scan_dir_info, backend)
|
|
infos = backend.ls_info("/home/yuxi/user-data")
|
|
paths = {item["path"] for item in infos}
|
|
assert "/home/yuxi/user-data/workspace" in paths
|
|
assert "/home/yuxi/user-data/uploads" in paths
|
|
assert "/home/yuxi/user-data/outputs" in paths
|
|
assert "/home/yuxi/user-data/bubble_sort.py" in paths
|
|
|
|
|
|
def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
|
|
backend = ProvisionerSandboxBackend(thread_id="thread-1")
|
|
monkeypatch.setattr(backend, "_read_binary", lambda path, offset=0, limit=None: b"\x89PNG\r\n\x1a\n")
|
|
|
|
result = backend.read("/home/yuxi/user-data/image.png")
|
|
|
|
assert result == "Error: File '/home/yuxi/user-data/image.png' is binary and cannot be rendered as text"
|
|
|
|
|
|
def test_provisioner_read_reports_invalid_path(monkeypatch) -> None:
|
|
backend = ProvisionerSandboxBackend(thread_id="thread-1")
|
|
|
|
def _raise_invalid_path(path, offset=0, limit=None):
|
|
raise ValueError("path traversal is not allowed")
|
|
|
|
monkeypatch.setattr(backend, "_read_binary", _raise_invalid_path)
|
|
|
|
result = backend.read("../secret.txt")
|
|
|
|
assert result == "Error: Invalid path '../secret.txt': path traversal is not allowed"
|
|
|
|
|
|
def test_provisioner_download_files_distinguishes_invalid_path_from_read_failure(monkeypatch) -> None:
|
|
backend = ProvisionerSandboxBackend(thread_id="thread-1")
|
|
calls: list[str] = []
|
|
|
|
def _fake_read_binary(path, offset=0, limit=None):
|
|
calls.append(path)
|
|
if path == "/bad-path":
|
|
raise ValueError("path is required")
|
|
raise RuntimeError("sandbox read timeout")
|
|
|
|
monkeypatch.setattr(backend, "_read_binary", _fake_read_binary)
|
|
|
|
responses = backend.download_files(["/bad-path", "/read-failed"])
|
|
|
|
assert responses[0].error == "invalid_path"
|
|
assert responses[1].error == "read_failed"
|