deer-flow/backend/tests/test_opensandbox_provider.py
Zeren Wang a06a6fed7e
feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2) (#5109)
* feat(harness): deterministic acceptance checklist for subagent delegations (RFC #4651, layer 2)

PR4 of RFC #4651: check lead-supplied acceptance_criteria in code when a
subagent completes, so objectively checkable requirements can never be
silently passed by a self-report.

- subagents/acceptance_checks.py: deterministic leaf families —
  file:<path> exists|non-empty and file_written:<path> read through
  read_current_file_content scoped to the shared thread workspace; the
  read uses the sandbox-native virtual path form (the local read
  validator and provider mount tables resolve /mnt/user-data/... paths,
  not host paths); the scope decision canonicalizes with realpath on the
  local sandbox so workspace symlinks cannot escape into uploads; a
  remote provider's "Error: ..." return string is normalized to a
  failed check (provider-typed via is_local_sandbox); a
  UnicodeDecodeError marks a binary deliverable as existing and
  non-empty; out-of-scope paths degrade to UNVERIFIED.
  tests_passed:<command> anchors to a matching recorded bash execution
  with status=success and a test-summary shape; matching is
  shell-structure aware with control-flow attribution (span must end at
  the last segment with provable execution), negating-option values are
  ineligible evidence and a target negated anywhere in the command
  degrades the match, extra flags must be selection-preserving, extra
  positionals widen only after a path-scoped criterion, truncated
  commands degrade via command_truncated, the summary shape is read
  only from output attributable to the matched segment (preceding
  segments provably silent by invocation form), and pass shapes require
  a nonzero passed count. Criterion text is neutralized with
  neutralize_untrusted_tags before storage/rendering. Anything else
  renders UNVERIFIED, never silently passed.
- executor: accumulate bounded bash command/output evidence per streamed
  chunk (merged by tool_call_id, newest-capped) so subagent
  summarization compacting earlier messages cannot erase a recorded
  execution; the recorded status is the actual shell exit status parsed
  from the output's exit marker (signed codes included; the remote
  Command exited with code N form is accepted only as the whole trimmed
  output), falling back to deerflow_tool_meta only when no marker
  exists.
- sandbox providers: e2b/opensandbox/tenki/boxlite append the
  LocalSandbox-style "Exit Code: N" marker on nonzero exit even with
  non-empty output; aio propagates the SDK's structured exit_code on
  both exec paths the same way; local timeouts append Exit Code: 124;
  and _truncate_bash_output always preserves a trailing exit marker
  (signed included) inside its budget, with a 32-char floor raising any
  smaller configured limit, so the actual shell outcome always survives
  in the output text.
- task_tool: run the checklist offloaded (asyncio.to_thread) on the
  completed branch, failure-isolated; stamp the verdict into result
  metadata and render the per-criterion section into the model-visible
  result text.
- status contract: additive subagent_acceptance_verdict transport with
  read-side structural validation.
- delegation ledger: entry carries the verdict and renders a compact
  acceptance segment; gateway strips caller-forged verdicts from both
  ledger entries and message metadata, like the citation verdict.
- blocking-IO anchor pins the offload (teeth proven red->green); leaf
  read errors catch only OSError/SandboxError so unexpected errors reach
  the task-tool-level isolation instead of being mislabeled.

* fix(harness): close acceptance evidence gaps from review (RFC #4651 PR4)

- negating options: overlap with a matched criterion target is now
  checked by path/nodeid prefix, not exact token equality — excluding a
  sub-path of the criterion's selection (pytest tests --deselect
  tests/unit/test_auth.py) degrades to UNVERIFIED instead of holds
- output attribution: any redirection token in the matched final segment
  makes the recorded tail non-attributable (> / >> / 2> are word
  characters to the parser, so redirection was invisible to the matcher)
- silent-source allowlist narrowed from any *activate suffix to the
  */bin/activate shape
- status_contract docstring: restore the shared-fixture sentence and
  note subagent_acceptance_verdict is deliberately outside the fixture
- executor: update_bash_executions publishes [] (stream carried no
  bash-family calls) instead of collapsing it into None, mirroring
  update_tool_receipts

* fix(harness): close acceptance residual gaps from re-review (RFC #4651 PR4)

- tests_passed: add error outcomes to the fail shapes — "4 passed, 1 error"
  and pytest's "ERROR <nodeid>" short summary no longer satisfy the pass
  shape when the exit status is swallowed (|| true) or absent; zero-error
  counts stay clean.
- file leaves: bound the deliverable read — a "wc -c" shell size probe
  answers files above 50k bytes without loading ~2x their size, honoring
  the host-bash kill switch and falling back to the full read on any
  non-integer rendering, so verdicts never get less sound.
- executor: record the exit marker text as status_marker on harvested bash
  evidence; the leaf detail now reports the marker actually seen instead of
  asserting a failure indistinguishable from the command's own trailing text.
- extend the blocking-IO anchor to drive the probe branch inside the
  offload; teeth re-verified red->green.

* fix(harness): close acceptance forgery and bound gaps from P2 re-review (RFC #4651 PR4)

- file leaves: never read unbounded — size is established first (os.stat on
  the validated local host path, so the host-bash-disabled configuration
  needs no shell; a guarded wc -c on remote providers that renders
  missing/unreadable in its own words). Above the 50k cap the leaf answers
  from the size alone, at/below it the full read runs, and an
  unestablishable size degrades to UNVERIFIED instead of an unlimited
  fallback read.
- output attribution: source/. prefixes are never provably silent — a
  crafted */bin/activate path shape says nothing about what the script
  prints, so sourced segments can no longer lend a passing summary.
- executable identity: an explicitly path-spelled criterion now requires
  the same normalized executable path; the basename rule stays only for
  deliberately bare criterion commands.

* fix(harness): run acceptance size probe outside subagent-controlled state (RFC #4651 PR4)

- remote probe no longer runs in the sandbox's persistent shell: a fresh
  env -i /bin/sh with absolute-path stat/realpath (poisoned functions,
  aliases, PATH, exported functions, IFS, locale cannot steer it), plus a
  marker env routing AIO onto a fresh per-call bash.exec session.
- metadata-only: stat never opens content, so a FIFO deliverable cannot
  block the parent for the provider's idle timeout; non-regular files
  (fifo/dir/symlink) degrade to UNVERIFIED.
- containment canonicalized against the literal mount root: a
  final-component symlink or a swapped parent directory (root included)
  cannot redirect the check outside shared storage; unprovable layouts
  degrade to UNVERIFIED.

* fix(harness): canonicalize probe containment against the canonical mount root (RFC #4651 PR4)

Literal-root equality made every remote file leaf permanently UNVERIFIED
on e2b and Tenki, which realize /mnt/user-data as a symlink to the home
dir by default (e2b bootstrap 'sudo ln -sfn', Tenki best-effort symlink).
Containment now compares the file's realpath against the mount root's
realpath — exactly what the provider's own read path resolves, so probe
and read-back stay consistent; final-component symlinks stay rejected by
the non-dereferencing stat, and an intermediate dir-link escape under a
sane root still lands ESCAPED. The inner script is a module constant and
the suite now executes the composed probe for real against on-disk
layouts (real dir, symlinked prefix, final symlink, fifo, missing,
dir-link escape), which the canned-output stub could not see.

* fix(harness): close bare-criterion negation and CDPATH summary channels (RFC #4651 PR4)

- matching: a criterion with no positional selection target (bare pytest,
  make test) stands for the runner's default selection, so ANY negating
  option (--ignore/--deselect/...) makes the recorded run a different
  selection — unprovable. The overlap guard only sees consumed criterion
  tokens, which a bare criterion does not have; scoped criteria keep the
  unrelated-exclusion behavior.
- attribution: cd is no longer blanket-silent — CDPATH makes cd print the
  resolved (subagent-chosen) destination and the pass shapes match as
  substrings, so one mkdir 'all tests passed' plus an export minted a pass
  for any quiet command. A cd argument or CDPATH= value (export or leading
  assignment) carrying any summary shape makes the segment non-silent;
  shape-free cd dir wrappers keep matching.
- docs: _truncate_bash_output states the effective 32-char floor (the
  guarantee previously read as an unconditional max_chars bound).

* fix(harness): close env-assignment and expansion channels in acceptance matching (RFC #4651 PR4)

Self-audit in the shape of the last review rounds — channels the matcher
classified as accounted-for that can change what runs, narrow the
selection, or lend the summary text:

- env assignments are no longer blanket-stripped: only an allowlist of
  inert display/CI knobs (CI, NO_COLOR, PY_COLORS, ...) may prefix a
  matched span, and a non-allowlisted assignment in any preceding segment
  (pure-assignment or export NAME=) is state pollution — PATH redirects
  the executable, LD_PRELOAD/PYTHONPATH/NODE_OPTIONS inject code,
  PYTEST_ADDOPTS/GOFLAGS/MAKEFILES inject selection-changing inputs,
  BASH_ENV runs arbitrary shell startup. All degrade to unprovable.
- runtime expansions: any span token carrying /$( )/backticks, any
  negating-option value carrying an expansion or glob (unknown excluded
  set), and any extra executed token carrying glob metacharacters
  (crafted option-looking filenames narrow invisibly) are unprovable.
  Criterion-side globs stay self-consistent (literal match).
- cd: an argument carrying a runtime expansion or glob is non-silent
  (unknown destination, unknown print); CDPATH= assignments are now
  handled as state pollution at the match layer, subsuming the
  value-shape special case.

* fix(harness): persistent-shell evidence, exact env sets, option-arity scoping (RFC #4651 PR4)

- tests_passed: on a persistent-shell provider (new
  Sandbox.persistent_shell_sessions capability, set by AioSandbox) every
  leaf degrades to UNVERIFIED — any earlier call in the shared session
  could have mutated the state the clean-looking run executed in, and
  only a fresh controlled session (RFC section 6 verifier) can prove
  otherwise. The flag is read from the provider registry without
  acquiring a sandbox.
- env assignments: the allowlist is gone — no variable is provably inert
  across repositories (CI/DEBUG are routinely read by tests). The span's
  assignment prefix must equal the criterion's exactly (values included,
  order-insensitive); any assignment or export NAME= in a preceding
  segment is state pollution.
- scoping: positional targets are now read by option arity, so a path
  embedded in an option (--basetemp=/tmp/p, --junitxml=/tmp/r.xml) never
  counts as a selection target and an extra positional after such a
  criterion narrows the default selection it denotes.

* fix(harness): stamp shell provenance at harvest, close export/unset and arity gaps (RFC #4651 PR4)

* fix(harness): split physical newlines as shell separators in acceptance matching (RFC #4651 PR4)

* fix(harness): scope cd wrappers to thread data roots, pin accepted boundaries (RFC #4651 PR4)

* fix(harness): preserve criterion connectors, prove file_written readable, fail-closed shell capability (RFC #4651 PR4)

* fix(harness): compare only the connector prefix, tolerate trailing criterion semicolons (RFC #4651 PR4)

* fix(harness): preserve continuation-line operators, keep ./-spelled executable identity (RFC #4651 PR4)

* fix(harness): render criteria single-line so a multiline criterion cannot inject a forged checklist line (RFC #4651 PR4)

* fix(harness): reject parent-traversal executable tokens in acceptance matching (RFC #4651 PR4)

* fix(harness): reject parent-traversal negated values in acceptance matching (RFC #4651 PR4)
2026-09-01 16:13:41 +08:00

760 lines
30 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 "):
return self._find(command)
if command.startswith(("grep ", "{ grep ")):
return self._grep(command)
return _execution()
def _find(self, command: str) -> _Execution:
tokens = shlex.split(command)
root = tokens[1].rstrip("/") or "/"
include_dirs = "d" in tokens
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}/"))
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")