Aari 9e0fbd60fa
fix(sandbox): isolate concurrent subagent shell sessions (#5134)
* fix(sandbox): isolate concurrent subagent shell sessions

* fix(sandbox): make execution acquire idempotent

* fix(sandbox): close execution lifecycle gaps

* fix(sandbox): serialize retained client lifecycle

* fix(sandbox): close remaining client lifecycle gaps

* fix(sandbox): unwind failed client lookup

* fix(sandbox): protect internal lease identities

* fix(sandbox): make cancellation reconciliation durable

* fix(sandbox): fence cancelled workers and IM uploads
2026-09-02 21:05:23 +08:00

226 lines
8.5 KiB
Python

import asyncio
import threading
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from deerflow.config import get_app_config
from deerflow.reflection import resolve_class
from deerflow.sandbox.sandbox import Sandbox
if TYPE_CHECKING:
from deerflow.skills.projection import SkillProjectionPaths
class SandboxProvider(ABC):
"""Abstract base class for sandbox providers"""
uses_thread_data_mounts: bool = False
needs_upload_permission_adjustment: bool = True
# Capability for enforcing a lead Agent's physical skill view across the
# provider's current Agent-accessible tool surface. Host-backed providers
# must return False whenever shell access can bypass managed path mappings.
supports_agent_skill_isolation: bool = False
@abstractmethod
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire a sandbox environment and return its ID.
Returns:
The ID of the acquired sandbox environment.
"""
pass
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire a sandbox without blocking the event loop.
Most sandbox providers expose a synchronous lifecycle API because local
Docker/provisioner operations are blocking. Async runtimes should call
this method so those blocking operations run in a worker thread instead
of stalling the event loop.
"""
return await asyncio.to_thread(self.acquire, thread_id, user_id=user_id)
def sync_agent_skills(
self,
sandbox_id: str,
*,
thread_id: str,
user_id: str,
projection: "SkillProjectionPaths",
) -> None:
"""Synchronize a prepared thread skill projection into a sandbox.
Bind-mount providers observe the stable projection roots directly and
use this no-op implementation. Upload-based providers override it.
"""
async def sync_agent_skills_async(
self,
sandbox_id: str,
*,
thread_id: str,
user_id: str,
projection: "SkillProjectionPaths",
) -> None:
"""Async wrapper for upload-based skill synchronization."""
await asyncio.to_thread(
self.sync_agent_skills,
sandbox_id,
thread_id=thread_id,
user_id=user_id,
projection=projection,
)
@abstractmethod
def get(self, sandbox_id: str) -> Sandbox | None:
"""Get a sandbox environment by ID.
Args:
sandbox_id: The ID of the sandbox environment to retain.
"""
pass
@abstractmethod
def release(self, sandbox_id: str) -> None:
"""Release a sandbox environment.
Args:
sandbox_id: The ID of the sandbox environment to destroy.
"""
pass
def reset(self) -> None:
"""Clear cached state that survives provider instance replacement.
Provider overrides can release resources and make the instance unusable.
"""
pass
_default_sandbox_provider: SandboxProvider | None = None
# Guards every read and write of `_default_sandbox_provider`. The singleton is
# reachable from more than one OS thread (e.g. the main event loop and the Feishu
# channel thread, which runs its own loop), so a bare check-then-create can double
# initialize the provider, and an unsynchronized reset/shutdown racing a get can
# hand a caller `None` or a torn instance. Every access to the global below takes
# this lock, including the read+return in `get_sandbox_provider()`.
#
# The lock guards only the reference swap. Provider callbacks (`__init__`,
# `reset()`, `shutdown()`) and the dynamic import in `resolve_class()` run
# *outside* the lock: they are plugin-supplied (`config.sandbox.use` resolves to
# an arbitrary class) and may be slow or, worse, re-enter these lifecycle
# functions. Holding a non-reentrant `threading.Lock` across them would
# self-deadlock such a provider and would block every concurrent `get()` during a
# slow teardown. Keeping callbacks off the lock avoids both.
_provider_lock = threading.Lock()
def get_sandbox_provider(**kwargs) -> SandboxProvider:
"""Get the sandbox provider singleton.
Returns a cached singleton instance. Use `reset_sandbox_provider()` to clear
the cache, or `shutdown_sandbox_provider()` to properly shutdown and clear.
Returns:
A sandbox provider instance.
"""
global _default_sandbox_provider
# Fast path: a single locked read so a concurrent reset/shutdown can't null
# the global between the check and the return.
with _provider_lock:
if _default_sandbox_provider is not None:
return _default_sandbox_provider
# Cold start. Resolve + construct outside the lock: the import and the
# provider constructor are plugin code and must not run under a non-reentrant
# lock. The construction may race another caller; we reconcile under the lock.
config = get_app_config()
cls = resolve_class(config.sandbox.use, SandboxProvider)
provider = cls(**kwargs)
with _provider_lock:
if _default_sandbox_provider is None:
_default_sandbox_provider = provider
return provider
# We lost the install race: another thread got there first. `winner` is
# read under the same lock, so it is always a live instance, never None.
winner = _default_sandbox_provider
# Discard the instance we just built (outside the lock). For providers with
# side-effectful constructors (e.g. AioSandboxProvider starts an idle-checker
# thread), this tears down the orphan so it does not leak — issue #3721.
if hasattr(provider, "shutdown"):
provider.shutdown()
return winner
def reset_sandbox_provider() -> None:
"""Reset the sandbox provider singleton.
This clears the cached instance without calling shutdown directly.
The next call to `get_sandbox_provider()` will create a new instance.
Useful for testing or when switching configurations.
Providers can override `reset()` to clear any module-level state they keep
alive across instances (for example, `LocalSandboxProvider`'s cached
`LocalSandbox` singleton). Without it, config/mount changes would not take
effect on the next acquire().
A provider override can release active sandboxes during reset.
Otherwise, active sandboxes become orphaned.
Do not reuse the detached provider after reset.
Use `shutdown_sandbox_provider()` for proper cleanup.
"""
global _default_sandbox_provider
# Detach the reference under the lock, then run the provider's `reset()`
# callback outside it (see the `_provider_lock` note).
with _provider_lock:
provider = _default_sandbox_provider
_default_sandbox_provider = None
if provider is not None:
from deerflow.sandbox.lease import discard_sandbox_lease_manager
discard_sandbox_lease_manager(provider)
provider.reset()
def shutdown_sandbox_provider() -> None:
"""Shutdown and reset the sandbox provider.
This properly shuts down the provider (releasing all sandboxes)
before clearing the singleton. Call this when the application
is shutting down or when you need to completely reset the sandbox system.
"""
global _default_sandbox_provider
# Detach the reference under the lock, then run the (potentially slow)
# `shutdown()` callback outside it (see the `_provider_lock` note).
with _provider_lock:
provider = _default_sandbox_provider
_default_sandbox_provider = None
if provider is not None and hasattr(provider, "shutdown"):
from deerflow.sandbox.lease import discard_sandbox_lease_manager
discard_sandbox_lease_manager(provider)
provider.shutdown()
def set_sandbox_provider(provider: SandboxProvider) -> None:
"""Set a custom sandbox provider instance.
This allows injecting a custom or mock provider for testing purposes.
Note: any previously installed provider is replaced but not shut down; the
caller owns the lifecycle of the instance it is overwriting.
Args:
provider: The SandboxProvider instance to use.
"""
global _default_sandbox_provider
with _provider_lock:
previous = _default_sandbox_provider
_default_sandbox_provider = provider
if previous is not None and previous is not provider:
from deerflow.sandbox.lease import discard_sandbox_lease_manager
discard_sandbox_lease_manager(previous)