mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-12 15:59:04 +00:00
* feat(sandbox): add BoxLite micro-VM sandbox provider (scaffold) Community SandboxProvider backed by BoxLite, a daemonless OCI-native micro-VM runtime. execute_command is wired end to end through a private asyncio loop that bridges BoxLite's async SDK to DeerFlow's sync Sandbox contract; the remaining Sandbox methods are stubbed pending approach review. Opt-in via sandbox.use (pip install boxlite); a packaged [boxlite] extra will follow. Existing providers are unchanged. Refs #3936, #3439, #3213 * feat(sandbox): implement full Sandbox contract for BoxLite backend Rename the community BoxLite integration to read as a compute backend rather than "a sandbox": module boxlite_sandbox -> boxlite, BoxliteSandboxProvider -> BoxliteProvider, BoxliteSandbox -> BoxliteBox. Implement the file surface DeerFlow's default tool path assumes -- read_file, write_file, update_file, download_file, list_dir, glob, grep -- as shell commands inside the box (cat/find/grep/chunked base64), reusing deerflow.sandbox.search and busybox-portable flags. Removes the reachable NotImplementedError regression once the provider is selected. download_file keeps the /mnt/user-data prefix + traversal guards; the provider materialises those virtual dirs on box start. Refs #3936, #3940 * fix(sandbox): resolve CI + review findings on BoxLite backend - provider: import DEFAULT_SKILLS_CONTAINER_PATH instead of the "/mnt/skills" literal (backend-unit-tests guard), and drop the redundant env-var re-resolution -- AppConfig.resolve_env_variables already resolves $VARS and raises on missing. - box: grep now passes the raw pattern to grep (-F/-E); it previously handed the re.escape'd pattern to grep -F, so a literal search of e.g. foo.bar looked for foo\.bar and never matched. - box: execute_command now calls _validate_extra_env(env), matching the Sandbox POSIX env-key contract that the local/e2b/aio sandboxes enforce. - add tests/test_boxlite_provider.py (CI-safe, no BoxLite): actionable ImportError on the lazy import, clean acquire-failure + shutdown, the traversal / download-prefix guards, and env-key rejection. Refs #3936, #3940
73 lines
2.8 KiB
Python
73 lines
2.8 KiB
Python
"""Unit tests for the BoxLite community provider.
|
|
|
|
These run in CI without BoxLite installed: they cover the lazy-import error path,
|
|
provider lifecycle, and the path-safety guards — none of which need a live box.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from deerflow.community.boxlite.box import BoxliteBox
|
|
from deerflow.community.boxlite.provider import BoxliteProvider, _import_simplebox
|
|
|
|
|
|
def _no_boxlite(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Make ``import boxlite`` raise, regardless of whether it is installed."""
|
|
monkeypatch.setitem(sys.modules, "boxlite", None)
|
|
|
|
|
|
def test_import_simplebox_missing_raises_actionable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_no_boxlite(monkeypatch)
|
|
with pytest.raises(ImportError, match=r"pip install boxlite"):
|
|
_import_simplebox()
|
|
|
|
|
|
def test_acquire_without_boxlite_raises_and_shuts_down_cleanly(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Stub config so the provider constructs without a config.yaml on disk.
|
|
stub = types.SimpleNamespace(sandbox=types.SimpleNamespace())
|
|
monkeypatch.setattr("deerflow.community.boxlite.provider.get_app_config", lambda: stub)
|
|
_no_boxlite(monkeypatch)
|
|
|
|
provider = BoxliteProvider()
|
|
try:
|
|
with pytest.raises(ImportError, match=r"pip install boxlite"):
|
|
provider.acquire("thread-1", user_id="u")
|
|
finally:
|
|
provider.shutdown() # must not raise even though no box was ever created
|
|
# Idempotent shutdown.
|
|
provider.shutdown()
|
|
|
|
|
|
def test_guard_traversal() -> None:
|
|
assert BoxliteBox._guard_traversal("/mnt/user-data/workspace/a.txt") == "/mnt/user-data/workspace/a.txt"
|
|
assert BoxliteBox._guard_traversal("relative/ok.txt") == "relative/ok.txt"
|
|
with pytest.raises(PermissionError):
|
|
BoxliteBox._guard_traversal("/mnt/user-data/../etc/passwd")
|
|
with pytest.raises(ValueError):
|
|
BoxliteBox._guard_traversal("")
|
|
|
|
|
|
def test_download_file_guards_reject_before_touching_box() -> None:
|
|
# ``run`` must never be called: both guards raise before any exec.
|
|
def _fail_run(_coro: object) -> None:
|
|
raise AssertionError("download_file must reject the path before running a command")
|
|
|
|
box = BoxliteBox("box-id", box=object(), run=_fail_run)
|
|
with pytest.raises(PermissionError):
|
|
box.download_file("/etc/passwd") # outside the /mnt/user-data prefix
|
|
with pytest.raises(PermissionError):
|
|
box.download_file("/mnt/user-data/../etc/passwd") # traversal
|
|
|
|
|
|
def test_execute_command_rejects_invalid_env_key() -> None:
|
|
def _fail_run(_coro: object) -> None:
|
|
raise AssertionError("execute_command must reject a bad env key before running")
|
|
|
|
box = BoxliteBox("box-id", box=object(), run=_fail_run)
|
|
with pytest.raises(ValueError, match=r"POSIX"):
|
|
box.execute_command("echo hi", env={"BAD KEY": "x"})
|