* 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.
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_commandforwards per-call environment variables and positive wall-clock timeouts throughRunCommandOpts, 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, andgrepuse portablefind/grepcommands 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.