deer-flow/backend/tests/test_opensandbox_provider.py
wutongyuonce d8ed8160c9
fix(sandbox): stop list_dir from reporting failures as empty (#5264)
* fix(sandbox): stop list_dir from reporting failures as empty

Remote providers swallowed find/client errors as [] and 2>/dev/null
missing paths as empty stdout. ls_tool then told the agent the
directory was (empty). Raise OSError/FileNotFoundError instead so
the tool returns Error.

* fix(sandbox): list_dir raises on missing local paths and uses find -H

Empty stdout is not a missing path when find's start point is a
symlink (E2B /mnt/acp-workspace). Dereference only the start point
with find -H. LocalSandbox now raises FileNotFoundError for a
non-directory root, matching remote providers. AIO maps a missing
result.data to OSError rather than FileNotFoundError.

* fix(sandbox): group AIO list_dir find type predicates

Without parentheses, find PATH -maxdepth N -type f -o -type d applies
-type d without maxdepth and can drop files from the listing.

* fix(sandbox): distinguish list_dir command failure from missing path

Tenki, Boxlite, and OpenSandbox treated any empty find stdout as
FileNotFoundError, so a missing find binary (exit 127) or SDK error
looked like a missing directory. Raise OSError when find status is
outside (0, 1); keep FileNotFoundError for the find-ran-but-empty case.

* fix(sandbox): apply list_dir exit-status contract to AIO and E2B

Same gap as Tenki/Boxlite/OpenSandbox: empty find stdout with exit 127
was FileNotFoundError. Raise OSError when the status is outside (0, 1).

* fix(sandbox): classify list_dir by find status not head status

find | head under sh -lc reports head's exit code, so a missing find
binary (127) became FileNotFoundError. Record find's own status after
the bounded listing, treat SIGPIPE 141 as truncation success, and add
a shell-level regression test.

* test(auth): include projects permissions in /me contract pins

#5265 added projects:read/write/delete to the registered route set.
The /auth/me tests still pinned the pre-projects list, so CI failed
after merging main.

* fix(sandbox): do not treat missing list_dir marker as success

The generated script ended on `rm -f`, so process status was 0/1 even
when find's marker never landed. Both codes are in _FIND_OK, and the
parser fallback then classified an empty listing as FileNotFoundError —
the 127 misclassification this helper was meant to close.

Exit with find's status (126 if unknown). A missing marker is now
OSError unless the process status is already a non-OK failure.

* test(sandbox): emit list_dir status marker in provider fixtures

Parser now requires __DF_FIND_STATUS__ and refuses marker-less stdout.
Update AIO/Boxlite/E2B stubs and OpenSandbox/Tenki find fakes so listings
carry :0 and missing paths carry :1 with matching exit codes.

* style(sandbox): format list dir test fixture

* style(sandbox): format remote list dir helper

* docs(sandbox): keep guidance within the tested size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-09 10:12:45 +08:00

796 lines
31 KiB
Python

"""Unit tests for the optional OpenSandbox community provider.
The real ``opensandbox`` SDK is deliberately not required for this suite. The
tests pin DeerFlow's adapter contract with a small synchronous fake: lazy
dependency loading, scoped lifecycle reuse, command forwarding, native file
transport, search parsing, path guards, and terminal-session eviction.
"""
from __future__ import annotations
import asyncio
import errno
import logging
import re
import shlex
import sys
import threading
import time
import types
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
import pytest
from deerflow.community.opensandbox.provider import OpenSandboxProvider, _import_sdk
from deerflow.community.opensandbox.sandbox import OpenSandboxSandbox
@dataclass
class _Message:
text: str
@dataclass
class _Result:
text: str | None
@dataclass
class _Logs:
stdout: list[_Message] = field(default_factory=list)
stderr: list[_Message] = field(default_factory=list)
@dataclass
class _Execution:
exit_code: int | None = 0
logs: _Logs = field(default_factory=_Logs)
result: list[_Result] = field(default_factory=list)
def _execution(*, stdout: tuple[str, ...] = (), stderr: tuple[str, ...] = (), result: tuple[str, ...] = (), exit_code: int | None = 0) -> _Execution:
return _Execution(
exit_code=exit_code,
logs=_Logs(
stdout=[_Message(text) for text in stdout],
stderr=[_Message(text) for text in stderr],
),
result=[_Result(text) for text in result],
)
@dataclass
class _FakeRunCommandOpts:
background: bool = False
working_directory: str | None = None
timeout: timedelta | None = None
uid: int | None = None
gid: int | None = None
envs: dict[str, str] | None = None
class _FakeFiles:
def __init__(self, owner: _FakeRemote) -> None:
self._owner = owner
self.calls: list[tuple[str, str]] = []
def _guard(self) -> None:
if self._owner.file_error is not None:
raise self._owner.file_error
def read_file(self, path: str, *, encoding: str = "utf-8") -> str:
self._guard()
self.calls.append(("read_file", path))
if path not in self._owner.file_data:
raise FileNotFoundError(path)
return self._owner.file_data[path].decode(encoding, errors="replace")
def read_bytes(self, path: str) -> bytes:
self._guard()
self.calls.append(("read_bytes", path))
if path not in self._owner.file_data:
raise FileNotFoundError(path)
return self._owner.file_data[path]
def read_bytes_stream(self, path: str):
self._guard()
self.calls.append(("read_bytes_stream", path))
if path not in self._owner.file_data:
raise FileNotFoundError(path)
data = self._owner.file_data[path]
try:
yield from (data[index : index + 3] for index in range(0, len(data), 3))
finally:
self._owner.stream_closed = True
def write_file(self, path: str, data: str | bytes, *, mode: int = 755) -> None:
self._guard()
self.calls.append(("write_file", path))
self._owner.file_data[path] = data.encode() if isinstance(data, str) else bytes(data)
parent = path.rsplit("/", 1)[0]
while parent:
self._owner.directories.add(parent)
parent = parent.rsplit("/", 1)[0]
class _FakeCommands:
def __init__(self, owner: _FakeRemote) -> None:
self._owner = owner
self.calls: list[tuple[str, _FakeRunCommandOpts | None]] = []
def run(self, command: str, *, opts: _FakeRunCommandOpts | None = None) -> _Execution:
self.calls.append((command, opts))
if self._owner.command_error is not None:
raise self._owner.command_error
if command.startswith("mkdir -p /mnt/user-data/"):
return _execution(stderr=("bootstrap failed",), exit_code=self._owner.bootstrap_exit_code)
if command == "true":
return _execution(exit_code=self._owner.health_exit_code)
if command == "mixed-output":
return _execution(stdout=("out-1", "out-2"), stderr=("err-1",), exit_code=7)
if command == "result-output":
return _execution(stdout=("stdout",), result=("result",), stderr=("stderr",))
if command == "silent-failure":
return _execution(exit_code=9)
if command == "missing-complete":
return _execution(stderr=("stream ended",), exit_code=None)
if command.startswith("find ") or "find -H " in command:
return self._find(command)
if command.startswith(("grep ", "{ grep ")):
return self._grep(command)
return _execution()
def _find(self, command: str) -> _Execution:
match = re.search(r"(?:^|[\s;{])find(?:\s+-[HLP])*\s+(\S+)", command)
root = (match.group(1).strip("'\"") if match else "").rstrip("/") or "/"
include_dirs = "-type d" in command
paths = list(self._owner.file_data)
if include_dirs:
paths.extend(self._owner.directories)
matches = sorted(path for path in set(paths) if path == root or path.startswith(f"{root}/"))
if "__DF_FIND_STATUS__:" in command:
status = 0 if matches else 1
marker = f"__DF_FIND_STATUS__:{status}"
stdout = (*matches, "", marker) if matches else ("", marker)
return _execution(stdout=stdout, exit_code=status)
return _execution(stdout=tuple(matches))
def _grep(self, command: str) -> _Execution:
tokens = shlex.split(command)
pattern = tokens[tokens.index("-e") + 1]
root = tokens[tokens.index("-e") + 2].rstrip("/")
flags = 0 if "-i" not in tokens else re.IGNORECASE
literal = "-F" in tokens
rows: list[str] = []
for path, data in sorted(self._owner.file_data.items()):
if path != root and not path.startswith(f"{root}/"):
continue
for line_number, line in enumerate(data.decode(errors="replace").splitlines(), start=1):
matched = pattern.lower() in line.lower() if literal and flags else pattern in line if literal else re.search(pattern, line, flags) is not None
if matched:
rows.append(f"{path}:{line_number}:{line}")
if self._owner.grep_duplicate_rows:
rows.extend(rows)
return _execution(stdout=tuple(rows))
class _FakeRemote:
def __init__(self, remote_id: str, *, bootstrap_exit_code: int | None = 0) -> None:
self.id = remote_id
self.bootstrap_exit_code = bootstrap_exit_code
self.health_exit_code: int | None = 0
self.command_error: Exception | None = None
self.file_error: Exception | None = None
self.renew_error: Exception | None = None
self.renew_calls: list[timedelta] = []
self.destroy_calls = 0
self.file_data: dict[str, bytes] = {}
self.directories: set[str] = set()
self.stream_closed = False
self.grep_duplicate_rows = False
self.commands = _FakeCommands(self)
self.files = _FakeFiles(self)
def renew(self, timeout: timedelta) -> None:
self.renew_calls.append(timeout)
if self.renew_error is not None:
raise self.renew_error
def destroy(self) -> None:
self.destroy_calls += 1
class _FakeSandboxClass:
def __init__(self, remote_factory=None) -> None:
self.remote_factory = remote_factory
self.create_calls: list[dict[str, Any]] = []
self.remotes: list[_FakeRemote] = []
def create(self, image: str, **kwargs: Any) -> _FakeRemote:
self.create_calls.append({"image": image, **kwargs})
index = len(self.remotes) + 1
remote = self.remote_factory(index) if self.remote_factory is not None else _FakeRemote(f"remote-{index}")
self.remotes.append(remote)
return remote
class _FakeConnectionConfig:
def __init__(self, **kwargs: Any) -> None:
self.kwargs = kwargs
class _TerminalApiError(RuntimeError):
def __init__(self, message: str, status_code: int = 404) -> None:
super().__init__(message)
self.status_code = status_code
def _stub_config(attrs: dict[str, Any] | None = None) -> types.SimpleNamespace:
values = {"idle_timeout": 0, **(attrs or {})}
return types.SimpleNamespace(sandbox=types.SimpleNamespace(**values))
def _install(monkeypatch: pytest.MonkeyPatch, *, sdk: _FakeSandboxClass | None = None, config: dict[str, Any] | None = None) -> tuple[OpenSandboxProvider, _FakeSandboxClass]:
fake_sdk = sdk or _FakeSandboxClass()
monkeypatch.setattr("deerflow.community.opensandbox.provider.get_app_config", lambda: _stub_config(config))
monkeypatch.setattr(
"deerflow.community.opensandbox.provider._import_sdk",
lambda: (fake_sdk, _FakeConnectionConfig, _FakeRunCommandOpts),
)
return OpenSandboxProvider(), fake_sdk
def _box(
remote: _FakeRemote,
*,
on_terminal_failure=None,
default_env=None,
sandbox_timeout: timedelta | None = None,
default_command_timeout: float = 600,
) -> OpenSandboxSandbox:
return OpenSandboxSandbox(
"sandbox-id",
remote,
run_command_opts_cls=_FakeRunCommandOpts,
default_env=default_env,
sandbox_timeout=sandbox_timeout,
default_command_timeout=default_command_timeout,
on_terminal_failure=on_terminal_failure,
)
def test_missing_sdk_has_actionable_error(monkeypatch: pytest.MonkeyPatch) -> None:
for module_name in (
"opensandbox",
"opensandbox.sync",
"opensandbox.config.connection_sync",
"opensandbox.models.execd",
):
monkeypatch.setitem(sys.modules, module_name, None)
with pytest.raises(ImportError, match=r"deerflow-harness\[opensandbox\]"):
_import_sdk()
def test_provider_defers_sdk_import_until_acquire(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("deerflow.community.opensandbox.provider.get_app_config", lambda: _stub_config())
calls = 0
def fail_if_called():
nonlocal calls
calls += 1
raise AssertionError("SDK imported")
monkeypatch.setattr("deerflow.community.opensandbox.provider._import_sdk", fail_if_called)
provider = OpenSandboxProvider()
assert calls == 0
with pytest.raises(AssertionError, match="SDK imported"):
provider.acquire("thread", user_id="user")
assert calls == 1
provider.shutdown()
def test_create_passes_connection_lifetime_scope_and_environment(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
monkeypatch.setenv("OPEN_SANDBOX_TEST_VALUE", "resolved")
monkeypatch.delenv("OPEN_SANDBOX_ABSENT_VALUE", raising=False)
provider, sdk = _install(
monkeypatch,
config={
"image": "python:3.12",
"api_key": "secret",
"domain": "sandbox.example",
"protocol": "https",
"request_timeout": 12,
"ready_timeout": 18,
"sandbox_timeout": 7200,
"use_server_proxy": True,
"environment": {
"BASE": "1",
"FROM_ENV": "$OPEN_SANDBOX_TEST_VALUE",
"MISSING_ENV": "$OPEN_SANDBOX_ABSENT_VALUE",
},
},
)
provider.acquire("thread-1", user_id="user-1")
call = sdk.create_calls[0]
assert call["image"] == "python:3.12"
assert call["timeout"] == timedelta(seconds=7200)
assert call["ready_timeout"] == timedelta(seconds=18)
assert call["env"] == {"BASE": "1", "FROM_ENV": "resolved", "MISSING_ENV": ""}
assert call["metadata"] == {
"deer_flow_provider": "opensandbox",
"deer_flow_thread": "thread-1",
"deer_flow_user": "user-1",
}
assert call["connection_config"].kwargs == {
"api_key": "secret",
"domain": "sandbox.example",
"protocol": "https",
"request_timeout": timedelta(seconds=12),
"use_server_proxy": True,
}
assert "unauthenticated localhost:8080" not in caplog.text
assert "remote OpenSandbox domain uses HTTP" not in caplog.text
provider.shutdown()
def test_missing_connection_config_warns_about_sdk_default(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
monkeypatch.delenv("OPEN_SANDBOX_API_KEY", raising=False)
monkeypatch.delenv("OPEN_SANDBOX_DOMAIN", raising=False)
provider, _ = _install(monkeypatch)
assert any(record.levelno == logging.WARNING and "unauthenticated localhost:8080" in record.getMessage() for record in caplog.records)
assert "remote OpenSandbox domain uses HTTP" not in caplog.text
provider.shutdown()
def test_remote_http_connection_warns_without_logging_api_key(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG, logger="deerflow.community.opensandbox.provider")
provider, _ = _install(
monkeypatch,
config={"api_key": "not-a-real-secret", "domain": "sandbox.example", "protocol": "http"},
)
assert any(record.levelno == logging.WARNING and "remote OpenSandbox domain uses HTTP" in record.getMessage() for record in caplog.records)
assert "not-a-real-secret" not in caplog.text
provider.shutdown()
def test_null_sandbox_timeout_uses_default(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch, config={"sandbox_timeout": None})
provider.acquire("thread-1", user_id="user-1")
assert sdk.create_calls[0]["timeout"] == timedelta(hours=4)
provider.shutdown()
@pytest.mark.parametrize("exit_code", [17, None])
def test_bootstrap_failure_destroys_created_remote(monkeypatch: pytest.MonkeyPatch, exit_code: int | None) -> None:
sdk = _FakeSandboxClass(lambda index: _FakeRemote(f"remote-{index}", bootstrap_exit_code=exit_code))
provider, _ = _install(monkeypatch, sdk=sdk)
with pytest.raises(RuntimeError, match="bootstrap"):
provider.acquire("thread-1", user_id="user-1")
assert sdk.remotes[0].destroy_calls == 1
assert provider._sandboxes == {}
assert provider._warm_pool == {}
provider.shutdown()
def test_scope_reuse_and_user_thread_isolation(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
first = provider.acquire("thread-1", user_id="user-1")
assert provider.acquire("thread-1", user_id="user-1") == first
other_user = provider.acquire("thread-1", user_id="user-2")
other_thread = provider.acquire("thread-2", user_id="user-1")
assert len({first, other_user, other_thread}) == 3
assert len(sdk.create_calls) == 3
assert sdk.remotes[0].renew_calls == [timedelta(hours=4)]
assert len({id(call["connection_config"]) for call in sdk.create_calls}) == 3
provider.shutdown()
def test_active_scope_terminal_renewal_failure_rebuilds_in_same_acquire(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
sandbox_id = provider.acquire("thread-1", user_id="user-1")
sdk.remotes[0].renew_error = _TerminalApiError("sandbox expired", status_code=410)
assert provider.acquire("thread-1", user_id="user-1") == sandbox_id
assert len(sdk.create_calls) == 2
assert sdk.remotes[0].destroy_calls == 1
replacement = provider.get(sandbox_id)
assert replacement is not None and replacement.remote_id == "remote-2"
provider.shutdown()
def test_active_scope_non_terminal_renewal_failure_is_not_hidden(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
sandbox_id = provider.acquire("thread-1", user_id="user-1")
sdk.remotes[0].renew_error = RuntimeError("temporary management failure")
with pytest.raises(RuntimeError, match="temporary management failure"):
provider.acquire("thread-1", user_id="user-1")
assert len(sdk.create_calls) == 1
assert sdk.remotes[0].destroy_calls == 0
assert provider.get(sandbox_id) is not None
sdk.remotes[0].renew_error = None
provider.shutdown()
def test_release_and_same_scope_warm_reclaim(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
sandbox_id = provider.acquire("thread-1", user_id="user-1")
provider.release(sandbox_id)
assert sandbox_id not in provider._sandboxes
assert sandbox_id in provider._warm_pool
assert provider.acquire("thread-1", user_id="user-1") == sandbox_id
assert len(sdk.create_calls) == 1
assert sdk.remotes[0].commands.calls[-1][0] == "true"
provider.shutdown()
@pytest.mark.parametrize("exit_code", [1, None])
def test_unhealthy_warm_entry_is_destroyed_and_replaced(monkeypatch: pytest.MonkeyPatch, exit_code: int | None) -> None:
provider, sdk = _install(monkeypatch)
sandbox_id = provider.acquire("thread-1", user_id="user-1")
provider.release(sandbox_id)
sdk.remotes[0].health_exit_code = exit_code
assert provider.acquire("thread-1", user_id="user-1") == sandbox_id
assert sdk.remotes[0].destroy_calls == 1
assert len(sdk.create_calls) == 2
provider.shutdown()
def test_reset_parks_active_and_shutdown_destroys_active_and_warm(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
active_id = provider.acquire("active", user_id="user")
warm_id = provider.acquire("warm", user_id="user")
provider.release(warm_id)
provider.reset()
assert provider._sandboxes == {}
assert {active_id, warm_id} == set(provider._warm_pool)
provider.shutdown()
provider.shutdown()
assert [remote.destroy_calls for remote in sdk.remotes] == [1, 1]
assert provider._sandboxes == {} and provider._warm_pool == {}
def test_shutdown_stops_idle_reaper(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(OpenSandboxProvider, "IDLE_CHECK_INTERVAL", 0.01)
provider, _ = _install(monkeypatch, config={"idle_timeout": 60})
checker = provider._idle_checker_thread
provider.shutdown()
assert provider._idle_checker_stop.is_set()
assert checker is not None and not checker.is_alive()
def test_execute_forwards_env_timeout_and_combines_streams() -> None:
remote = _FakeRemote("remote")
box = _box(remote, default_env={"BASE": "1"})
# A nonzero exit with non-empty output keeps the authoritative marker
# (LocalSandbox parity) instead of losing the failure.
assert box.execute_command("mixed-output", env={"EXTRA": "2"}, timeout=5) == "out-1\nout-2\nerr-1\nExit Code: 7"
_, opts = remote.commands.calls[-1]
assert opts is not None
assert opts.envs == {"BASE": "1", "EXTRA": "2"}
assert opts.timeout == timedelta(seconds=5)
assert box.execute_command("result-output") == "stdout\nresult\nstderr"
assert box.execute_command("silent-failure") == "Command exited with code 9"
assert box.execute_command("missing-complete") == "Error: OpenSandbox command completed without an exit code: stream ended"
def test_operations_renew_remote_lifetime_and_bound_default_commands() -> None:
remote = _FakeRemote("remote")
remote.file_data["/mnt/user-data/workspace/note.txt"] = b"note"
box = _box(
remote,
sandbox_timeout=timedelta(seconds=60),
default_command_timeout=120,
)
assert box.execute_command("true") == "(no output)"
_, opts = remote.commands.calls[-1]
assert opts is not None and opts.timeout == timedelta(seconds=120)
assert remote.renew_calls[-1] == timedelta(seconds=150)
assert box.read_file("/mnt/user-data/workspace/note.txt") == "note"
assert remote.renew_calls[-1] == timedelta(seconds=60)
def test_short_operation_cannot_shorten_in_flight_command_renewal() -> None:
remote = _FakeRemote("remote")
remote.file_data["/mnt/user-data/workspace/note.txt"] = b"note"
box = _box(
remote,
sandbox_timeout=timedelta(seconds=60),
default_command_timeout=120,
)
command_started = threading.Event()
finish_command = threading.Event()
file_started = threading.Event()
short_renew_attempted = threading.Event()
original_run = remote.commands.run
original_renew = remote.renew
def blocking_run(command: str, *, opts: _FakeRunCommandOpts | None = None) -> _Execution:
command_started.set()
assert finish_command.wait(timeout=2)
return original_run(command, opts=opts)
remote.commands.run = blocking_run # type: ignore[method-assign]
def observed_renew(timeout: timedelta) -> None:
if timeout == timedelta(seconds=60):
short_renew_attempted.set()
original_renew(timeout)
remote.renew = observed_renew # type: ignore[method-assign]
command_result: list[str] = []
file_result: list[str] = []
command_thread = threading.Thread(target=lambda: command_result.append(box.execute_command("long-command")))
def read_file() -> None:
file_started.set()
file_result.append(box.read_file("/mnt/user-data/workspace/note.txt"))
file_thread = threading.Thread(target=read_file)
command_thread.start()
assert command_started.wait(timeout=2)
file_thread.start()
assert file_started.wait(timeout=2)
assert not short_renew_attempted.wait(timeout=0.1)
assert file_thread.is_alive()
assert remote.renew_calls == [timedelta(seconds=150)]
finish_command.set()
command_thread.join(timeout=2)
file_thread.join(timeout=2)
assert command_result == ["(no output)"]
assert file_result == ["note"]
assert remote.renew_calls == [timedelta(seconds=150), timedelta(seconds=60)]
def test_explicit_cleanup_mode_skips_renewal() -> None:
remote = _FakeRemote("remote")
box = _box(remote, sandbox_timeout=None)
assert box.execute_command("true") == "(no output)"
assert remote.renew_calls == []
@pytest.mark.parametrize("timeout", [0, -1])
def test_execute_rejects_unbounded_or_negative_timeout(timeout: float) -> None:
remote = _FakeRemote("remote")
box = _box(remote)
assert box.execute_command("true", timeout=timeout).startswith("Error: timeout must be positive")
assert remote.commands.calls == []
def test_execute_rejects_invalid_environment_key() -> None:
box = _box(_FakeRemote("remote"))
with pytest.raises(ValueError, match="POSIX"):
box.execute_command("true", env={"BAD KEY": "x"})
def test_text_binary_append_and_line_ranges() -> None:
box = _box(_FakeRemote("remote"))
path = "/mnt/user-data/workspace/note.txt"
box.write_file(path, "one\ntwo\nthree")
assert box.read_file(path, 2, 3) == "two\nthree"
box.write_file(path, "\nfour", append=True)
assert box.read_file(path) == "one\ntwo\nthree\nfour"
binary_path = "/mnt/user-data/outputs/blob.bin"
box.update_file(binary_path, b"\x00\xffpayload")
assert box.download_file(binary_path) == b"\x00\xffpayload"
def test_download_rejects_oversize_stream(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("deerflow.community.opensandbox.sandbox._MAX_DOWNLOAD_SIZE", 4)
remote = _FakeRemote("remote")
path = "/mnt/user-data/outputs/oversize.bin"
remote.file_data[path] = b"12345"
with pytest.raises(OSError) as excinfo:
_box(remote).download_file(path)
assert excinfo.value.errno == errno.EFBIG
assert remote.files.calls == [("read_bytes_stream", path)]
assert remote.stream_closed
def test_list_glob_and_grep_return_virtual_paths() -> None:
remote = _FakeRemote("remote")
remote.grep_duplicate_rows = True
box = _box(remote)
box.write_file("/mnt/user-data/workspace/src/a.py", "Needle here\nsecond\n")
box.write_file("/mnt/user-data/workspace/vendor/b.py", "needle there\n")
assert box.list_dir("/mnt/user-data/workspace") == [
"/mnt/user-data/workspace",
"/mnt/user-data/workspace/src",
"/mnt/user-data/workspace/src/a.py",
"/mnt/user-data/workspace/vendor",
"/mnt/user-data/workspace/vendor/b.py",
]
found, truncated = box.glob("/mnt/user-data/workspace", "src/*.py")
assert found == ["/mnt/user-data/workspace/src/a.py"]
assert truncated is False
matches, truncated = box.grep("/mnt/user-data/workspace", "needle", glob="src/*.py", literal=True)
assert [(match.path, match.line_number, match.line) for match in matches] == [("/mnt/user-data/workspace/src/a.py", 1, "Needle here")]
assert truncated is False
grep_tokens = shlex.split(remote.commands.calls[-1][0])
assert "--include=*.py" in grep_tokens
assert "-m100" in grep_tokens
box.grep("/mnt/user-data/workspace", "needle", glob="src/*.py; echo injected", literal=True)
unsafe_glob_tokens = shlex.split(remote.commands.calls[-1][0])
assert "--include=*.py; echo injected" in unsafe_glob_tokens
assert unsafe_glob_tokens.count("grep") == 2
assert 'status=$?; [ "$status" -eq 2 ] &&' in remote.commands.calls[-1][0]
fallback_tokens = unsafe_glob_tokens[unsafe_glob_tokens.index("grep", 2) :]
assert not any(token.startswith("--include=") or token.startswith("-m") for token in fallback_tokens)
def test_search_rejects_non_positive_limits_and_negative_depth() -> None:
remote = _FakeRemote("remote")
box = _box(remote)
with pytest.raises(ValueError, match="max_depth"):
box.list_dir("/mnt/user-data/workspace", max_depth=-1)
with pytest.raises(ValueError, match="max_results"):
box.glob("/mnt/user-data/workspace", "*", max_results=0)
with pytest.raises(ValueError, match="max_results"):
box.grep("/mnt/user-data/workspace", "text", max_results=-1)
assert remote.commands.calls == []
@pytest.mark.parametrize(
"path",
["", "relative.txt", "/mnt/user-data/../etc/passwd", "\\mnt\\user-data\\..\\etc\\passwd"],
)
def test_path_guard_rejects_unsafe_paths(path: str) -> None:
box = _box(_FakeRemote("remote"))
with pytest.raises((ValueError, PermissionError)):
box.read_file(path)
def test_download_rejects_outside_virtual_prefix_before_sdk_call() -> None:
remote = _FakeRemote("remote")
box = _box(remote)
with pytest.raises(PermissionError):
box.download_file("/etc/passwd")
assert remote.files.calls == []
def test_missing_file_api_404_does_not_evict_sandbox() -> None:
invalidated: list[tuple[str, str]] = []
remote = _FakeRemote("remote")
remote.file_error = _TerminalApiError("file not found", status_code=404)
box = _box(remote, on_terminal_failure=lambda sandbox_id, reason: invalidated.append((sandbox_id, reason)))
assert box.read_file("/mnt/user-data/workspace/missing.txt").startswith("Error:")
assert invalidated == []
def test_terminal_renewal_failure_evicts_before_operation() -> None:
invalidated: list[tuple[str, str]] = []
remote = _FakeRemote("remote")
remote.renew_error = _TerminalApiError("sandbox expired")
box = _box(
remote,
sandbox_timeout=timedelta(minutes=5),
on_terminal_failure=lambda sandbox_id, reason: invalidated.append((sandbox_id, reason)),
)
assert box.execute_command("true") == "Error: sandbox expired"
assert invalidated == [("sandbox-id", "sandbox expired")]
assert remote.commands.calls == []
def test_terminal_error_evicts_active_sandbox(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
sandbox_id = provider.acquire("thread-1", user_id="user-1")
box = provider.get(sandbox_id)
assert box is not None
sdk.remotes[0].command_error = _TerminalApiError("sandbox is gone")
assert box.execute_command("true") == "Error: sandbox is gone"
assert provider.get(sandbox_id) is None
assert sdk.remotes[0].destroy_calls == 1
provider.shutdown()
def test_concurrent_same_scope_acquire_creates_once(monkeypatch: pytest.MonkeyPatch) -> None:
provider, sdk = _install(monkeypatch)
original_create = sdk.create
started = threading.Event()
def slow_create(image: str, **kwargs: Any) -> _FakeRemote:
started.set()
time.sleep(0.05)
return original_create(image, **kwargs)
sdk.create = slow_create # type: ignore[method-assign]
results: list[str] = []
first = threading.Thread(target=lambda: results.append(provider.acquire("thread", user_id="user")))
second = threading.Thread(target=lambda: results.append(provider.acquire("thread", user_id="user")))
first.start()
assert started.wait(timeout=2)
second.start()
first.join(timeout=2)
second.join(timeout=2)
assert len(results) == 2 and results[0] == results[1]
assert len(sdk.create_calls) == 1
provider.shutdown()
@pytest.mark.asyncio
async def test_cancelled_acquire_async_serializes_retry_behind_abandoned_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""A cancelled acquire_async abandons the body thread, not the lock.
Regression test (#4741): the serializer hold must follow the abandoned
body to completion, so a retry for the same scope serializes behind it
instead of overlapping it and creating a duplicate, untracked remote.
"""
provider, sdk = _install(monkeypatch)
started = threading.Event()
release = threading.Event()
original_create = sdk.create
def blocking_create(image: str, **kwargs: Any) -> _FakeRemote:
started.set()
assert release.wait(timeout=10)
return original_create(image, **kwargs)
sdk.create = blocking_create # type: ignore[method-assign]
first = asyncio.create_task(provider.acquire_async("thread", user_id="user"))
assert await asyncio.to_thread(started.wait, 10) # body is blocked inside create()
first.cancel()
with pytest.raises(asyncio.CancelledError):
await first
release.set() # abandoned body runs to completion and registers
second = await provider.acquire_async("thread", user_id="user")
expected_id = provider._sandbox_id("thread", "user")
assert len(sdk.create_calls) == 1 # no duplicate remote sandbox
assert second == expected_id
assert provider._thread_sandboxes[provider._thread_key("thread", "user")] == expected_id
provider.shutdown()
def test_sandbox_id_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
assert OpenSandboxProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")
assert OpenSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1")
def test_list_dir_raises_when_find_returns_no_entries() -> None:
remote = _FakeRemote("remote")
box = _box(remote)
with pytest.raises(FileNotFoundError):
box.list_dir("/mnt/user-data/missing")
def test_list_dir_raises_oserror_when_find_exit_is_not_missing_path() -> None:
# find exit 1 is "start point absent"; 127 (no binary) must not look missing.
box = _box(_FakeRemote("remote"))
box._run = lambda *args, **kwargs: _execution(exit_code=127)
with pytest.raises(OSError, match="exited with code 127"):
box.list_dir("/mnt/user-data/workspace")
def test_list_dir_and_glob_preserve_trailing_space_in_filename() -> None:
# "notes.txt " (trailing space) is a legal Linux filename; find prints it
# verbatim, one entry per line, so a per-line strip() corrupts the name.
remote = _FakeRemote("remote")
box = _box(remote)
box.write_file("/mnt/user-data/workspace/notes.txt ", "payload")
assert "/mnt/user-data/workspace/notes.txt " in box.list_dir("/mnt/user-data/workspace")
found, truncated = box.glob("/mnt/user-data/workspace", "notes*")
assert found == ["/mnt/user-data/workspace/notes.txt "]
assert truncated is False