wutongyuonce d8ed8160c9
fix(sandbox): stop list_dir from reporting failures as empty (#5264)
* fix(sandbox): stop list_dir from reporting failures as empty

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style(sandbox): format list dir test fixture

* style(sandbox): format remote list dir helper

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

---------

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

Tenki backend

Runs each DeerFlow sandbox as a Tenki cloud sandbox — an isolated microVM created from a stock base image, with no daemon or local virtualization to manage. A cloud-hosted alternative to the container-based AIO sandbox and the local-virtualization BoxLite backend.

Configuration

sandbox:
  use: deerflow.community.tenki:TenkiSandboxProvider
  api_key: $TENKI_API_KEY   # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var
  base_url: https://tenki.cloud  # optional; SDK default when omitted
  image: my-base-image      # optional; Tenki account default base image when omitted
  workspace_id: ws_...       # optional; auto-selected if the account has exactly one
  cpu_cores: 2               # optional per-sandbox vCPUs
  memory_mb: 2048            # optional per-sandbox memory
  replicas: 3                # active + warm microVM cap per gateway process (default: 3)
  idle_timeout: 600          # warm microVM idle seconds before terminate; 0 disables
  max_duration: 14400        # Tenki sandbox lifetime in seconds (default: 4h); 0 uses the account default
  sticky: false              # pin the microVM to its host (only matters with pause/resume)
  home_dir: /home/tenki      # writable dir backing /mnt/user-data (default: /home/tenki)
  environment:               # injected into every command (and as create-time env)
    PYTHONUNBUFFERED: "1"

Install the optional SDK before selecting this provider:

pip install "deerflow-harness[tenki]"

The tenki package (which provides the tenki_sandbox module) is an optional DeerFlow harness extra, not part of the default install. Get an API key from https://tenki.cloud/docs/sandbox/sdk.

Design

Tenki's Python SDK is synchronous, so — unlike BoxLite — the adapter calls the SDK directly with no event-loop bridge. Sandboxes are named deterministically from user_id:thread_id, released into an in-process warm pool after each agent turn, and reclaimed (after a liveness health check) by the same thread on the next acquire. A terminal session error evicts the sandbox and the next acquire rebuilds it; other transport errors surface to the caller (exec is never auto-retried — it is not idempotent, so re-running could double a command's side effects). Sandboxes are created with wait=False and awaited via wait_ready() so a readiness failure still leaves this provider holding the handle to terminate, and with an explicit max_duration so a long-lived thread does not lose its sandbox to Tenki's default lifetime mid-conversation.

File Role
provider.py SandboxProvider lifecycle, warm pool, scope resolution
sandbox.py Sandbox adapter; execute_command + file ops

Contract coverage

The full Sandbox surface is implemented. File transport uses Tenki's native sandbox.fs API; directory and content search shell out and reuse deerflow.sandbox.search, mirroring e2b_sandbox:

  • execute_commandsh -lc, with per-call env and timeout.
  • read_file / write_file / update_file — native fs.read_text / fs.mkdir / fs.write_stream (binary-safe, streamed).
  • download_file — native fs.read_stream, restricted to the /mnt/user-data prefix; the 100 MB cap is enforced on bytes actually received, so a file growing mid-transfer cannot slip past it.
  • list_dir / glob / grepfind / grep with busybox-portable flags (the fs API is single-level and has no content search); results filtered/capped in Python and reported back under /mnt/user-data.

Tenki sandboxes run as the unprivileged tenki user with /mnt root-owned, so DeerFlow's /mnt/user-data virtual prefix is remapped under the writable home_dir (like e2b_sandbox). The provider also best-effort sudo-symlinks /mnt/user-datahome_dir at create time so agent shell commands using the literal /mnt/... path still work; if sudo is unavailable the file APIs keep working via the remap.

Warm-pool capacity is governed by sandbox.replicas across active + warm sandboxes. sandbox.idle_timeout controls how long released warm sandboxes stay running; 0 disables idle reaping. Active sandboxes are never evicted to satisfy the cap.

Scope: stable features only

Only the stable Tenki surface is used — sandbox create/terminate plus exec/shell/filesystem over shell commands. Volumes, snapshots, and template builds are intentionally not used, so no prebaked image or unstable Tenki feature is required and any stock base image works.

Not yet implemented (follow-ups): cross-process orphan reconciliation (adopting sandboxes left by a previous gateway process) and a preview-URL surface.

Status

Verified end-to-end against live Tenki sandboxes: provider resolution → execute_command → full file-op surface (read/write/update/download, list_dir/glob/grep) → warm-pool reclaim → terminate, plus the /mnt/user-data sudo symlink. Unit tests run in CI without tenki installed; test_integration_real_sandbox exercises a real microVM when TENKI_API_KEY is set.