Zeren Wang bb75f8d736
feat(sandbox): share sandbox identity derivation and acquire serialization (#4741) (#5089)
* feat(sandbox): share sandbox identity derivation and acquire serialization (#4741)

Remote providers (AIO, E2B, BoxLite, Tenki, OpenSandbox) each inlined the
same sha256(user:thread)[:16] sandbox-id expression and kept per-scope lock
dicts that grew unboundedly until shutdown. This extracts both mechanisms
into shared components without changing provider lifecycle, ids, capacity
semantics, or public tool behavior:

- sandbox/identity.py: keyword-only derive_sandbox_scope_token (byte-pinned
  compatibility contract) + is_sandbox_scope_token; per-provider golden
  vectors pin current behavior including BoxLite's raw-None quirk and each
  provider's private user_id resolution.
- sandbox/acquire_serialization.py: AcquireSerializer — per-key lock table
  with holder/waiter refcount reclamation, bounded dedicated executor
  (async waits off both the event loop and the default executor),
  worker-owned cancellation cleanup (no event-loop callback dependency), idempotent close().
- Each provider adopts both components; AIO/E2B key by (user_id, thread_id)
  with acquire and (E2B) release serialized; BoxLite/Tenki/OpenSandbox key
  by derived sandbox id and offload the whole sync acquire to the
  serializer's executor so a cancelled awaiter cannot overlap a retried
  same-scope body (leaked-remote-VM regression caught in review).
- thread_id=None acquires stay unserialized; provider shutdown()/reset()
  close the serializer; E2B capacity/ledger/reconciliation and AIO
  ownership/flock machinery untouched.
- blocking-IO anchor proves contended OpenSandbox acquire_async stays off
  the event loop (teeth verified red/green); AGENTS.md documents the
  shared components.

* refactor(sandbox): address review on acquire serialization (#5089)

- Replace unreachable checkin branch with an assertion: run() returns
  False only after abandon(), which the except handler always re-raises;
  the old _checkin would have double-decremented the refcount.
- Document the task.cancelling() == 0 assumption in hold_async.
- Drop unused thread_id/user_id kwargs from BoxLite and Tenki
  _acquire_scope_locked (OpenSandbox still forwards them).

* fix(sandbox): preserve request ContextVars in acquire executor bridge (#5089)

loop.run_in_executor() does not copy contextvars, unlike the inherited
SandboxProvider.acquire_async() which used asyncio.to_thread(). The
BoxLite/OpenSandbox/Tenki acquire_async bridges introduced in this PR
therefore dropped the request trace id (logged as trace_id=-).

Add AcquireSerializer.run_on_executor(), which copies the calling
context and runs the callable through ctx.run, and route all three
providers through it. Add regression tests binding request_trace_context
and verifying the worker thread observes it.
2026-08-30 10:30:34 +08:00
..

OpenSandbox backend

Runs DeerFlow sandboxes on OpenSandbox, using the synchronous Python SDK behind DeerFlow's Sandbox and SandboxProvider contracts.

Installation and configuration

Install the optional SDK, then select the provider:

pip install "deerflow-harness[opensandbox]"
sandbox:
  use: deerflow.community.opensandbox:OpenSandboxProvider
  image: python:3.11
  # api_key: $OPEN_SANDBOX_API_KEY
  # domain: localhost:8080
  # protocol: http
  # request_timeout: 30
  # ready_timeout: 30
  # use_server_proxy: false
  # sandbox_timeout: 14400  # remote lifetime; 0 means explicit cleanup only
  # bash_command_timeout: 600  # default command timeout
  # replicas: 3             # active + warm sandboxes per gateway process
  # idle_timeout: 600       # warm seconds before destroy; 0 disables reaping
  # environment:
  #   PYTHONUNBUFFERED: "1"

api_key and domain may be omitted when OPEN_SANDBOX_API_KEY and OPEN_SANDBOX_DOMAIN are set. use_server_proxy is useful when DeerFlow can reach the OpenSandbox management service but cannot directly reach sandbox execd endpoints.

Values in sandbox.environment that start with $ are resolved from the Gateway process environment when the provider starts. Missing variables resolve to an empty string, matching the E2B provider.

Lifecycle and contract

The provider derives a stable local ID from (user_id, thread_id). A released sandbox enters an in-process warm pool and only the same scope may reclaim it. Create returns only after the SDK readiness check and DeerFlow's /mnt/user-data/{workspace,uploads,outputs} bootstrap succeed. A bootstrap failure explicitly destroys the newly created remote sandbox. Each remote has an independent SDK connection transport. Before an operation, the adapter renews sandbox_timeout; commands use bash_command_timeout when the caller supplies no timeout and extend the renewal horizon when necessary. Operations are serialized per remote so a shorter renewal cannot overwrite the horizon of an in-flight long command. Setting sandbox_timeout: 0 selects explicit-cleanup mode and disables renewal.

The full DeerFlow surface is implemented:

  • execute_command forwards per-call environment variables and positive wall-clock timeouts through RunCommandOpts, preserving stdout, stderr, and non-zero exit information in DeerFlow's string result.
  • Text and binary file operations use OpenSandbox's native filesystem API. Append is serialized as a read-modify-write because SDK 0.1.x has no append primitive.
  • list_dir, glob, and grep use portable find/grep commands and the shared DeerFlow result parsers.
  • All paths must be absolute and traversal-free. Artifact downloads are further restricted to /mnt/user-data.
  • Command-path HTTP 404, HTTP 410, unhealthy-session errors, and broken transports evict the dead client so the next acquire cold-starts a replacement. A file-path 404 remains an ordinary missing-file error.

reset() parks active clients for later cleanup; shutdown() destroys active and warm remotes. Cross-process discovery and ownership coordination are not implemented yet, so each Gateway process has its own warm pool and capacity accounting.