* 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>
40 KiB
Sandbox System (packages/harness/deerflow/sandbox/)
Interface: Sandbox: execute_command(command, env=None), additive execute_command_in_scope(..., scope_id=...) / release_command_scope(scope_id), read_file, write_file, list_dir, glob, grep. Scoped hooks default to pass-through without server-side sessions, preserving third-party subclasses. grep accepts a text file or directory tree. Per-call env injects secrets: LocalSandbox merges into the subprocess environment; AioSandbox uses fresh bash.exec(env=...) sessions. list_dir: missing path → FileNotFoundError; command/client failure → OSError, never [] (ls_tool: (empty)). Remotes use sandbox/remote_list_dir.py: capture find's status, not | head's (sh -lc lacks pipefail); missing binary → OSError; truncation SIGPIPE → success.
Provider Pattern: SandboxProvider exposes acquire, acquire_async, get, release. Async agent/tool paths use async hooks to keep Docker creation, discovery, cross-process locking, readiness polling, and release off-loop. Set supports_agent_skill_isolation=True only when the whole tool surface enforces explicit lead Agent policy: bind mounts use prepared thread roots; upload providers implement sync_agent_skills. Host-backed providers report false if an enabled shell bypasses path mappings. Under explicit policy, middleware rejects unsupported providers before acquire.
Shared components (RFC #4741): remote IDs use derive_sandbox_scope_token (sandbox/identity.py); preserve its keyword-only SHA-256/16-hex contract to avoid orphaning containers. AcquireSerializer (sandbox/acquire_serialization.py) serializes selected acquire/release transitions with a bounded, refcounted per-key threading.Lock table and dedicated bounded executor (no event-loop/default-executor blocking). Workers own cancellation cleanup without waiting for cancelled tasks to resume; provider shutdown()/reset() calls idempotent close(). Keys: AIO (user_id, thread_id), E2B (user_id, thread_id, skills_root), BoxLite/Tenki/OpenSandbox derived id. Random-UUID thread_id=None acquires bypass serialization.
Execution leases (sandbox/lease.py, #5128): cross-instance ownership decides which Gateway may reap a container; process-local SandboxLeaseManager tracks concurrent lead, subagent, Gateway-request, and channel-upload users of one client. Runs get ephemeral owners, persisted sandboxes are retained idempotently, and the last holder performs any pending SandboxProvider.release. Outer lifecycle fences repeat idempotent release after their complete graph/tool/request batch drains; per-tool terminal Command wrappers never release because sibling handlers may still run. Fork-restored children and upload syncs use non-releasing holders: they fence the client and own scope cleanup without themselves requesting a park; an earlier normal-owner request waits for them, and a missing fork client is replaced by a normal owner. Persisted lookup plus retention is serialized per (user_id, thread_id); stale bindings fall through to acquire, and a post-acquire lookup miss rolls back before raising. Provider I/O does not hold the metadata lock. Repeated cancellation cannot interrupt acquire/rollback/release reconciliation or let a to_thread sandbox operation outlive its enclosing execution/request holder; failures are logged without replacing the original cancellation. Lease/scope context IDs are server-owned: Gateway and worker scrub caller values; only the internal subagent path assigns a task ID. Managers are registered by provider object identity, not hash/equality, so unhashable custom providers remain valid. Subagent owners also serve as sandbox_command_scope_id: AIO gives each scope one ordered persistent shell session, replaces it after ErrorObservation, and cleans it on lease release. Registry identity is revalidated after every scope-lock wait, preventing queued commands from resurrecting released sessions. Env-bearing commands use fresh bash.exec sessions so secrets do not persist.
Authorization gate (sandbox:execute, RFC #4063 Phase 3): every sandbox-backed tool call passes through the gate in deerflow/authz/sandbox_authz.py - a binary authorize(principal, "sandbox", "execute", target="*") check before either reusing a persisted sandbox id or calling provider.acquire. Rechecking reuse is required because authorization config and user roles can change while the sandbox remains cached. Sync tool invocations call authorize_sandbox_execution; async tool invocations await authorize_sandbox_execution_async exactly once. A task-local ContextVar scopes that single decision across the complete composed tool invocation, including ReadBeforeWriteMiddleware's pre-write inspection, tool body, and post-read mark; the value is copied into asyncio.to_thread workers. Authorization denial is converted to the normal error ToolMessage at the composed middleware boundary and is explicitly excluded from the gate's generic fail-open handlers. Async config loading and provider class discovery/import are offloaded before aauthorize() so reused sandbox calls do not hash config files or import custom modules on the event loop; provider construction remains on the running event loop because async providers may initialize loop-affine clients. The gate lives at the single tool initialization entry point (ensure_sandbox_initialized / ensure_sandbox_initialized_async in tools.py), while SandboxMiddleware.before_agent / abefore_agent apply the matching sync/async check to eager acquisition. Deny raises SandboxAuthorizationError (sandbox/exceptions.py), which propagates out of ordinary tool execution as a friendly error ToolMessage ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (authorization calls and provider resolution) follow authorization.fail_closed / fail_open; no readable config.yaml or authorization.enabled: false makes the gate a no-op (safe_app_config tolerates missing config). Gateway upload/artifact sync calls try_acquire_sandbox_for_request (app/gateway/authz.py), which gates, returns a request lease, and skips sync on deny while preserving the primary operation. Callers release after their last sandbox operation; artifacts request normal parking, uploads do not. Tests: tests/test_sandbox_authorization.py and tests/blocking_io/test_sandbox_authorization.py.
Environment policy (sandbox/env_policy.py): execute_command no longer inherits the full os.environ. build_sandbox_env() scrubs secret-looking names (*KEY*/*SECRET*/*TOKEN*/*PASS*/*CREDENTIAL*) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. OPENAI_API_KEY) never leak into skill subprocesses. Benign vars (PATH, HOME, LANG, VIRTUAL_ENV, ...) are preserved.
Implementations:
LocalSandboxProvider- Local filesystem execution.acquire(thread_id)returns a per-user/threadLocalSandbox(idlocal:{user_id}:{thread_id}) whosepath_mappingsresolve/mnt/user-data/{workspace,uploads,outputs}and/mnt/acp-workspaceto that thread's host directories, so the publicSandboxAPI honours the/mnt/user-datacontract uniformly with AIO.acquire()/acquire(None)keeps the legacy generic singleton (idlocal) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by athreading.Lock. Shared runs use category mappings; a policy-scoped run replaces them with one/mnt/skillsroot mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled hostbashsubprocess can use canonical paths withoutPathMapping, sosupports_agent_skill_isolationis dynamic and explicit Agent policies fail closed while host bash is enabled. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeatedrealpath()walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.AioSandboxProvider(packages/harness/deerflow/community/) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire.get()remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (_renew_owned_leases). Text appends use the AIO file API's native append mode rather than a client-side read-modify-write, so a failed pre-read cannot turn an append into an overwrite.reset()closes the per-instance acquire serializer so replacing the singleton cannot retain its executor workers; full remote sandbox teardown remainsshutdown().uses_thread_data_mountsdefaults to backend detection (LocalContainerBackend=True, remote/provisioner backends=False), while the optionalsandbox.thread_data_mountsboolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting ittrueskips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. An explicit Agent policy uses four thread projection category mounts and a distinct deterministic sandbox identity, preventing reuse of an older container created with shared mounts.skills.container_pathis a provider-startup snapshot shared by mount construction, sandbox identity, the remote Gateway request, and provisioner validation; custom roots are identity-scoped so a container or Pod created for one destination cannot be reused after the root changes. The Gateway and provisioner independently require one canonical absolute root that does not overlap reserved platform mounts, and both derive the four category allowlist entries from that root. The provisioner accepts all four category overrides; when all are present it suppresses the default hostPath or skills-PVC mount. WithUSERDATA_PVC_NAME, the thread projection categories use subpaths on that shared data PVC. Readiness probes andagent_sandboxclients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and settrust_env=False; external FQDNs and public IPs retain environment proxy support.E2BSandboxProvider(packages/harness/deerflow/community/e2b_sandbox/) provides E2B remote isolation. New unrestricted sandboxes receive a one-shot upload from the enabled-only public, custom, legacy, and managed integration projections. For a thread with an explicit Agent policy, creation skips that shared upload; after acquire,sync_agent_skillsclears only DeerFlow's four managed category directories and signature before strictly uploading the signed thread projection. It rejects non-canonical paths, protected mounts/homes, symlinked roots, and standard operating-system trees before destructive work. E2B rebuilds the managed categories on every policy sync; it deletes legacy sandbox-visible signature markers instead of trusting them as proof that the remote tree is intact. Reset plus all four uploads hold the same per-user/thread and skills-root serializer used by acquire and release, so overlapping policy syncs cannot interleave a wipe with another run's upload. Delegated subagents are non-owners of the lead's thread projection: they reuse that filesystem view and never rebuild it from their discovery/activation policy. The provider snapshotsskills.container_pathat startup and carries it through mount construction, the warm-pool seed, remote metadata, discovery, reconciliation, and synchronization. A VM from a different root is never adopted and is reaped after the duplicate grace period once no live peer owns it. Acquire and release share anAcquireSerializerhold keyed by(user_id, thread_id, skills_root). The serializer does not cover remote IO.burst_limitadds capacity only for theburstpolicy. Thewaitpolicy fails the turn afteracquire_timeout. The runtime does not retry the turn automatically. E2B acquisition uses a bounded executor. Waiting calls do not consume the default asyncio executor. Therejectpolicy can evict one warm VM before it returns an error. With memory ownership,replicaslimits one Gateway process. Redis ownership shares one<ownership.key_prefix>:e2b-capacityHash, making the limit (plus a bounded burst) deployment-wide. Lua atomically manages VM and in-flight-create entries; missing or unavailable state fails closed. E2B reservation metadata repairs interrupted creates. Inventory replacement is revision-CAS guarded, and incomplete inventories never remove entries; complete omissions get a grace period. Uncertain cleanup keeps a tombstone slot. Shutdown tracks owned remote operation IDs. Discovery can find a VM from another Gateway. Shutdown closes an unowned discovery client without destroying its VM. Release ends its transition count when the VM enters the warm pool. Local client cleanup does not consume a second slot. A create that returns after shutdown retries one failed kill through a new client. An unconfirmed remote ID stays tracked.reset()uses full shutdown semantics. It destroys tracked active and warm VMs. It wakes capacity waiters. Callers cannot reuse the old provider instance. A background startup pass and periodic reconciliation list provider-tagged remote sandboxes within page/item/time budgets, probe every candidate until a healthy canonical sandbox is found, adopt only through the shared ownership store, and reap duplicates/orphans only after their configured grace/TTL and an atomicdel:claim. Lease renewal is independent of reconciliation. Failed canonical adoption clears both its capacity reservation and acquire-intent marker even if a peer takes ownership between the initial claim and bootstrap cleanup. Shutdown kills only IDs whose leases are owned by this provider instance; peer-owned clients are merely closed.- Cross-instance ownership store (
aio_sandbox/ownership/, #4206): gateway instances sharing a container backend coordinate container ownership through a pluggable lease store, selected bysandbox.ownership.type(memory|redis) and resolved likestream_bridge(factory.py, lazy per-branch import,redisoptional extra,DEER_FLOW_SANDBOX_OWNERSHIP_REDIS_URLenv escape hatch; a setDEER_FLOW_STREAM_BRIDGE_REDIS_URLimplies a multi-instance deployment and infersredis).memoryis single-instance only and declaressupports_cross_process = False.- A lease answers "who reaps this container", not "who may use it". That splits the interface in two:
take()transfers ownership on the acquire path (a container is deterministic per user/thread, so consecutive turns legitimately land on different instances — a conditional claim there would strand the thread until the previous lease expired), whileclaim()succeeds only if the container is unowned or already ours and gates every adopt/reap path.release()never clears a peer's lease. - A lease carries a state, and that is what makes the destroy window safe.
own:= responsible for this container;del:= tearing it down (claim(..., for_destroy=True)).take()is refused against adel:lease, so a container cannot be re-acquired between a destroy path's claim and its container stop. Without the two states an unconditionaltake()would silently overwrite the destroyer's claim and the peer's stop would land on a container the new owner had already handed to an agent — i.e. #4206 again. That pairing is what replaced the previous same-hostflockguard, which is gone; Redis makes the scope genuinely multi-instance instead of same-host. A destroyer that dies mid-stop leaves adel:marker that lapses with the TTL. On the acquire path a refused take raisesSandboxBeingDestroyedError: the reuse/reclaim paths drop the container and cold-start, and the discover path propagates (falling through to create would collide with the not-yet-removed container name). - The
del:state has to be held for the stop, not just written before it. The two states alone do not makeflockredundant — a held lock cannot expire, whereas a lease can, andclaim(..., for_destroy=True)writes the marker with the ordinary lease TTL. Nothing else refreshes it:renew()extends onlyown:and deliberately reports a teardown asLOST, and the destroy paths drop the sandbox from the maps_renew_owned_leasesiterates. So a container stop that outlived the TTL let the marker lapse, a peer'stake()succeeded against the still-running container, and the stop then landed on the turn that had just been handed it — the exact windowdel:exists to close, reopened by its own expiry._held_teardown_leasewraps everydel:-marked stop —_destroy_warm_entry,destroy(), and_drop_unhealthy_sandbox— and re-claims the marker everyrenewal_interval_secondsuntil the stop returns._drop_unhealthy_sandboxneeds it most: it untracks before claiming (under itsexpected_infoTOCTOU guard), so_renew_owned_leasescannot see the id either. The final release is the heartbeat's own last act, not the caller's — a refreshclaimstill in flight when the context exits (the store's socket timeout bounds it, but it can be mid-round-trip) would otherwise land after a caller-side release and rewritedel:on a container whose stop already completed, stranding a freshtake()(or rolling back a fresh create) until the TTL. Releasing from inside the heartbeat, after its loop stops, sequences the release strictly after the last refresh, so no claim can follow it; the context join is bounded and, on a genuine wedge, defers the release to that thread rather than clearing the marker itself. This covers a failed stop too (the container is probably still up, and a marker left behind refuses its own thread'stake()until the TTL lapses);destroy()still lets the error propagate out of thewith—shutdown()logs per sandbox off it.RedisOwnershipStoresets asocket_timeoutso no store round trip — and so no heartbeat refresh — can block unbounded, keeping that deferred release finite. This needs no abnormal backend: the schema bounds onlyrenewal_interval_seconds(> 0) andttl_multiplier(>= 2), so a legal config puts the TTL below a normal container stop.LocalContainerBackend._stop_containernow passes atimeouttosubprocess.run(_STOP_TIMEOUT_SECONDS) so a wedged daemon cannot block unbounded — that bounds the residual window independently of the ownership layer, for the case where thedel:marker lapses mid-stop (a store outage longer than the TTL) and the stop then lands on a container a peer has been handed. A timed-out stop propagates rather than being swallowed like aCalledProcessError: the container is probably still running, so reporting a clean stop would drop the warm entry and leak it. The TTL stays finite on purpose — the heartbeat dies with the process, so a destroyer that crashes mid-stop still releases the container one TTL later instead of marking it undestroyable forever. Raising a separate teardown TTL instead would only be sufficient if it were bounded above every backend's real stop deadline. - Fail-closed both directions. Establishment: a sandbox whose ownership cannot be published is never handed out (a just-created container is destroyed rather than leaked as an adoptable orphan) — acquiring raises
OwnershipBackendError, matching the stream bridge's fail-hard v1 policy. Reaping: a store that cannot answer is treated as peer-owned, so an outage never turns live peer containers into orphans. Renewal is the deliberate exception: an unanswerable store there means unknown, not lost, so_refresh_ownershipkeeps the sandbox and retries — failing closed on that path would evict every live sandbox on every instance the moment the store blinked. The TTL still bounds how long a genuinely dead owner holds a lease. Both paths that stop a container they still track —destroy()and_destroy_warm_entry— claim before untracking, so a refused claim cannot leave a container running and untracked. (_drop_unhealthy_sandboxuntracks first, under itsexpected_infoTOCTOU guard, then claims before the stop; a refused claim there leaves the container to the next reconcile, which re-adopts it after the grace.) - A lease excludes peers, never ourselves — same-process exclusion is the provider's job.
claim()andtake()both succeed against this instance's ownown:lease by design (that is what lets a destroy path claim what it already owns), sodel:says nothing to this process's other threads. Every reaper — idle checker, renewal, warm eviction, unhealthy drop — decides outsideself._lock, because a store round trip must not be held under the lock that guards every acquire; so each one acts on a decision its own acquire path may already have invalidated. Two guards cover the two directions, and both live inAioSandboxProvider, not the store:- Reaping (
_reserve_local_teardown/_local_teardown): the reaper marks the id, and every promote path —_reuse_in_process_sandbox,_reclaim_warm_pool_sandbox,_register_discovered_sandbox— refuses a marked id exactly as it refuses a peer'sdel:(drop and cold-start). The "is this still reapable?" check runs in the same critical section as the mark, passed down as astill_reapablepredicate rather than run by the caller beforehand: checking first and marking second is the window, not a narrower version of it. This matters most where the entry deliberately stays visible during the stop — both warm reapers defer their pop so a refused claim cannot lose the container — and where the maps are cleared first (_drop_unhealthy_sandbox), which leaves backend discovery as the open path. Onmainthe mixin's_evict_oldest_warm/_reap_expired_warmpopped under the lock, so the deferred pop is what made this reachable. - Forgetting (
_acquire_epoch): whenrenew()reportsLOSTthe peer legitimately wins, so here the promote is the thing to detect._publish_ownershipbumps a per-id acquire epoch;_renew_owned_leasesandrelease()snapshot it before the round trip and hand it to_forget_lost_sandbox, which skips the pop if it moved. Object identity is not enough: the reuse path re-publishes ownership while handing out the same trackedAioSandbox, so an identity check sees nothing and the pop closes a client mid-turn. - A guard must become visible no later than the transition it guards. The epoch cannot satisfy that for
take(): the takeover is durable beforetake()returns (redis has committed the SET while the reply is in flight), and the epoch can only be written afterwards, so a renewal holding an olderLOSTwalks through the gap, drops the maps, and closes the client the acquire is about to hand back — acquire then returns an id whoseget()isNone._publish_ownershiptherefore publishes an intent mark (_acquire_inflight) under_lockbefore the round trip; the epoch covers the other half, "an acquire completed since you decided"._forget_lost_sandboxhonours the intent mark unconditionally, not only when an epoch is supplied — "no epoch" must not read as "no guard". - A reservation must cover the removal, not just the stop.
_destroy_warm_entrypops the warm entry itself, inside the reservation. Releasing the reservation when the stop returns and letting the caller pop afterwards leaves a gap where the container is stopped, the entry is still in_warm_pool, and nothing marks it — a reclaim there hands out a dead container. The pop stays deferred relative to the stop (a refused or failed stop keeps the entry), just no longer relative to the reservation. - A check taken before a round trip must be retaken after it.
_reuse_in_process_sandboxre-verifies both its map entry and the local teardown reservation,_reclaim_warm_pool_sandboxre-checks the reservation, and_register_discovered_sandboxre-checks before installing its client, all after publishing ownership. Before the intent mark is set a renewal'sLOSTis both current and correct, so the forget can legitimately remove the entry the acquire decided to hand out; independently, a local reaper can reserve an id while reuse is outside_lockfor its health/store calls and deliberately leaves the map entry present until its destroy claim succeeds. Falling through re-discovers or cold-starts instead of returning/installing a client for either stale decision. The pre-round-trip checks remain as early-outs that skip backend and store work on an already-doomed entry. - Adoption is a promote too.
_reconcile_orphanshonours the reservation: a container being torn down is untracked and still running, which is exactly the shape that loop adopts, and neither the claim (ours) nor the recovery grace (skipped entirely onmemory, wheresupports_cross_processisFalse) excludes it. - Active and warm are exclusive, and only a promote can violate it. Both register paths pop
_warm_poolinside the same locked section that inserts into_sandboxes: a warm entry for an id is stale the moment that id becomes active, and leaving it gives the container two reapers —_reap_expired_warmjudges it by the warm timestamp and never consults_last_activity, so it stops a container an agent is using while_sandboxesstill hands out its client. Reachable because reconciliation adopts into the warm pool inside the register's publish → track window, and onmemoryit adopts on sight (_adoptable_after_graceshort-circuits whensupports_cross_processisFalse, so an id carrying this process's own lease reads as adoptable). Onmainthe track was a single locked insert with nothing before it, so the window did not exist. A non-destroyclaim()is the one case the store does police against its own owner: it refuses to overwrite our owndel:, because the stop it marks is already in flight and downgrading the marker would let atake()hand out a container about to die. Enforced in both backends (Lua and Python) so they cannot drift.
- Reaping (
- Renewal is independent of
idle_timeout(_start_lease_renewal, own daemon thread; TTL =renewal_interval_seconds × ttl_multiplier). Renewal used to ride on the idle checker, which__init__only starts whenidle_timeout > 0— soidle_timeout: 0("keep warm VMs until shutdown", a documented config) let every lease lapse. Liveness and reaping must not share a switch. Renewal covers warm entries as well as active ones; losing a lease drops the sandbox from this instance's maps without touching the container (_forget_lost_sandbox) — destroying it there would be the very cross-instance kill this store prevents.- A warm teardown is the local exception to that forget rule:
_destroy_warm_entrydeliberately keeps the entry in_warm_pooluntil the backend stop succeeds, while its owndel:marker makes ordinaryrenew()reportLOST._forget_lost_sandboxtherefore honours_local_teardown; otherwise the renewal thread can pop the retained entry mid-stop and a failed stop leaves a running container untracked.
- A warm teardown is the local exception to that forget rule:
renew()distinguishes lapsed from lost (RenewOutcome), and the two must not be collapsed.LAPSEDmeans the lease is simply absent — nobody took it — so_refresh_ownershipre-establishes it;LOSTmeans a peer holds it and it is never re-taken. Treating an absent lease as lost meant a Redis restart without persistence (every key gone) evicted every in-flight sandbox on every instance at once.- Renewal's fail-open rule covers both store round trips. If
renew()returnsLAPSEDbut the follow-upclaim()cannot answer, ownership is still unknown rather than lost, so the provider keeps the sandbox and retries. The ordinary_claim_ownershiphelper remains fail-closed for adopt/reap callers and is intentionally not used for this re-claim.
- Renewal's fail-open rule covers both store round trips. If
- Teardown join budget covers refresh plus release. Redis bounds each ownership operation at five seconds, and context exit can catch the heartbeat in one final refresh before its
finallyperforms the final release._TEARDOWN_JOIN_TIMEOUT_SECONDSis therefore 12 seconds — greater than both sequential operation bounds — so a normal pair of socket timeouts does not emit the deferred-release warning; a still-running heartbeat continues to own the release safely. - An absent lease means the same thing on both paths, and reconciliation must say so too. The
LAPSEDrule above only covers an owner renewing its own lease; on its own it does not make state loss safe, because reconciliation reads the same absent key as "orphan, adopt". After a Redis flush (restart without persistence, or eviction undermaxmemory) every owner is alive and merely pre-renewal-tick, so whichever instance reconciles first would adopt every live container, each real owner's next renewal would reportLOST, and it would drop a sandbox mid-turn for the adopter to idle-destroy — #4206 through the back door._adoptable_after_gracecloses it: an untracked container must be seen unowned (owner(), a read-only peek — the atomicclaim()is still what actually gates adoption) across a full lease TTL before it can be adopted, tracked per container in_unowned_since. That rebuilds the delay the flush erased — a live owner republishes within one renewal interval, shorter than the TTL by construction (ttl_multiplier >= 2) — while a genuinely crashed owner never republishes, so its containers are still adopted one grace later rather than leaking. A republished lease resets the grace; a pausing-only timer would still expire over a live owner's lease. The grace is skipped whensupports_cross_processisFalse: no peer can hold a lease such a store would show us, so single-instance deployments keep instant orphan cleanup, and a grace could not help a multi-worker gateway onmemoryanyway (peers are invisible to each other's leases with or without it). - The
memorystore is single-instance only and says so viasupports_cross_process = False; the provider logs a warning at startup when the configured store cannot see peers. A multi-worker gateway onmemoryhas no cross-process coordination at all — same contract asstream_bridge's memory backend. This is why the redis inference matters: it readsapp_config.stream_bridgeand the env var, in the same order the bridge's own resolver does, so any deployment already pointing the bridge at Redis (i.e. every multi-instance one) gets a redis ownership store without extra config. get()stays a pure in-memory lookup and must never call the store (that is blocking filesystem/network IO on the event loop); anchored bytests/blocking_io/test_aio_sandbox_get.py, which injects a deliberately-blocking probe store so the anchor keeps its teeth regardless of the configured backend. Tests:tests/test_sandbox_ownership_store.py(store contract, defined once for both backends — the redis tier is@pytest.mark.integration, usesDEER_FLOW_TEST_REDIS_URLwhen set, and otherwise self-skips without a reachable Redis. Backend CI provisions Redis, so the merge gate executes the real Lua tier; there is no fake-redis tier because a fake would not execute the Lua exclusions) andtests/test_sandbox_orphan_reconciliation.py(provider behaviour, two providers sharing one store).
- A lease answers "who reaps this container", not "who may use it". That splits the interface in two:
- Cross-instance ownership store (
BoxliteProvider(packages/harness/deerflow/community/boxlite/) - BoxLite micro-VM isolation. Theboxliteruntime is optional (deerflow-harness[boxlite]) and lazy-imported only when this provider is selected. The provider owns one private asyncio event loop on a daemon thread because BoxLite handles are loop-affine; syncSandboxcalls marshal onto that loop withrun_coroutine_threadsafe. Boxes are named deterministically fromuser_id:thread_id, released into an in-process warm pool after each agent turn, and reclaimed only by the same user/thread. Warm-pool health checks use a short explicit timeout and forward that timeout through both BoxLiteexec(timeout=...)and the private-loop.result(timeout)bridge so a hung VM cannot pin the per-thread acquire lock indefinitely.sandbox.replicascaps active + warm VMs per gateway process; if capacity is exhausted, only warm-pool VMs are evicted.sandbox.idle_timeoutstops idle warm VMs after the configured seconds.reset()is intentionally a lightweight registry clear forreset_sandbox_provider()and does not close boxes, stop the idle reaper, or close the private loop; full teardown remainsshutdown().TenkiSandboxProvider(packages/harness/deerflow/community/tenki/) - Tenki cloud microVM isolation. ThetenkiSDK is optional (deerflow-harness[tenki], importable astenki_sandbox) and lazy-imported (_import_client) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's nativesandbox.fsAPI (read_text/read_stream/write_stream/mkdir/stat) — binary-safe and streaming, no base64/shell hop; only directory/content search (list_dir/glob/grep) shells out to busybox-portablefind/grep, parsed with the shareddeerflow.sandbox.searchhelpers likecommunity/e2b_sandbox. Sandboxes run as the unprivilegedtenkiuser, so DeerFlow's/mnt/user-dataprefix is remapped under a writable HOME (_resolve_path) and best-effortsudo-symlinked at bootstrap. Boxes are named deterministically fromsha256(user_id:thread_id)[:16](64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtinConnectionError/BrokenPipeError/EOFError) routes through_invalidate_sandboxto evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today).
Shared warm-pool lifecycle: community sandbox providers that keep released sandboxes alive for fast reuse share deerflow.community.warm_pool_lifecycle.WarmPoolLifecycleMixin. The mixin owns the common DEFAULT_IDLE_TIMEOUT=600, IDLE_CHECK_INTERVAL=60, DEFAULT_REPLICAS=3, idle-checker loop, warm-pool expiry, oldest-warm eviction, replica counting, and soft-cap logging. Providers remain responsible for their own active registries, creation/discovery, health checks, and destroy hook (_destroy_warm_entry): AIO destroys SandboxInfo through its backend; Boxlite closes loop-affine BoxliteBox handles; Tenki closes the microVM session (TenkiSandbox.close, which terminates the remote sandbox). AIO keeps active-idle cleanup outside the mixin and delegates only warm-pool expiry to the shared helper.
Virtual Path System:
- Agent sees:
/mnt/user-data/{workspace,uploads,outputs},/mnt/skills - Physical:
backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/...; raw skills stay underdeer-flow/skills/and managed integration storage. Unrestricted sandboxes readbackend/.deer-flow/skills_view/public/andbackend/.deer-flow/users/{user_id}/skills_view/{custom,legacy,integrations}/; explicit lead Agent policies readbackend/.deer-flow/users/{user_id}/threads/{thread_id}/skills_view/{public,custom,legacy,integrations}/. - Translation:
LocalSandboxProviderbuilds per-threadPathMappings at acquire time. Sandbox-backed readers resolve only/mnt/user-data/...in the tool layer; skills, ACP workspaces, and configured custom mounts stay virtual so the provider mount table remains the single source of acquire-time identity and visibility. Full reads, ranged reads, and read-before-write hashing share this path.tools.pykeepsreplace_virtual_path()/replace_virtual_paths_in_command()as a defense-in-depth layer (and for path validation). AIO has the directories volume-mounted at the same virtual paths inside its container, so both implementations accept/mnt/user-data/...natively. - Detection:
is_local_sandbox()accepts bothsandbox_id == "local"(legacy / no-thread) andsandbox_id.startswith("local:")(per-thread)
Sandbox Tools (in packages/harness/deerflow/sandbox/tools.py):
- Every sandbox tool keeps a model-visible
descriptionfield for a human-readable progress label, but the field is optional and defaults to an empty string. Tool execution must depend only on its operational arguments; the frontend supplies localized fallback labels when a provider omitsdescription. bash- Execute commands with path translation and error handling. ForLocalSandbox(host bash), output on POSIX and Windows is captured through bounded pipe-drain threads and stdin is/dev/null; Windows capture decodes with the platform text encoding and applies universal-newline translation, matching the formersubprocess.run(..., text=True)behavior for locale-code-page output, Python UTF-8 Mode, CRLF, and bare CR. That translation is Windows-only so the pre-existing POSIX output contract remains byte-decoded without newline rewriting. On POSIX, a backgrounded long-lived process (server &) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (sandbox.bash_command_timeout, default 600s); on timeout the whole POSIX process group or Windows process tree is killed and the agent gets a notice telling it to background long-lived processes. The shared bash tool description scopes host environment detection to LocalSandbox: start withuname -s, follow withsw_verson Darwin, and read Linux host system files only when the active policy permits them. Local path andfile://rejections provide the same conditional recovery guidance: command-only probes for environment questions, allowed virtual paths otherwise, and no repetition of the rejected path. The description also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. SeeLocalSandbox.execute_command, its platform runners, andbash_tool's docstring.ls- Directory listing (tree format, max 2 levels)glob- Find files or directories below a root directory with bounded resultsgrep- Search one text file or recursively search a directory, with optional glob filtering and bounded line-level resultsread_file- Read file contents with optional line rangewrite_file- Write/append to files, creates directories; overwrites by default and exposes theappendargument in the model-facing schema for end-of-file writes; subject to the read-before-write gate whenread_before_write.enabled(see Middleware Chain)str_replace- Substring replacement (single or all occurrences); same-path serialization is scoped to(sandbox.id, path)so isolated sandboxes do not contend on identical virtual paths inside one process; subject to the read-before-write gate whenread_before_write.enabled(see Middleware Chain)