mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 21:16:17 +00:00
* fix(sandbox): stop stripping filenames when parsing find output in remote providers The list_dir and glob parsers in the e2b, OpenSandbox, AIO, Tenki, and BoxLite providers called .strip() on every line of find output. A filename that legitimately ends (or begins) in whitespace was corrupted, so the listed path never resolved on any follow-up file API call, and the remote providers diverged from LocalSandbox, which preserves such names via pathlib. splitlines() already removes the line terminators, so filter empty lines only and keep each entry verbatim. Same class of bug as the e2b _sync_outputs_to_host fix (#4861), applied to the search parsers. Adds a trailing-space regression test per provider at the seam each suite already uses. * fix(sandbox): split find output on \n only, and rename the tenki test Review follow-ups from willem-bd: - aio_sandbox.list_dir used str.splitlines(), which also breaks records on \v, \f, \x1c-\x1e and \x85 - all legal inside a Linux filename, and all contrary to this PR's own rule that the newline is the only delimiter. find emits \n and nothing else, so split("\n") is the correct parse. - Renamed test_search_preserves_trailing_space_in_filename to test_list_dir_and_glob_preserve_trailing_space_in_filename, matching the sibling tests in test_opensandbox_provider.py and test_boxlite_provider.py. The body covers list_dir and glob; it never touches grep.
514 lines
21 KiB
Python
514 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import errno
|
|
import logging
|
|
import re
|
|
import shlex
|
|
import threading
|
|
|
|
from e2b_code_interpreter import Sandbox as E2BClientSandbox
|
|
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
|
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
|
|
from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
|
|
|
|
# Where DeerFlow's ``/mnt/user-data`` virtual prefix is materialised inside
|
|
# the e2b sandbox. e2b code-interpreter templates default to ``/home/user``
|
|
# as the working directory.
|
|
DEFAULT_E2B_HOME_DIR = "/home/user"
|
|
|
|
_E2B_NOT_FOUND_SIGNATURES = (
|
|
"sandbox was not found",
|
|
"sandbox not found",
|
|
"paused sandbox",
|
|
)
|
|
|
|
|
|
def _is_sandbox_gone_error(exc: BaseException) -> bool:
|
|
msg = str(exc).lower()
|
|
return any(sig in msg for sig in _E2B_NOT_FOUND_SIGNATURES)
|
|
|
|
|
|
class E2BSandbox(Sandbox):
|
|
"""DeerFlow Sandbox adapter that delegates to an e2b cloud sandbox.
|
|
|
|
Args:
|
|
id: DeerFlow-side sandbox id (used as cache key in the provider).
|
|
client: A live ``e2b_code_interpreter.Sandbox`` (sync) instance.
|
|
The caller owns the connection and is responsible for ``kill()``;
|
|
this wrapper only calls ``close()`` on its host-side HTTP client
|
|
during release.
|
|
home_dir: Directory inside the sandbox that backs the
|
|
``VIRTUAL_PATH_PREFIX`` (``/mnt/user-data``) prefix. Defaults to
|
|
:data:`DEFAULT_E2B_HOME_DIR`.
|
|
"""
|
|
|
|
#: Every call is a fresh ``sandbox.commands.run`` execution — no shell
|
|
#: state survives into the next command.
|
|
persistent_shell_sessions = False
|
|
|
|
def __init__(
|
|
self,
|
|
id: str,
|
|
client: E2BClientSandbox,
|
|
*,
|
|
home_dir: str = DEFAULT_E2B_HOME_DIR,
|
|
) -> None:
|
|
super().__init__(id)
|
|
self._client = client
|
|
self._home_dir = home_dir.rstrip("/") or "/"
|
|
self._lock = threading.Lock()
|
|
self._closed = False
|
|
self._dead = False
|
|
|
|
# ── Properties / lifecycle ───────────────────────────────────────────
|
|
|
|
@property
|
|
def client(self) -> E2BClientSandbox:
|
|
return self._client
|
|
|
|
@property
|
|
def home_dir(self) -> str:
|
|
return self._home_dir
|
|
|
|
@property
|
|
def sandbox_id(self) -> str:
|
|
"""e2b-side sandbox id (different from DeerFlow's ``self.id`` cache key)."""
|
|
return getattr(self._client, "sandbox_id", None) or self.id
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
client = self._client
|
|
self._client = None
|
|
|
|
if client is None:
|
|
return
|
|
|
|
for closer in (
|
|
getattr(client, "close", None),
|
|
getattr(getattr(client, "_transport", None), "close", None),
|
|
):
|
|
if callable(closer):
|
|
try:
|
|
closer()
|
|
except Exception as e:
|
|
logger.warning("Error closing E2BSandbox %s: %s", self.id, e)
|
|
return
|
|
|
|
def _resolve_path(self, path: str) -> str:
|
|
"""Map DeerFlow virtual paths into the e2b sandbox filesystem.
|
|
|
|
``VIRTUAL_PATH_PREFIX`` (``/mnt/user-data``) is rewritten under
|
|
:attr:`home_dir`, mirroring how ``LocalContainerBackend`` bind-mounts
|
|
the host workspace into the AIO container at ``/mnt/user-data``.
|
|
Other absolute paths are returned verbatim so the sandbox can reach
|
|
system directories (``/tmp``, ``/etc``, …) when needed.
|
|
"""
|
|
if not path:
|
|
raise ValueError("path must be a non-empty string")
|
|
normalised = path.replace("\\", "/")
|
|
for segment in normalised.split("/"):
|
|
if segment == "..":
|
|
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
|
|
if normalised == VIRTUAL_PATH_PREFIX or normalised.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
|
|
tail = normalised[len(VIRTUAL_PATH_PREFIX) :].lstrip("/")
|
|
return f"{self._home_dir}/{tail}".rstrip("/") if tail else self._home_dir
|
|
return normalised
|
|
|
|
def execute_command(
|
|
self,
|
|
command: str,
|
|
env: dict[str, str] | None = None,
|
|
timeout: float | None = None,
|
|
) -> str:
|
|
"""Execute a shell command via ``sandbox.commands.run``.
|
|
|
|
Returns the combined stdout/stderr.
|
|
The lock serialises concurrent calls on the same instance
|
|
because the e2b SDK shares a single HTTP/2 connection per sandbox.
|
|
|
|
Args:
|
|
command: The command to execute.
|
|
env: Optional per-call environment variables (request-scoped secrets,
|
|
issue #3861). Validated against the POSIX env-var name rule
|
|
(shared with the local and AIO sandboxes) and passed through to
|
|
e2b as ``envs``, which are scoped to this command only and never
|
|
placed in the command string.
|
|
timeout: Optional per-call command timeout in seconds. ``None`` keeps
|
|
the e2b SDK default (60s).
|
|
"""
|
|
_validate_extra_env(env)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return "Error: sandbox client has been closed"
|
|
if self._dead:
|
|
return "Error: e2b sandbox has been reaped by the control plane (idle timeout or explicit pause). The provider will rebuild a fresh sandbox on the next tool call."
|
|
try:
|
|
kwargs: dict[str, object] = {}
|
|
if env is not None:
|
|
kwargs["envs"] = env
|
|
if timeout is not None:
|
|
kwargs["timeout"] = timeout
|
|
result = client.commands.run(command, **kwargs)
|
|
stdout = getattr(result, "stdout", "") or ""
|
|
stderr = getattr(result, "stderr", "") or ""
|
|
exit_code = getattr(result, "exit_code", 0)
|
|
if stdout and stderr:
|
|
output = f"{stdout}\n{stderr}"
|
|
else:
|
|
output = stdout or stderr
|
|
if exit_code not in (0, None):
|
|
# Mirror LocalSandbox: a nonzero exit must survive in the
|
|
# output text even when the command produced output, so
|
|
# evidence consumers (acceptance checklist) can recover the
|
|
# actual shell status.
|
|
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
|
|
return output if output else "(no output)"
|
|
except Exception as e:
|
|
if _is_sandbox_gone_error(e):
|
|
self._dead = True
|
|
logger.error("Failed to execute command in e2b sandbox: %s", e)
|
|
return f"Error: {e}"
|
|
|
|
@property
|
|
def is_dead(self) -> bool:
|
|
"""Whether the underlying e2b VM is known to be reaped.
|
|
|
|
Updated lazily by ``execute_command`` and the provider's ``ping`` /
|
|
bootstrap calls — there is no proactive heartbeat. Reading the value
|
|
does *not* round-trip to the API.
|
|
"""
|
|
with self._lock:
|
|
return self._dead
|
|
|
|
def ping(self) -> bool:
|
|
"""Cheap health check: returns False if the e2b VM has been reaped.
|
|
|
|
Run as ``commands.run("true")`` so successful execution implies the
|
|
full HTTP path (auth + control plane + envd) is alive. Sets
|
|
``_dead = True`` on the same "sandbox not found" signature
|
|
:func:`_is_sandbox_gone_error` recognises so subsequent calls
|
|
short-circuit.
|
|
"""
|
|
with self._lock:
|
|
if self._dead or self._client is None:
|
|
return False
|
|
client = self._client
|
|
try:
|
|
client.commands.run("true")
|
|
return True
|
|
except Exception as e:
|
|
if _is_sandbox_gone_error(e):
|
|
with self._lock:
|
|
self._dead = True
|
|
return False
|
|
logger.warning("e2b sandbox ping raised non-fatal error: %s", e)
|
|
return True
|
|
|
|
def read_file(
|
|
self,
|
|
path: str,
|
|
start_line: int | None = None,
|
|
end_line: int | None = None,
|
|
) -> str:
|
|
resolved = self._resolve_path(path)
|
|
try:
|
|
content = self._client.files.read(resolved)
|
|
if start_line is None and end_line is None:
|
|
return content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or ""
|
|
text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content or ""
|
|
lines = text.splitlines()
|
|
start = start_line or 1
|
|
end = end_line if end_line is not None else len(lines)
|
|
content = "\n".join(lines[start - 1 : end])
|
|
return content
|
|
except Exception as e:
|
|
logger.error("Failed to read file %s in e2b sandbox: %s", resolved, e)
|
|
return f"Error: {e}"
|
|
|
|
def download_file(self, path: str) -> bytes:
|
|
normalised = path.replace("\\", "/")
|
|
for segment in normalised.split("/"):
|
|
if segment == "..":
|
|
logger.error("Refused download due to path traversal: %s", path)
|
|
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
|
|
|
|
stripped_path = normalised.lstrip("/")
|
|
allowed_prefix = VIRTUAL_PATH_PREFIX.lstrip("/")
|
|
if stripped_path != allowed_prefix and not stripped_path.startswith(f"{allowed_prefix}/"):
|
|
logger.error(
|
|
"Refused download outside allowed directory: path=%s, allowed_prefix=%s",
|
|
path,
|
|
VIRTUAL_PATH_PREFIX,
|
|
)
|
|
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
|
|
|
|
resolved = self._resolve_path(path)
|
|
# Prefer the streaming API so the 100 MB cap is enforced *before* the
|
|
# whole payload is buffered in the gateway process. ``format="bytes"``
|
|
# is implemented by the e2b SDK as ``bytearray(r.content)`` — i.e. the
|
|
# entire file is materialised in memory before returning — which would
|
|
# let a multi-GB artifact OOM the shared gateway on hosted deployments.
|
|
# ``format="stream"`` returns a ``FileStreamReader`` (an
|
|
# ``Iterator[bytes]``) that owns its HTTP response and releases the
|
|
# pooled connection on exhaustion / close / error.
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
data = client.files.read(resolved, format="stream")
|
|
except TypeError:
|
|
try:
|
|
data = client.files.read(resolved, format="bytes")
|
|
except Exception as e:
|
|
logger.error("Failed to download file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
except Exception as e:
|
|
logger.error("Failed to download file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
|
|
if data is None:
|
|
return b""
|
|
|
|
# Buffered fallbacks (bytes/bytearray/str): apply the cap up front so
|
|
# we still refuse oversize payloads even on this path.
|
|
if isinstance(data, (bytes, bytearray)):
|
|
if len(data) > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
return bytes(data)
|
|
if isinstance(data, str):
|
|
encoded = data.encode("utf-8")
|
|
if len(encoded) > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
return encoded
|
|
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
close = getattr(data, "close", None)
|
|
try:
|
|
try:
|
|
for chunk in data:
|
|
if not chunk:
|
|
continue
|
|
chunk_bytes = chunk if isinstance(chunk, bytes) else bytes(chunk)
|
|
total += len(chunk_bytes)
|
|
if total > _MAX_DOWNLOAD_SIZE:
|
|
raise OSError(
|
|
errno.EFBIG,
|
|
f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes",
|
|
path,
|
|
)
|
|
chunks.append(chunk_bytes)
|
|
except OSError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to stream file %s from e2b sandbox: %s", resolved, e)
|
|
raise OSError(f"Failed to download file '{path}' from sandbox: {e}") from e
|
|
finally:
|
|
if callable(close):
|
|
try:
|
|
close()
|
|
except Exception:
|
|
pass
|
|
return b"".join(chunks)
|
|
|
|
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return []
|
|
try:
|
|
result = client.commands.run(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500")
|
|
output = getattr(result, "stdout", "") or ""
|
|
# splitlines() already removed the terminators; do NOT strip
|
|
# entries — a filename that legitimately ends in whitespace
|
|
# would be corrupted and never resolve again.
|
|
return [line for line in output.splitlines() if line]
|
|
except Exception as e:
|
|
logger.error("Failed to list_dir %s in e2b sandbox: %s", resolved, e)
|
|
return []
|
|
|
|
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
if append:
|
|
existing = ""
|
|
try:
|
|
existing = client.files.read(resolved) or ""
|
|
if isinstance(existing, bytes):
|
|
existing = existing.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
existing = ""
|
|
content = (existing or "") + content
|
|
client.files.write(resolved, content)
|
|
except Exception as e:
|
|
logger.error("Failed to write file %s in e2b sandbox: %s", resolved, e)
|
|
raise
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
resolved = self._resolve_path(path)
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
raise RuntimeError("sandbox client has been closed")
|
|
try:
|
|
# e2b's ``files.write`` accepts either ``str`` or ``bytes`` —
|
|
# passing bytes preserves binary content losslessly.
|
|
client.files.write(resolved, content)
|
|
except Exception as e:
|
|
logger.error("Failed to update file %s in e2b sandbox: %s", resolved, e)
|
|
raise
|
|
|
|
def glob(
|
|
self,
|
|
path: str,
|
|
pattern: str,
|
|
*,
|
|
include_dirs: bool = False,
|
|
max_results: int = 200,
|
|
) -> tuple[list[str], bool]:
|
|
resolved = self._resolve_path(path)
|
|
types = "f,d" if include_dirs else "f"
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return [], False
|
|
try:
|
|
hard_limit = max(max_results * 4, max_results + 50)
|
|
cmd = f"find {shlex.quote(resolved)} \\( " + " -o ".join(f"-type {t}" for t in types.split(",")) + f" \\) -print 2>/dev/null | head -{hard_limit}"
|
|
result = client.commands.run(cmd)
|
|
output = getattr(result, "stdout", "") or ""
|
|
except Exception as e:
|
|
logger.error("Failed to glob in e2b sandbox: %s", e)
|
|
return [], False
|
|
|
|
matches: list[str] = []
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
for entry in output.splitlines():
|
|
# Do NOT strip: trailing whitespace can be part of the filename.
|
|
if not entry:
|
|
continue
|
|
if entry != root and not entry.startswith(root_prefix):
|
|
continue
|
|
if should_ignore_path(entry):
|
|
continue
|
|
rel_path = entry[len(root) :].lstrip("/")
|
|
if not rel_path:
|
|
continue
|
|
if path_matches(pattern, rel_path):
|
|
matches.append(entry)
|
|
if len(matches) >= max_results:
|
|
return matches, True
|
|
return matches, False
|
|
|
|
def grep(
|
|
self,
|
|
path: str,
|
|
pattern: str,
|
|
*,
|
|
glob: str | None = None,
|
|
literal: bool = False,
|
|
case_sensitive: bool = False,
|
|
max_results: int = 100,
|
|
) -> tuple[list[GrepMatch], bool]:
|
|
regex_source = re.escape(pattern) if literal else pattern
|
|
re.compile(regex_source, 0 if case_sensitive else re.IGNORECASE)
|
|
|
|
resolved = self._resolve_path(path)
|
|
# Build a portable ``grep`` invocation:
|
|
# -r recursive, -n line numbers, -H always print filename, -I skip
|
|
# binary files, -E extended regex (or -F for literal/fixed strings).
|
|
flags = ["-r", "-n", "-H", "-I"]
|
|
if not case_sensitive:
|
|
flags.append("-i")
|
|
if literal:
|
|
flags.append("-F")
|
|
else:
|
|
flags.append("-E")
|
|
if glob is not None:
|
|
# ``grep --include`` only matches by basename, at any depth -- it
|
|
# cannot express a directory-scoping prefix like ``src/`` in
|
|
# ``src/*.js``. Pass just the basename portion as a coarse
|
|
# pre-filter (a superset of the true match set: every file
|
|
# ``path_matches`` can accept also satisfies this basename
|
|
# pattern) and enforce the real directory scope below via
|
|
# ``path_matches``, the same helper ``glob()`` uses.
|
|
include_pattern = glob.split("/")[-1] or glob
|
|
flags.append(f"--include={include_pattern}")
|
|
|
|
per_file_cap = max(max_results, 50)
|
|
total_cap = max(max_results * 4, max_results + 50)
|
|
flags.append(f"-m{per_file_cap}")
|
|
|
|
cmd = "grep " + " ".join(flags) + f" -- {shlex.quote(regex_source)} {shlex.quote(resolved)} 2>/dev/null" + f" | head -{total_cap}"
|
|
|
|
with self._lock:
|
|
client = self._client
|
|
if client is None:
|
|
return [], False
|
|
try:
|
|
result = client.commands.run(cmd)
|
|
output = getattr(result, "stdout", "") or ""
|
|
except Exception as e:
|
|
logger.error("Failed to grep in e2b sandbox: %s", e)
|
|
return [], False
|
|
|
|
root = resolved.rstrip("/") or "/"
|
|
root_prefix = root if root == "/" else f"{root}/"
|
|
|
|
matches: list[GrepMatch] = []
|
|
truncated = False
|
|
for raw in output.splitlines():
|
|
try:
|
|
file_path, line_no_str, line_text = raw.split(":", 2)
|
|
except ValueError:
|
|
continue
|
|
try:
|
|
line_number = int(line_no_str)
|
|
except ValueError:
|
|
continue
|
|
if should_ignore_path(file_path):
|
|
continue
|
|
if glob is not None:
|
|
# Restrict to the caller's real directory scope -- the
|
|
# ``--include`` flag above only pre-filtered by basename.
|
|
if file_path != root and not file_path.startswith(root_prefix):
|
|
continue
|
|
rel_path = file_path.rsplit("/", 1)[-1] if file_path == root else file_path[len(root) :].lstrip("/")
|
|
if not path_matches(glob, rel_path):
|
|
continue
|
|
matches.append(
|
|
GrepMatch(
|
|
path=file_path,
|
|
line_number=line_number,
|
|
line=truncate_line(line_text),
|
|
)
|
|
)
|
|
if len(matches) >= max_results:
|
|
truncated = True
|
|
break
|
|
return matches, truncated
|