Jeremy Schoemaker 6b4f803354
fix(sandbox): preserve trailing whitespace in filenames from list_dir and glob in remote providers (#4980)
* 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.
2026-09-02 09:23:04 +08:00

474 lines
21 KiB
Python

"""``TenkiSandbox`` — DeerFlow :class:`Sandbox` backed by a Tenki cloud sandbox.
Tenki's Python SDK (the ``tenki`` distribution, which ships the
``tenki_sandbox`` module) is synchronous, so — unlike
``community/boxlite`` — this adapter calls the SDK directly with no event-loop
bridge. File transport uses Tenki's native ``sandbox.fs`` API (``read_text`` /
``read_stream`` / ``write_stream`` / ``mkdir`` / ``stat``), which is binary-safe
and streams, so no base64/shell encoding is involved. Directory and content
*search* (``list_dir`` / ``glob`` / ``grep``) still shells out to ``find`` /
``grep`` — the fs API is single-level and has no content search — and is parsed
with the shared ``deerflow.sandbox.search`` helpers, the same approach as
``community/e2b_sandbox``. Those commands use only busybox-portable flags so any
Tenki base image works.
The Tenki SDK is not imported at module load (only its exception *class names*
are matched, as strings), so importing this package never requires
``tenki`` to be installed — it is needed only once the provider is
selected and a sandbox is actually created.
"""
from __future__ import annotations
import errno
import logging
import posixpath
import re
import shlex
import threading
from typing import TYPE_CHECKING, Any, TypeVar
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
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from tenki_sandbox import Sandbox as TenkiClientSandbox
from tenki_sandbox.fs import SandboxFS
T = TypeVar("T")
logger = logging.getLogger(__name__)
_MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
# Tenki sandboxes run as the unprivileged ``tenki`` user (HOME=/home/tenki) and
# ``/mnt`` is root-owned, so DeerFlow's ``/mnt/user-data`` virtual prefix is not
# writable directly. Like ``community/e2b_sandbox``, file ops are remapped under
# this home dir (the provider also best-effort symlinks /mnt/user-data → here so
# agent shell commands using the literal path still work).
DEFAULT_TENKI_HOME_DIR = "/home/tenki"
# Frame size for fs.write_stream uploads.
_STREAM_CHUNK = 1024 * 1024
# Tenki SDK exception *class names* that mean the remote session is gone for
# good — matched as strings so this module imports without ``tenki``.
# A terminated/not-found/closed session is unrecoverable; the provider drops it
# and rebuilds on the next call. This is only the named-error half of the rule:
# _is_terminal_failure ALSO treats the builtin ConnectionError / BrokenPipeError
# / EOFError as terminal via isinstance, so a transport reset evicts the sandbox
# and cold-starts the next acquire too. That is a deliberate fail-safe (a reset
# often means the microVM is gone); the cost is churning a warm sandbox on a
# one-off flaky-network blip.
_TERMINAL_ERROR_NAMES = frozenset(
{
"SessionTerminatedError",
"SessionNotFoundError",
"InvalidStateError",
"StreamClosedError",
}
)
class TenkiSandbox(Sandbox):
"""DeerFlow Sandbox adapter that delegates to a live Tenki cloud sandbox.
Args:
id: DeerFlow-side sandbox id (the provider's cache key).
sandbox: A live, started ``tenki_sandbox.Sandbox``. The provider owns
its lifecycle; this adapter terminates it on :meth:`close`.
default_env: Static environment merged into every command, overridden
per-call by the ``env`` passed to :meth:`execute_command`
(request-scoped secrets).
home_dir: Writable directory that backs the ``VIRTUAL_PATH_PREFIX``
(``/mnt/user-data``) prefix inside the sandbox. Defaults to
:data:`DEFAULT_TENKI_HOME_DIR`.
on_terminal_failure: Optional callback ``(sandbox_id, reason)`` invoked
when an operation fails with a terminal Tenki error, so the provider
can evict the dead sandbox.
"""
#: Every call is a fresh ``sh -lc`` exec in the sandbox — no shell state
#: survives into the next command.
persistent_shell_sessions = False
def __init__(
self,
id: str,
sandbox: TenkiClientSandbox,
*,
default_env: dict[str, str] | None = None,
home_dir: str = DEFAULT_TENKI_HOME_DIR,
on_terminal_failure: Callable[[str, str], None] | None = None,
) -> None:
super().__init__(id)
self._sandbox = sandbox
self._default_env = dict(default_env or {})
self._home_dir = home_dir.rstrip("/") or "/"
self._on_terminal_failure = on_terminal_failure
self._lock = threading.Lock()
# Serialises the append read-modify-write across its three fs ops. A
# lock distinct from _lock, so it can wrap the whole sequence without the
# per-op eviction callback (which reaches back into the provider) ever
# running under it.
self._write_lock = threading.Lock()
self._closed = False
@property
def is_closed(self) -> bool:
with self._lock:
return self._closed
@staticmethod
def _is_terminal_failure(error: Exception) -> bool:
if isinstance(error, (BrokenPipeError, ConnectionError, EOFError)):
return True
return type(error).__name__ in _TERMINAL_ERROR_NAMES
def close(self) -> None:
"""Terminate the underlying Tenki session (idempotent).
The microVM is terminated *first*; the adapter is only marked closed once
the session is actually gone, so a failed termination stays retryable
instead of silently leaking a running (billed) sandbox. A terminal
session error means it is already gone, which counts as closed; anything
else is raised so the caller can retry or alert.
"""
with self._lock:
if self._closed:
return
sandbox = self._sandbox
try:
sandbox.close()
except Exception as e:
if not self._is_terminal_failure(e):
logger.error("Error terminating Tenki sandbox %s: %s", self.id, e)
raise
logger.info("Tenki sandbox %s was already gone at close: %s", self.id, e)
with self._lock:
self._closed = True
# ── bridge helpers ──────────────────────────────────────────────────
def _note_failure(self, error: Exception) -> None:
"""Evict this sandbox when an operation failed with a terminal error."""
if self._on_terminal_failure is None or not self._is_terminal_failure(error):
return
try:
self._on_terminal_failure(self.id, str(error))
except Exception:
logger.exception("Terminal Tenki failure callback errored for %s", self.id)
def _fs_op(self, op: Callable[[SandboxFS], T]) -> T:
"""Run a native ``sandbox.fs`` call, evicting the sandbox on terminal errors.
The lock is held across ``op`` (not just the fs lookup) so concurrent
calls on the same sandbox serialise: the Tenki SDK shares one connection
per instance, like community/e2b_sandbox. ``_note_failure`` runs *after*
the lock is released — it reaches back into the provider, which locks in
the opposite order (provider then sandbox), so holding both at once could
deadlock.
"""
with self._lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
fs = self._sandbox.fs
try:
return op(fs)
except Exception as e:
failure = e
self._note_failure(failure)
raise failure
def _exec(self, *argv: str, env: dict[str, str] | None = None, timeout: float | None = None) -> Any:
# No forced cwd: commands run in the sandbox default working directory
# (like community/e2b_sandbox and community/boxlite); file ops address
# absolute, home-remapped paths, so cwd is irrelevant to them.
#
# No auto-retry: exec is not idempotent (the command may have run
# server-side before a transport ack dropped), so re-running it risks
# double side effects. Like boxlite, a transient error is surfaced to the
# caller (returned as text by execute_command); a terminal session error
# additionally evicts the sandbox so the next acquire rebuilds it.
with self._lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
sandbox = self._sandbox
try:
return sandbox.exec(*argv, env=env, timeout=timeout)
except Exception as e:
self._note_failure(e)
raise
def _sh(self, script: str, env: dict[str, str] | None = None, timeout: float | None = None) -> Any:
return self._exec("sh", "-lc", script, env=env, timeout=timeout)
# ── path safety (mirrors community/e2b_sandbox) ──────────────────────
@staticmethod
def _guard_traversal(path: str) -> str:
if not path:
raise ValueError("path must be a non-empty string")
normalized = path.replace("\\", "/")
for segment in normalized.split("/"):
if segment == "..":
raise PermissionError(f"Access denied: path traversal detected in '{path}'")
return normalized
def _resolve_path(self, path: str) -> str:
"""Map DeerFlow virtual paths into the writable sandbox home dir.
``VIRTUAL_PATH_PREFIX`` (``/mnt/user-data``) is rewritten under
:attr:`_home_dir`; other absolute paths pass through so the sandbox can
reach system directories when needed. Traversal is always rejected.
"""
normalized = self._guard_traversal(path)
if normalized == VIRTUAL_PATH_PREFIX or normalized.startswith(f"{VIRTUAL_PATH_PREFIX}/"):
tail = normalized[len(VIRTUAL_PATH_PREFIX) :].lstrip("/")
return f"{self._home_dir}/{tail}".rstrip("/") if tail else self._home_dir
return normalized
def _virtual_path(self, resolved: str) -> str:
"""Inverse of :meth:`_resolve_path` — the form callers gave us.
Everything that *returns* paths (``list_dir``/``glob``/``grep``) reports
them under ``VIRTUAL_PATH_PREFIX``, not the sandbox-internal home dir, so
results can be fed straight back into the other file APIs.
"""
if resolved == self._home_dir:
return VIRTUAL_PATH_PREFIX
if resolved.startswith(f"{self._home_dir}/"):
return f"{VIRTUAL_PATH_PREFIX}/{resolved[len(self._home_dir) :].lstrip('/')}"
return resolved
# ── command execution ───────────────────────────────────────────────
def execute_command(
self,
command: str,
env: dict[str, str] | None = None,
timeout: float | None = None,
) -> str:
"""Run ``command`` through a shell in the Tenki sandbox and return output.
DeerFlow passes a bash command *string*; it runs through ``sh -lc``.
Per-call ``env`` is layered over the static config environment and
scoped to this command only (request-scoped secrets, issue #3861).
"""
_validate_extra_env(env) # POSIX env-var key rule; raises ValueError on a bad key
if self.is_closed:
return "Error: sandbox has been closed"
merged_env = {**self._default_env, **(env or {})} or None
try:
result = self._sh(command, env=merged_env, timeout=timeout)
except Exception as e:
logger.error("Failed to execute command in Tenki sandbox %s: %s", self.id, e)
return f"Error: {e}"
stdout = result.stdout_text or ""
stderr = result.stderr_text or ""
if stdout and stderr:
output = f"{stdout}\n{stderr}"
else:
output = stdout or stderr
if result.exit_code not in (0, None):
# Mirror LocalSandbox: preserve a nonzero exit in the output text
# even when the command produced output (see e2b_sandbox).
output = f"{output}\nExit Code: {result.exit_code}" if output else f"Command exited with code {result.exit_code}"
return output if output else "(no output)"
# ── file operations ─────────────────────────────────────────────────
def read_file(self, path: str) -> str:
resolved = self._resolve_path(path)
try:
return self._fs_op(lambda fs: fs.read_text(resolved))
except Exception as e:
logger.error("read_file %s failed: %s", resolved, e)
return f"Error: {e}"
def write_file(self, path: str, content: str, append: bool = False) -> None:
self._write_bytes(self._resolve_path(path), content.encode("utf-8"), append=append)
def update_file(self, path: str, content: bytes) -> None:
self._write_bytes(self._resolve_path(path), content, append=False)
def _write_bytes(self, resolved: str, data: bytes, *, append: bool) -> None:
parent = posixpath.dirname(resolved)
if not append:
if parent:
self._fs_op(lambda fs: fs.mkdir(parent))
self._fs_op(lambda fs: fs.write_stream(resolved, _frames(data)))
return
# Tenki's write stream has no append mode (it starts at offset 0), so we
# read-modify-write like community/e2b_sandbox. The read and the write are
# separate fs ops, so two concurrent appends could both read the same
# pre-image and the second would clobber the first; _write_lock makes the
# whole sequence atomic.
with self._write_lock:
if parent:
self._fs_op(lambda fs: fs.mkdir(parent))
try:
data = self._fs_op(lambda fs: fs.read_bytes(resolved)) + data
except Exception as e:
if type(e).__name__ != "FileNotFoundError":
raise
self._fs_op(lambda fs: fs.write_stream(resolved, _frames(data)))
def download_file(self, path: str) -> bytes:
normalized = self._guard_traversal(path)
stripped = normalized.lstrip("/")
allowed = VIRTUAL_PATH_PREFIX.lstrip("/")
if stripped != allowed and not stripped.startswith(f"{allowed}/"):
raise PermissionError(f"Access denied: path must be under '{VIRTUAL_PATH_PREFIX}': '{path}'")
resolved = self._resolve_path(path)
with self._lock:
if self._closed:
raise RuntimeError("sandbox has been closed")
fs = self._sandbox.fs
# Deliberate: the lock is dropped before streaming, unlike _fs_op which
# holds it across its op. _fs_op's serialization guards short, bounded
# calls; a download can be up to _MAX_DOWNLOAD_SIZE (100 MB), and holding
# the instance lock across it would block every other tool on this
# sandbox for the whole transfer. The Tenki read stream is safe to run
# alongside other ops (the SDK multiplexes over its connection), so we
# accept the interleave here for latency and still evict on a terminal
# transport error via _note_failure below.
#
# The cap is enforced on bytes actually received, so a file that grows
# mid-transfer still can't exceed it (a stat-then-read check could).
chunks: list[bytes] = []
total = 0
try:
for chunk in fs.read_stream(resolved):
total += len(chunk)
if total > _MAX_DOWNLOAD_SIZE:
raise OSError(errno.EFBIG, f"File exceeds maximum download size of {_MAX_DOWNLOAD_SIZE} bytes", path)
chunks.append(chunk)
except OSError as e:
# Our own EFBIG size-cap is not a session death — let it pass through
# without evicting. Every other OSError is a real transport failure:
# ConnectionError / BrokenPipeError / EOFError are OSError subclasses
# that _is_terminal_failure treats as terminal, so they must route
# through _note_failure like _fs_op/_exec do. Without this, a session
# that dies mid-download is never evicted and the agent keeps hitting
# OSErrors until some other op happens to reap it.
if e.errno == errno.EFBIG:
raise
self._note_failure(e)
raise
except Exception as e:
self._note_failure(e)
raise OSError(f"cannot read '{path}' from sandbox: {e}") from e
return b"".join(chunks)
def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
resolved = self._resolve_path(path)
r = self._sh(f"find {shlex.quote(resolved)} -maxdepth {int(max_depth)} \\( -type f -o -type d \\) 2>/dev/null | head -500")
# splitlines() already removed the terminators; do NOT strip entries —
# a filename that legitimately ends in whitespace would be corrupted.
return [self._virtual_path(line) for line in (r.stdout_text or "").splitlines() if line]
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",)
type_expr = " -o ".join(f"-type {t}" for t in types)
hard_limit = max(max_results * 4, max_results + 50)
r = self._sh(f"find {shlex.quote(resolved)} \\( {type_expr} \\) -print 2>/dev/null | head -{hard_limit}")
matches: list[str] = []
root = resolved.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
for entry in (r.stdout_text or "").splitlines():
# Do NOT strip: trailing whitespace can be part of the filename.
if not entry or (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(self._virtual_path(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]:
# Validate a regex pattern at the boundary (grep uses POSIX ERE, but this
# catches gross errors); a literal needs none. grep receives the RAW
# pattern: -F matches it literally, -E as a regex.
if not literal:
re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
resolved = self._resolve_path(path)
# busybox+GNU-portable flags: -r recursive, -H always print the filename
# (without it, grep -r on a path that resolves to a single file prints
# "line:text" and the file:line:text unpack below drops every match), -n
# line numbers, -I skip binary, -E/-F regex vs fixed. --include and -m are
# omitted for busybox portability; glob-scoping and the result cap are
# applied in Python below.
flags = ["-r", "-H", "-n", "-I"]
if not case_sensitive:
flags.append("-i")
flags.append("-F" if literal else "-E")
total_cap = max(max_results * 4, max_results + 50)
cmd = "grep " + " ".join(flags) + f" -e {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null | head -{total_cap}"
r = self._sh(cmd)
root = resolved.rstrip("/") or "/"
root_prefix = root if root == "/" else f"{root}/"
matches: list[GrepMatch] = []
truncated = False
for raw in (r.stdout_text or "").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:
# Match the caller's real directory scope: a pattern like
# "src/*.js" must not broaden to every *.js in the tree. Same
# helper, same relative-to-root semantics as glob() above.
if file_path != root and not file_path.startswith(root_prefix):
continue
rel_path = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/")
if not path_matches(glob, rel_path):
continue
matches.append(GrepMatch(path=self._virtual_path(file_path), line_number=line_number, line=truncate_line(line_text)))
if len(matches) >= max_results:
truncated = True
break
return matches, truncated
def _frames(data: bytes) -> Iterator[bytes]:
"""Slice ``data`` into upload frames for ``fs.write_stream``."""
for i in range(0, len(data), _STREAM_CHUNK):
yield data[i : i + _STREAM_CHUNK]