← the journey
06

stop 06 · published

Sandbox system

source anchors · @7e7f041

The same bash("rm -rf build/") call means very different things under different sandbox providers. In local mode it runs through the shell on the Gateway host. In AIO mode it is sent to a shell/file API inside a container or Pod. The command text is the same; the execution boundary is not.

The tools in sandbox/tools.py look like simple file and shell operations: bash, ls, read_file, write_file, str_replace. The architectural question is where each external operation lands, and which boundary contains its side effects.

The sandbox is the tool execution environment and capability boundary: it decides what external resources an agent may touch, and where commands execute.

flowchart TD
TC["model emits tool call"] --> TN["ToolNode calls tool function and injects runtime"]
TN --> ESI["ensure_sandbox_initialized(runtime)"]
ESI --> SID["read runtime.state['sandbox'].sandbox_id"]
SID --> PROV["provider.get(sandbox_id)"]
PROV --> SBX["Sandbox instance: local / container / Pod"]
SBX --> EXEC["execute_command / read_file / write_file / list_dir"]
Tool execution: ToolNode calls the tool function, while ensure_sandbox_initialized and the provider bind that call to an execution environment.

State Stores Identity

The first boundary to keep clear is the boundary between graph state and live resources. The graph state stores only a sandbox_id; it does not store the object that can execute commands.

runtime.state["sandbox"] = {"sandbox_id": "local:thread-123"}

That value is part of ThreadState (see thread_state.py::ThreadState ). It records which sandbox this thread is bound to. Its reducer is DeerFlow’s own merge_sandbox, and the field type is factored into SandboxStateField. The behavior is explicit: idempotent writes of the same sandbox_id are accepted, conflicting sandbox ids are rejected. The actual Sandbox instance lives behind the sandbox provider.

This split is necessary because graph state can be checkpointed and restored in another process. A container connection, an httpx client, or a process handle is a live in-process resource; it cannot be safely serialized into a checkpoint. The stable thing that can be persisted is an id. After restart, or when a request lands on another worker, the provider uses that id to find or rebuild the execution resource.

sandbox_id        resource identity stored in graph state
Sandbox instance  live execution object held by the provider

One subtlety matters here. Writing runtime.state["sandbox"] = ... inside a tool does not by itself persist the update into LangGraph state. That mutation is local to the current tool invocation. To make the new sandbox id visible to later nodes and checkpoints, DeerFlow returns a Command(update={"sandbox": ...}). SandboxMiddleware.wrap_tool_call exists partly to attach that update when lazy initialization happens inside a tool call.

Two Interfaces

The sandbox layer has two separate interfaces.

Sandbox (see sandbox.py::Sandbox ) describes what can be done inside an environment:

execute_command(command)
read_file(path)
write_file(path, content)
list_dir(path)
glob / grep

SandboxProvider (see sandbox_provider.py::SandboxProvider ) describes how an environment is acquired, reused, and released:

acquire(thread_id)  find or create a sandbox for this thread
get(sandbox_id)     return the live Sandbox instance
release(sandbox_id) release the current run's hold on it
shutdown()          clean up on process shutdown

Do not confuse sandbox provider with model provider. A model provider serves model calls. A sandbox provider serves tool execution environments.

Thread Identity

Here thread_id is not an OS thread and not a Python thread. It is the application-level identity for a DeerFlow conversation or task.

OS process       one Python service process with its own memory
Python thread    an execution unit inside a process
DeerFlow thread  a conversation/task identity, carried as thread_id

Sandbox tools look for the id in two places:

thread_id = runtime.context.get("thread_id")
if thread_id is None:
    thread_id = runtime.config.get("configurable", {}).get("thread_id")

Gateway workers pass it through runtime context; embedded clients pass it through configurable. The provider uses it as the stable key for “the same conversation should reuse the same sandbox”. Process memory may be gone after restart, but the upstream caller can still pass the same thread_id.

Local Is Not Strong Isolation

LocalSandboxProvider (see local_sandbox_provider.py::LocalSandboxProvider ) is easy to misread. It is not a strong isolation sandbox. It is an adapter over the host filesystem and host shell.

LocalSandboxProvider.acquire(thread_id)
  build path mappings for this thread
  return LocalSandbox("local:{thread_id}", path_mappings=...)

LocalSandbox.execute_command(command)
  resolve virtual paths to host paths
  subprocess.run([shell, "-c", command])
  mask host paths back to virtual paths in output

The key line is in LocalSandbox.execute_command() (see local_sandbox.py::LocalSandbox.execute_command ): local bash ultimately runs through subprocess.run([shell, "-c", command]). It runs on the Gateway host, not inside a container.

File tools can validate paths and keep operations under allowed roots. Bash is broader: pipes, redirection, environment variables, system commands, and side effects cannot be fully contained by string validation. That is why local bash needs a separate host-bash gate.

Host Bash Gating

is_host_bash_allowed() checks whether the current provider is local and whether sandbox.allow_host_bash is explicitly enabled.

local provider + allow_host_bash=False   reject bash
local provider + allow_host_bash=True    allow host bash
non-local provider                       bash runs inside the sandbox/container

The gate protects the host shell:

local bash is not container bash
local bash is the shell on the Gateway host

Path validation, virtual path replacement, and output masking reduce accidents, but they are not a strong isolation boundary. DeerFlow therefore defaults to denying host bash instead of assuming command-string validation can make it safe.

The validation ignores obvious non-path text fragments such as REST templates (/devices/{id}) and non-ASCII string literals. That improves usability, but it does not change the security boundary: local bash is still host bash.

AIO Runs Through HTTP

AioSandboxProvider (see aio_sandbox_provider.py::AioSandboxProvider ) is the container/Pod path. AIO here means an all-in-one sandbox runtime; it is not Python’s asyncio.

AioSandboxProvider.acquire(thread_id)
  find or create a sandbox container/Pod
  return AioSandbox(id, base_url)

AioSandbox.execute_command(command)
  AioSandboxClient(base_url)
  send an HTTP request
  AIO API inside the container executes the command

In this mode, the Gateway process holds an HTTP client. The actual command and file operations happen inside the AIO runtime. Side effects stay inside that container or Pod.

local Docker:
  Gateway -> LocalContainerBackend -> docker run all-in-one-sandbox
          -> AioSandboxClient(http://localhost:{port}) -> container shell/file API

remote provisioner:
  Gateway -> RemoteSandboxBackend -> POST /api/sandboxes
          -> provisioner creates Pod + Service
          -> AioSandboxClient(sandbox_url) -> Pod shell/file API

Because bash runs inside the isolated runtime, AIO bash does not need host-bash gating.

Why Acquire Is Layered

acquire(thread_id) does not execute commands. It finds a usable sandbox for the current thread and returns its sandbox_id. The path is layered because it handles concurrency, reuse, process restart, and multi-process deployment:

acquire(thread_id)
  take the in-process lock for this thread
  check active cache in this process
  compute stable sandbox_id from sha256(thread_id)[:8]
  check warm pool
  take cross-process file lock
  backend.discover(sandbox_id)
  backend.create(...)

Each step handles a different failure mode:

in-process lock   avoid duplicate creates inside one process
active cache      reuse a client this process already holds
stable id         let different processes compute the same resource name
warm pool         reclaim a released-but-still-running sandbox
file lock         serialize discover/create across processes
discover          reconnect to a container/Pod another process created
create            create only after all reuse paths fail

“Cross-process recovery” does not mean retrieving another process’s Python object. It means reconnecting to the same external sandbox resource. The Python client is in-process; the container/Pod is external. A stable sandbox_id lets any process rebuild the client.

Virtual Paths

The model sees a stable virtual filesystem:

/mnt/user-data/workspace
/mnt/user-data/uploads
/mnt/user-data/outputs
/mnt/skills
/mnt/acp-workspace

Host paths are separated by user and thread:

{base_dir}/users/{user_id}/threads/{thread_id}/user-data/workspace
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/uploads
{base_dir}/users/{user_id}/threads/{thread_id}/user-data/outputs
{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace

Local and AIO implement the virtual layer differently:

local:
  Python maps virtual paths to host paths before execution,
  then masks host paths back to virtual paths in output.

AIO:
  host directories are mounted into the container,
  so the container directly sees /mnt/user-data/...

Virtual paths give the model stable coordinates. They are not the isolation boundary. Isolation comes from the provider type, path validation, mount permissions, host-bash gating, and container boundaries.

Lifecycle

The lifecycle trigger is SandboxMiddleware (see middleware.py::SandboxMiddleware ); the policy lives in the provider.

before_agent
  eager acquire only when lazy_init=False

first sandbox tool call
  default lazy_init=True acquires here

after_agent
  provider.release(sandbox_id)

shutdown
  provider.shutdown()

Default behavior is lazy. A run that only chats and never touches files may never allocate a sandbox.

Because lazy initialization can happen inside a tool call, wrap_tool_call compares the sandbox id before and after the tool. If a new sandbox was created, it wraps the result in a Command update:

Command(update={
    "sandbox": {"sandbox_id": sandbox_id},
    "messages": [tool_message],
})

release() is not the same as destroy().

local:
  release() is effectively a no-op; LocalSandbox stays cached

AIO:
  release() removes the active client and parks SandboxInfo in the warm pool
  destroy() stops the container or deletes the Pod
  idle checker eventually destroys idle active/warm sandboxes

Why the provider abstraction helps

  • Tool code is decoupled from local/container/Pod execution.
  • Graph state stores only sandbox_id, so it can be checkpointed.
  • Lazy creation plus warm pool avoids unnecessary cold starts.
  • The model sees stable virtual paths instead of host paths.

What it costs

  • Acquire logic becomes layered and harder to debug.
  • Local mode is not strong isolation; host bash needs explicit gating.
  • Virtual paths can be mistaken for a safety boundary.
  • Logical identity and physical resource lifecycle are currently tightly coupled.

Key Points