mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes Why this change: Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker capability probe collapsed "provisioner unreachable" into "broker not configured": one 1.5s timeout cached a negative for 300s, so new sandboxes were created without the broker sidecar while the per-user credential directories were still bind-mounted into the container, and existing Pods kept being reused without their actual mode ever being read. Second, an implicit-shell command that timed out or returned an unknown status only marked the current client object; warm-pool reclaim and rediscovery built a fresh client that re-entered a container whose shell state could not be proven clean, and nothing about that ambiguity survived a Gateway restart. What changed: - The capability cache stores confirmed observations only, keyed per provisioner endpoint/auth identity; an unknown answer (timeout, malformed payload) raises instead of selecting credential placement, while a confirmed broker requirement is retained through outages. One in-flight probe per identity shares success and failure, and a Pod-attested mode contradicting the cache forces one shared refresh. - Execution uses the mode attested for the actual sandbox: SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod observation into the client, so a probe outage delays drift detection but never denies reuse of an attested runtime, and never trusts an unattested one. - The provisioner reports each Pod's actual broker mode, generation (Pod UID) and shell capacity from one read, rejects lark requests conflicting with its configuration (409/503 carrying a capability-refresh marker the Gateway uses to drop the stale cache entry), rejects conflicting or Terminating Pods on coordination creates while reporting Terminating as data to status/get/list, and answers Pod presence independently of Service readiness. - A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by scope/sandbox/generation) fences containers after uncertain implicit shell outcomes across client rebuilds and restarts; in-process reuse, warm reclaim, discovery, create responses and orphan reconciliation all pass one policy gate, and replacement runs under the existing ownership and teardown fences. A fenced sandbox still tracked after a failed release-recycle is destroyed inline on the next acquire. - Quarantine records retire only after confirmed absence, retrying partial cleanup for the quarantined generation and releasing remembered ports under the same fences; local stop failures now propagate instead of being logged away, and remote destroy errors surface to callers. Problem solved: A provisioner blip could silently strip broker isolation from new lark sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted default shells persist across warm reuse, rediscovery and restarts. Docs updated: README, aio_sandbox and skills AGENTS.md. * docs(changelog): record sandbox attestation and quarantine fix (#6436) * fix(sandbox): preserve active leases and fail closed on unattested broker modes Concurrent uploads could recycle a healthy AIO runtime while another holder was creating or cleaning up a command session. Older provisioner responses could also trigger destructive replacement before failing admission. - Distinguish pending session creation from ambiguous outcomes and reserve idle teardown until execution and upload holders finish scope cleanup. - Reject stale fork lookups and reconcile cancelled acquires without releasing a sandbox still borrowed by another holder. - Default Lark broker mode to unverified and preserve unattested Pods until the provisioner is upgraded. - Recognize Docker 29 absence diagnostics while matching resource names exactly. - Preserve Gateway upload errors and cover admission and lifecycle regressions. * fix(sandbox): scope the unverified-broker refuse path to attested providers The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox without a lark_cli_broker attribute as unverified, but only AioSandbox carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no broker sidecar, no credential mounts, and no provisioner to upgrade, so every lark-cli command on those providers failed with an unactionable error. Declare the tri-state on the Sandbox base class (defaulting to False, the only mode brokerless providers have) so the None fail-closed path stays scoped to AioSandbox's deliberate unverified default. - Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring the persistent_shell_sessions tri-state pattern - Add a regression test pinning the non-broker overlay for a provider without the attribute * fix(sandbox): close review gaps in broker attestation and teardown lifecycle Address the latest #6436 review round: - The provisioner's post-build broker-mode 409 now carries the capability-refresh marker, so an incompatible-mode conflict surfaced by idempotent re-create, attestation drift or post-create verification drops the Gateway's cached capability observation instead of repeating the same 409 for the full negative-cache TTL. - AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False when teardown is deferred because an execution/upload holder or a peer teardown is still active, so callers can no longer mistake a deferred destroy for a completed one. - A live replacement under the same logical ID proves the fenced old generations are absent; retire_replaced_generations prunes only those stale records so they cannot strand future creates, while the live generation's fence and unversioned crash fences are never touched. - SandboxInfo.from_dict keeps a missing lark_cli_broker attestation unverified (None) instead of silently rehydrating as attested non-broker. - The restricted-network inspect path shares the strict _is_no_such_network_error parser, closing the substring loophole for context-name collisions and wrong-target errors. * docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate The three lease notes added in 26c307f3 pushed the effective guidance chain to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance (AG002). Fold them into two lines carrying the same rules. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""Regression anchor: DingTalk ``receive_file`` must not block the event loop.
|
|
|
|
``_receive_single_file`` prepares the thread directories, resolves the uploads
|
|
dir, scans it for the uniqueness claim, and writes the attachment — all blocking
|
|
filesystem IO that must run inside ``asyncio.to_thread`` (and sandbox sync must
|
|
go through ``acquire_async`` + an offloaded ``update_file``). This anchor drives
|
|
the real ``receive_file`` under the strict Blockbuster gate; if any of that
|
|
regresses back onto the event loop, Blockbuster raises ``BlockingError``.
|
|
|
|
The ``Paths`` construction is offloaded only because ``Paths.__init__`` resolves
|
|
paths synchronously; the surface under test (``receive_file``'s persist path) is
|
|
exercised on the event loop, not bypassed. The download itself is mocked — the
|
|
network leg is httpx-async and not the subject here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
class _BlockingRemoteSandbox:
|
|
def __init__(self) -> None:
|
|
self.update_started = threading.Event()
|
|
self.allow_update = threading.Event()
|
|
self.closed = False
|
|
self.updates: list[tuple[str, bytes]] = []
|
|
self.released_scopes: list[str] = []
|
|
|
|
def update_file(self, path: str, content: bytes) -> None:
|
|
self.update_started.set()
|
|
assert self.allow_update.wait(timeout=2)
|
|
assert not self.closed
|
|
self.updates.append((path, content))
|
|
|
|
def release_command_scope(self, scope_id: str) -> None:
|
|
self.released_scopes.append(scope_id)
|
|
|
|
|
|
class _BlockingRemoteProvider:
|
|
def __init__(self) -> None:
|
|
self.sandbox = _BlockingRemoteSandbox()
|
|
self.release_calls: list[str] = []
|
|
|
|
async def acquire_async(self, thread_id=None, *, user_id=None):
|
|
return "remote-sandbox"
|
|
|
|
def get(self, sandbox_id: str):
|
|
return self.sandbox if sandbox_id == "remote-sandbox" else None
|
|
|
|
def release(self, sandbox_id: str) -> None:
|
|
self.release_calls.append(sandbox_id)
|
|
self.sandbox.closed = True
|
|
|
|
|
|
async def test_receive_file_persist_does_not_block_event_loop(tmp_path, monkeypatch) -> None:
|
|
from app.channels.dingtalk import DingTalkChannel
|
|
from app.channels.message_bus import MessageBus
|
|
from deerflow.config.paths import Paths
|
|
|
|
paths = await asyncio.to_thread(Paths, str(tmp_path))
|
|
monkeypatch.setattr("app.channels.dingtalk.get_paths", lambda: paths)
|
|
|
|
async def _acquire_async(thread_id, user_id=None):
|
|
return "local"
|
|
|
|
monkeypatch.setattr(
|
|
"app.channels.dingtalk.get_sandbox_provider",
|
|
lambda: SimpleNamespace(uses_thread_data_mounts=True, acquire_async=_acquire_async, get=lambda sid: None),
|
|
)
|
|
|
|
channel = DingTalkChannel(MessageBus(), config={})
|
|
channel._download_by_code = AsyncMock(return_value=b"DATA")
|
|
|
|
msg = channel._make_inbound(
|
|
chat_id="c",
|
|
user_id="u",
|
|
text="hi",
|
|
thread_ts="m",
|
|
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
|
|
)
|
|
out = await channel.receive_file(msg, "t1", user_id="default")
|
|
|
|
assert "/uploads/a.pdf" in out.text
|
|
assert out.files == []
|
|
|
|
|
|
async def test_cancelled_receive_file_holds_sandbox_lease_until_remote_sync_finishes(tmp_path, monkeypatch) -> None:
|
|
from app.channels.dingtalk import DingTalkChannel
|
|
from app.channels.message_bus import MessageBus
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.sandbox.lease import discard_sandbox_lease_manager, get_sandbox_lease_manager
|
|
|
|
paths = await asyncio.to_thread(Paths, str(tmp_path))
|
|
provider = _BlockingRemoteProvider()
|
|
manager = get_sandbox_lease_manager(provider)
|
|
monkeypatch.setattr("app.channels.dingtalk.get_paths", lambda: paths)
|
|
monkeypatch.setattr("app.channels.dingtalk.get_sandbox_provider", lambda: provider)
|
|
|
|
channel = DingTalkChannel(MessageBus(), config={})
|
|
channel._download_by_code = AsyncMock(return_value=b"DATA")
|
|
|
|
await manager.acquire_async("active-run", "thread-1", user_id="ou-user")
|
|
receive_task = asyncio.create_task(
|
|
channel._receive_single_file(
|
|
"download-code",
|
|
"file",
|
|
"report.pdf",
|
|
"thread-1",
|
|
user_id="ou-user",
|
|
)
|
|
)
|
|
try:
|
|
assert await asyncio.to_thread(provider.sandbox.update_started.wait, 1)
|
|
for _ in range(3):
|
|
receive_task.cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
assert not receive_task.done()
|
|
await manager.release_async("active-run")
|
|
assert provider.release_calls == []
|
|
assert not provider.sandbox.closed
|
|
|
|
provider.sandbox.allow_update.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await receive_task
|
|
|
|
assert provider.sandbox.updates == [("/mnt/user-data/uploads/report.pdf", b"DATA")]
|
|
assert provider.release_calls == ["remote-sandbox"]
|
|
assert provider.sandbox.closed
|
|
finally:
|
|
provider.sandbox.allow_update.set()
|
|
if not receive_task.done():
|
|
receive_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await receive_task
|
|
await manager.release_async("active-run")
|
|
discard_sandbox_lease_manager(provider)
|