Diego Câmara ac18f518c8
feat(sandbox): add Tenki cloud sandbox provider (#4382)
* feat(sandbox): add Tenki cloud sandbox provider

Adds deerflow.community.tenki, a SandboxProvider backed by Tenki cloud
microVMs, alongside the existing e2b_sandbox / boxlite / aio_sandbox
backends. Selected via `sandbox.use: deerflow.community.tenki:TenkiSandboxProvider`
(resolved by class path, so the change is purely additive).

The full Sandbox contract is implemented — execute_command plus
read/write/update/download_file and list_dir/glob/grep — with file ops run
as busybox-portable shell commands (cat / find / grep / chunked base64),
reusing deerflow.sandbox.search, mirroring e2b_sandbox and boxlite. Tenki's
SDK is synchronous, so unlike boxlite there is no event-loop bridge.

Tenki sandboxes run as an unprivileged user with /mnt root-owned, so the
/mnt/user-data virtual prefix is remapped under the writable home dir (like
e2b_sandbox); the provider also best-effort sudo-symlinks /mnt/user-data to
that home dir so agent shell commands using the literal path still work.
Sandboxes are pooled per (user, thread) with warm reclaim, a replica cap,
and an idle reaper via the shared WarmPoolLifecycleMixin. Transient
transport blips get one bounded retry; terminal session errors evict and
recreate.

Only the stable Tenki surface is used (create/terminate + exec/shell/fs) —
no volumes, snapshots, or template builds — so any stock base image works.
The tenki-sandbox SDK is an optional extra (deerflow-harness[tenki]) and is
imported lazily, so a default install and every other provider are
unaffected.

Tested: unit suite runs in CI without tenki-sandbox installed; a live
integration test and full-surface e2e were verified against real Tenki
sandboxes.

* fix(sandbox): remove unsafe auto-retry from Tenki exec

Pre-merge review caught that the transient-transport retry sat at the
universal _exec layer, so it retried every operation — execute_command and
base64 file-write chunks included. gRPC has no exactly-once guarantee: a
"socket closed" ack-drop after the server already ran the op means the retry
runs it twice, double-firing command side effects and duplicating a write
chunk mid-file (silent binary corruption on multi-chunk writes).

exec is not idempotent, so it must not be auto-retried. Reverts to the
boxlite/e2b behavior: a transient error surfaces to the caller (returned as
text by execute_command, raised by the file ops); a terminal session error
still evicts the sandbox so the next acquire rebuilds it. Verified live
end-to-end across 31 edge cases (empty/binary/unicode/chunk-boundary files,
shell-metachar content, error paths, list/glob/grep, warm-pool reclaim,
concurrency).

* fix(sandbox): address Tenki provider review feedback

- Use Tenki's native sandbox.fs API for all file transport (read_text,
  read_bytes, write_stream, mkdir) instead of cat/chunked-base64 over shell.
  Uploads stream in 1 MiB frames; append is read-modify-write because the
  write stream has no append mode (same approach as community/e2b_sandbox).
- download_file streams via fs.read_stream and enforces the 100 MB cap on
  bytes actually received, closing the TOCTOU window between the old
  wc -c size probe and the read.
- list_dir/glob/grep report paths back under /mnt/user-data instead of the
  sandbox-internal home dir, so results feed straight into the file APIs.
- Create with wait=False and await wait_ready() here: create(wait=True)
  raises with the session handle still inside the SDK, leaking a running
  microVM this provider could never terminate.
- Configure the sandbox lifetime (max_duration, default 4h) and expose
  sticky; without it Tenki reaps a reused thread's sandbox after ~30 min.
- close() terminates before marking the adapter closed and re-raises real
  failures, so a failed termination stays retryable instead of silently
  leaking a billed microVM; an already-gone session still counts as closed.
- Bump the optional extra to tenki-sandbox>=0.4.0 and commit backend/uv.lock.

* fix(sandbox): scope tenki grep() glob filter to its directory prefix

Mirrors #4168, which fixed the same defect in the E2B provider. The tenki
adapter reduced a directory-scoped pattern like "src/*.js" to its basename
before filtering, so the search silently broadened to every matching-extension
file in the tree. Post-filter grep's hits through path_matches() against the
path relative to the search root, the same way glob() already does, so both
agree on what a directory-scoped pattern means.

* fix(sandbox): address Tenki provider review — eviction, id width, write lock, grep -H

Four fixes from the upstream review:

download_file no longer swallows terminal transport errors. The broad
`except OSError: raise` re-raised ConnectionError/BrokenPipeError/EOFError
(all OSError subclasses that _is_terminal_failure treats as terminal) before
_note_failure ran, so a session that died mid-download was never evicted. Only
our own EFBIG size-cap now passes through without eviction.

Sandbox id widened from 32 to 64 bits (`[:8]` to `[:16]`), matching
community/e2b_sandbox. The warm pool is keyed by this id with no full-seed
fallback, so a collision could let one user reclaim another's parked sandbox on
a multi-tenant gateway.

_fs_op now holds the lock across the op, not just the fs lookup, so concurrent
calls on the same sandbox serialise over the SDK's shared connection. The
eviction callback runs after the lock is released to avoid a lock-order
deadlock with the provider. The append read-modify-write is serialised by a
dedicated _write_lock so two concurrent appends can't clobber each other.

grep passes -H so a search whose path resolves to a single file still prints
the filename; without it the file:line:text unpack dropped every match.

* fix(sandbox): address Tenki provider review round 2

- validate config `environment` at load time (_validate_extra_env) so a bad
  key fails fast instead of surfacing as an SDK error mid-command
- document the deliberate lock decision in download_file: the instance lock is
  dropped before streaming so a 100 MB download can't block every other tool;
  terminal transport errors still evict via _note_failure
- tighten the terminal-error comment to note ConnectionError/BrokenPipeError/
  EOFError are also treated terminal via isinstance
- document TenkiSandboxProvider in backend/AGENTS.md (provider detail, warm-pool
  destroy hook, community provider list)
- add a commented Tenki block to config.example.yaml for parity with AIO/BoxLite
- tests: config env validation, grep -F/case-sensitive flags, glob include_dirs,
  list_dir max_depth, bootstrap-failure warning branch

* fix(sandbox): make Tenki bootstrap non-interactive and time-bounded

The create-time bootstrap runs under the per-scope acquire lock, so a hang
would stall acquire for that scope indefinitely:
- use `sudo -n` so a password-requiring sudoers entry fails fast (swallowed by
  the existing `|| true`) instead of blocking on a tty password prompt
- pass a timeout to the bootstrap `remote.exec` so any other stall drops to the
  existing warning path rather than wedging acquire

Best-effort by design; the file APIs still work via the home remap on failure.

* test(sandbox): pin Tenki bootstrap timeout to its actual value

Assert bootstrap["timeout"] == _BOOTSTRAP_TIMEOUT instead of `is not None`, so
a regression to timeout=0 (treated as no timeout by some SDKs) or an unrelated
value is caught rather than passing a weaker non-None check.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-27 22:20:07 +08:00

451 lines
20 KiB
Python

"""``TenkiSandboxProvider`` — DeerFlow :class:`SandboxProvider` backed by Tenki.
Integrates `Tenki <https://tenki.cloud>`_ cloud sandboxes as a DeerFlow sandbox
backend. Each sandbox is an isolated cloud microVM created from a stock base
image; the provider creates one per ``(user, thread)`` and reuses it within the
process, parking released sandboxes in a warm pool (shared
:class:`WarmPoolLifecycleMixin` machinery) for fast reclaim.
Config is read off :class:`SandboxConfig` (``extra="allow"``), so Tenki keys may
appear under ``sandbox:`` in ``config.yaml`` even though they are not declared on
the model — see this package's ``__init__`` docstring for the full set.
The Tenki SDK is imported lazily (``_import_client``) so the harness — and every
other provider — installs without ``tenki-sandbox``; the dependency is only
needed once this provider is selected.
"""
from __future__ import annotations
import atexit
import hashlib
import logging
import shlex
import threading
import time
import uuid
from typing import TYPE_CHECKING, Any
from deerflow.config import get_app_config
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.sandbox_provider import SandboxProvider
from ..warm_pool_lifecycle import WarmPoolLifecycleMixin
from .sandbox import DEFAULT_TENKI_HOME_DIR, TenkiSandbox
if TYPE_CHECKING:
from tenki_sandbox import Client
logger = logging.getLogger(__name__)
_SANDBOX_NAME_PREFIX = "deer-flow-tenki-"
# Tenki terminates a sandbox at its max lifetime (~30 min by default), which
# would silently drop a long-running thread's state mid-conversation. DeerFlow
# owns the lifecycle here — the warm pool's idle_timeout reaps unused sandboxes
# — so ask for a lifetime that comfortably outlives a research run. Override
# with sandbox.max_duration (seconds); 0 falls back to the Tenki account default.
DEFAULT_MAX_DURATION = 4 * 60 * 60
# Bootstrap is best-effort and holds the per-scope acquire lock, so bound its
# exec: a hang here (e.g. a stuck sudo) would otherwise stall acquire for that
# scope indefinitely and queue later acquires behind it. A timeout drops it to
# the existing warning path instead.
_BOOTSTRAP_TIMEOUT = 30.0
def _bootstrap_script(home_dir: str) -> str:
"""Materialise DeerFlow's virtual path layout inside the sandbox.
Tenki sandboxes run as the unprivileged ``tenki`` user with ``/mnt``
root-owned, so ``mkdir -p /mnt/user-data/...`` fails with Permission denied.
Mirroring ``community/e2b_sandbox``, we create the real backing directories
under the writable HOME and best-effort ``sudo``-symlink ``/mnt/user-data``
to it so commands using the documented ``/mnt/...`` paths still work. If
sudo is unavailable the symlink step is skipped; the file APIs keep working
via :meth:`TenkiSandbox._resolve_path`'s home remap.
``sudo -n`` (non-interactive) is deliberate: if the tenki user's sudoers
entry needs a password, a bare ``sudo`` on a tty-allocating exec would block
on the password prompt and — with this call's exec timeout — stall the whole
acquire. ``-n`` fails fast instead, and the existing ``|| true`` swallows it.
"""
home = shlex.quote(home_dir)
return (
f"mkdir -p {home}/workspace {home}/uploads {home}/outputs; "
f"if command -v sudo >/dev/null 2>&1; then "
f" if [ ! -e /mnt/user-data ] || [ -L /mnt/user-data ]; then "
f" sudo -n ln -sfn {home} /mnt/user-data 2>/dev/null || true; "
f" fi; "
f"fi; "
f"echo BOOTSTRAP_OK"
)
def _import_client() -> type[Client]:
"""Import Tenki's ``Client`` lazily.
Kept out of module import so the harness (and every other provider) installs
without Tenki; the dependency is only needed once this provider is selected.
"""
try:
from tenki_sandbox import Client
except ImportError as e: # pragma: no cover - depends on the optional dependency
raise ImportError("TenkiSandboxProvider requires the optional 'tenki-sandbox' dependency. Install it with: pip install 'deerflow-harness[tenki]' or pip install tenki-sandbox.") from e
return Client
class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider):
"""Run each DeerFlow sandbox as a Tenki cloud microVM."""
uses_thread_data_mounts = False
needs_upload_permission_adjustment = True
_idle_checker_thread_name = "tenki-idle-reaper"
@staticmethod
def _sandbox_id(thread_id: str, user_id: str) -> str:
"""Deterministic sandbox ID from user/thread scope.
Includes user_id so a sandbox created for one user's bucket cannot be
reclaimed by another user's thread with the same thread_id. The warm
pool is keyed by this id alone (``_reclaim_warm_pool`` looks it up
directly, with no full-seed fallback), so on a hosted multi-tenant
gateway a hash collision would let one user reclaim another's parked
sandbox. 64 bits, like community/e2b_sandbox, keeps that negligible.
"""
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
# ── Provider lifecycle ───────────────────────────────────────────────
def __init__(self) -> None:
self._lock = threading.Lock()
self._sandboxes: dict[str, TenkiSandbox] = {}
self._thread_sandboxes: dict[tuple[str, str], str] = {}
self._warm_pool: dict[str, tuple[TenkiSandbox, float]] = {}
self._acquire_locks: dict[str, threading.Lock] = {}
self._idle_checker_stop = threading.Event()
self._idle_checker_thread: threading.Thread | None = None
self._shutdown_called = False
self._client: Client | None = None
self._config = self._load_config()
atexit.register(self.shutdown)
self._start_idle_checker()
def _load_config(self) -> dict[str, Any]:
sandbox_config = get_app_config().sandbox
def _opt(name: str, default: Any = None) -> Any:
return getattr(sandbox_config, name, default)
api_key = _opt("api_key")
replicas = _opt("replicas")
idle_timeout = _opt("idle_timeout")
max_duration = _opt("max_duration")
environment = dict(_opt("environment") or {})
# Fail fast on a misconfigured key (e.g. "bad-key"): the per-call env goes
# through the same POSIX-name check in execute_command, but this static
# config env is merged into every command and would otherwise only surface
# as a confusing SDK error at create/exec time.
_validate_extra_env(environment)
return {
"max_duration": float(max_duration if max_duration is not None else DEFAULT_MAX_DURATION),
# Off by default (the SDK default). Warm-pool sandboxes stay running
# between turns, so host pinning only matters to deployments that
# also pause/resume; expose it rather than decide for them.
"sticky": bool(_opt("sticky", False)),
"api_key": api_key, # None → SDK falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN
"base_url": _opt("base_url"),
"image": _opt("image"), # None → Tenki account default base image
"home_dir": _opt("home_dir") or DEFAULT_TENKI_HOME_DIR,
"project_id": _opt("project_id"),
"workspace_id": _opt("workspace_id"),
"cpu_cores": _opt("cpu_cores"),
"memory_mb": _opt("memory_mb"),
"environment": environment,
"replicas": replicas if replicas is not None else self.DEFAULT_REPLICAS,
"idle_timeout": idle_timeout if idle_timeout is not None else self.DEFAULT_IDLE_TIMEOUT,
}
def _get_client(self) -> Client:
with self._lock:
if self._client is not None:
return self._client
client_cls = _import_client()
client = client_cls(auth_token=self._config["api_key"], base_url=self._config["base_url"])
with self._lock:
if self._client is None:
self._client = client
return self._client
def _resolve_scope(self) -> tuple[str | None, str | None]:
"""Return (project_id, workspace_id), auto-selecting when unambiguous.
Tenki's ``create`` needs a project scope. When the caller didn't set one
in config, pick it if the account has exactly one workspace and project;
otherwise raise with the choices so the operator can set ``project_id``.
"""
project_id = self._config["project_id"]
workspace_id = self._config["workspace_id"]
if project_id is not None:
return project_id, workspace_id
identity = self._get_client().who_am_i()
workspaces = list(identity.workspaces or [])
if workspace_id is not None:
workspaces = [w for w in workspaces if w.id == workspace_id]
workspace = self._require_single(workspaces, "workspace", "workspace_id")
project = self._require_single(list(workspace.projects or []), "project", "project_id")
return project.id, workspace.id
@staticmethod
def _require_single(items: list[Any], kind: str, param: str) -> Any:
if len(items) != 1:
raise ValueError(f"Could not auto-select a Tenki {kind}; set sandbox.{param} in config.yaml. Found: {[(getattr(i, 'id', None), getattr(i, 'name', None)) for i in items]}")
return items[0]
@staticmethod
def _thread_key(thread_id: str, user_id: str | None) -> tuple[str, str]:
return (user_id or "", thread_id)
@classmethod
def _sandbox_name(cls, sandbox_id: str) -> str:
return f"{_SANDBOX_NAME_PREFIX}{sandbox_id}"
def _lock_for_sandbox(self, sandbox_id: str) -> threading.Lock:
with self._lock:
lock = self._acquire_locks.get(sandbox_id)
if lock is None:
lock = threading.Lock()
self._acquire_locks[sandbox_id] = lock
return lock
def _start_idle_checker(self) -> None:
"""Start idle cleanup when enabled; idle_timeout=0 keeps it disabled."""
if self._config["idle_timeout"] <= 0:
return
super()._start_idle_checker()
def _active_count_locked(self) -> int:
return len(self._sandboxes)
def _destroy_warm_entry(self, sandbox_id: str, entry: TenkiSandbox, *, reason: str) -> None:
self._close_quietly(entry, context=f"warm pool, reason={reason}")
def _invalidate_sandbox(self, sandbox_id: str, reason: str) -> None:
"""Destroy and deregister a sandbox after a terminal command-path failure."""
to_close: TenkiSandbox | None = None
with self._lock:
active = self._sandboxes.pop(sandbox_id, None)
warm_entry = self._warm_pool.pop(sandbox_id, None)
for key in [k for k, sid in self._thread_sandboxes.items() if sid == sandbox_id]:
self._thread_sandboxes.pop(key, None)
to_close = active or (warm_entry[0] if warm_entry is not None else None)
if to_close is None:
logger.warning("Tenki sandbox %s failed terminally but was not tracked: %s", sandbox_id, reason)
return
logger.warning("Invalidating Tenki sandbox %s after terminal failure: %s", sandbox_id, reason)
self._close_quietly(to_close, context="terminal failure")
# ── Acquire / release ────────────────────────────────────────────────
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
if thread_id is None:
sandbox_id = str(uuid.uuid4())[:8]
sandbox = self._create_sandbox(sandbox_id)
with self._lock:
self._sandboxes[sandbox.id] = sandbox
return sandbox.id
key = self._thread_key(thread_id, user_id)
sandbox_id = self._sandbox_id(thread_id, user_id or "")
acquire_lock = self._lock_for_sandbox(sandbox_id)
with acquire_lock:
with self._lock:
existing = self._thread_sandboxes.get(key)
if existing is not None and existing in self._sandboxes:
return existing
reclaimed = self._reclaim_warm_pool(sandbox_id)
if reclaimed is not None:
with self._lock:
self._thread_sandboxes[key] = reclaimed
return reclaimed
sandbox = self._create_sandbox(sandbox_id)
with self._lock:
self._sandboxes[sandbox.id] = sandbox
self._thread_sandboxes[key] = sandbox.id
return sandbox.id
def _create_sandbox(self, sandbox_id: str) -> TenkiSandbox:
# Enforce replica soft cap: evict the oldest warm sandbox if active + warm
# are at capacity.
replicas, total = self._replica_count()
if total >= replicas:
evicted = self._evict_oldest_warm()
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
client = self._get_client()
project_id, workspace_id = self._resolve_scope()
create_kwargs: dict[str, Any] = {
"name": self._sandbox_name(sandbox_id),
"project_id": project_id,
"workspace_id": workspace_id,
"sticky": self._config["sticky"],
# Wait for readiness ourselves (below) instead of inside create():
# create(wait=True) raises with the session handle still local to the
# SDK, so a readiness failure would leak a running, billed microVM
# that this provider never sees and can never terminate.
"wait": False,
}
if self._config["max_duration"] > 0:
create_kwargs["max_duration"] = self._config["max_duration"]
for key in ("image", "cpu_cores", "memory_mb"):
if self._config[key] is not None:
create_kwargs[key] = self._config[key]
if self._config["environment"]:
create_kwargs["env"] = self._config["environment"]
remote = client.create(**create_kwargs)
try:
remote.wait_ready()
except Exception:
self._terminate_orphan(sandbox_id, remote)
raise
# Materialise DeerFlow's virtual path layout under the writable HOME.
# Best-effort: on failure the file APIs still work via the home remap.
try:
result = remote.exec("sh", "-lc", _bootstrap_script(self._config["home_dir"]), timeout=_BOOTSTRAP_TIMEOUT)
if result.exit_code not in (0, None) or "BOOTSTRAP_OK" not in (result.stdout_text or ""):
logger.warning(
"Tenki bootstrap for %s exited code=%s stderr=%s",
sandbox_id,
result.exit_code,
(result.stderr_text or "").strip(),
)
except Exception as e:
logger.warning("Tenki bootstrap for %s raised: %s", sandbox_id, e)
logger.info("Created Tenki sandbox %s (name=%s)", sandbox_id, self._sandbox_name(sandbox_id))
return TenkiSandbox(
sandbox_id,
remote,
default_env=self._config["environment"],
home_dir=self._config["home_dir"],
on_terminal_failure=self._invalidate_sandbox,
)
@staticmethod
def _terminate_orphan(sandbox_id: str, remote: Any) -> None:
"""Terminate a microVM that was created but never handed to the adapter."""
try:
remote.close()
logger.warning("Terminated Tenki sandbox %s after it failed to become ready", sandbox_id)
except Exception as e:
logger.error("Leaked Tenki sandbox %s (id=%s): could not terminate after readiness failure: %s", sandbox_id, getattr(remote, "id", "?"), e)
@staticmethod
def _close_quietly(sandbox: TenkiSandbox, *, context: str) -> None:
"""Close a sandbox where the caller has no way to act on a failure."""
try:
sandbox.close()
except Exception as e:
logger.warning("Error closing Tenki sandbox %s (%s): %s", sandbox.id, context, e)
def get(self, sandbox_id: str) -> Sandbox | None:
with self._lock:
return self._sandboxes.get(sandbox_id)
def release(self, sandbox_id: str) -> None:
"""Release a sandbox into the warm pool — the microVM stays running.
The sandbox moves from ``_sandboxes`` to ``_warm_pool`` and its
``_thread_sandboxes`` entries are cleared. It is NOT terminated unless
shutdown has already begun.
"""
close_sandbox: TenkiSandbox | None = None
with self._lock:
sandbox = self._sandboxes.pop(sandbox_id, None)
for key in [k for k, sid in self._thread_sandboxes.items() if sid == sandbox_id]:
self._thread_sandboxes.pop(key, None)
if sandbox is None:
return
if self._shutdown_called:
close_sandbox = sandbox
else:
self._warm_pool[sandbox_id] = (sandbox, time.time())
if close_sandbox is not None:
self._close_quietly(close_sandbox, context="released during shutdown")
logger.info("Closed released Tenki sandbox %s because shutdown is in progress", sandbox_id)
else:
logger.info("Released Tenki sandbox %s to warm pool (microVM still running)", sandbox_id)
def _reclaim_warm_pool(self, sandbox_id: str) -> str | None:
"""Reclaim a warm-pool sandbox by id after a liveness health check.
Returns sandbox_id on success, None if not present or the health check
fails (the dead entry is destroyed).
"""
with self._lock:
if sandbox_id not in self._warm_pool:
return None
sandbox, _ = self._warm_pool[sandbox_id]
try:
result = sandbox.execute_command("echo ok", timeout=10)
healthy = "ok" in result
except Exception as e:
logger.warning("Tenki warm-pool sandbox %s health check error: %s", sandbox_id, e)
healthy = False
if not healthy:
with self._lock:
warm_entry = self._warm_pool.pop(sandbox_id, None)
if warm_entry is not None:
self._destroy_warm_entry(sandbox_id, warm_entry[0], reason="health_check_failed")
return None
with self._lock:
warm_entry = self._warm_pool.pop(sandbox_id, None)
if warm_entry is None:
return None # Raced with another thread
self._sandboxes[sandbox_id] = warm_entry[0]
logger.info("Reclaimed warm-pool Tenki sandbox %s", sandbox_id)
return sandbox_id
def reset(self) -> None:
"""Park tracked sandboxes into this instance's warm pool for later cleanup.
``reset_sandbox_provider()`` drops the singleton and calls this so config
changes take effect on the next construction. Teardown belongs to
``shutdown()``; reset leaves running microVMs alive but visible to this
instance's idle reaper and atexit shutdown instead of orphaning them.
"""
with self._lock:
now = time.time()
for sandbox_id, sandbox in self._sandboxes.items():
self._warm_pool.setdefault(sandbox_id, (sandbox, now))
self._sandboxes.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
def shutdown(self) -> None:
with self._lock:
if self._shutdown_called:
return
self._shutdown_called = True
self._stop_idle_checker()
with self._lock:
active = list(self._sandboxes.values())
warm = [sandbox for sandbox, _ in self._warm_pool.values()]
self._sandboxes.clear()
self._warm_pool.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
for sandbox in active + warm:
self._close_quietly(sandbox, context="shutdown")