Onefly d8d110c637
fix(sandbox): prevent AIO subagent session eviction (#5178)
* fix(sandbox): prevent AIO subagent session eviction

* fix(sandbox): address PR 5178 review issues

* fix(sandbox): handle transient session and metadata failures

* fix(sandbox): fence capacity upgrades and validate reused limits

* docs(sandbox): restore list indentation and trim guidance

* fix(ci): stabilize Buzz persistence test and trim sandbox guidance

---------

Co-authored-by: ranxi2001 <ranxi2001@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-17 07:45:19 +08:00

391 lines
17 KiB
Python

"""Remote sandbox backend — delegates Pod lifecycle to the provisioner service.
The provisioner dynamically creates per-sandbox-id Pods + NodePort Services
in k3s. The backend accesses sandbox pods directly via ``k3s:{NodePort}``.
Architecture:
┌────────────┐ HTTP ┌─────────────┐ K8s API ┌──────────┐
│ this file │ ──────▸ │ provisioner │ ────────▸ │ k3s │
│ (backend) │ │ :8002 │ │ :6443 │
└────────────┘ └─────────────┘ └─────┬────┘
│ creates
┌─────────────┐ ┌─────▼──────┐
│ backend │ ────────▸ │ sandbox │
│ │ direct │ Pod(s) │
└─────────────┘ k3s:NPort └────────────┘
"""
from __future__ import annotations
import logging
import posixpath
from pathlib import PurePosixPath
import requests
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.storage import user_should_see_legacy_skills
from .backend import SandboxBackend
from .sandbox_info import SandboxInfo
logger = logging.getLogger(__name__)
_PROVISIONER_EXTRA_MOUNT_PATHS = {
"/mnt/acp-workspace",
"/mnt/integrations/lark-cli/config",
"/mnt/integrations/lark-cli/config/locks",
"/mnt/integrations/lark-cli/data",
"/mnt/integrations/lark-cli/runtime",
}
_MANAGED_SKILL_CATEGORY_NAMES = (
"public",
"custom",
"legacy",
"integrations",
)
_RESERVED_SANDBOX_MOUNT_PATHS = (
"/mnt/user-data",
"/mnt/acp-workspace",
"/mnt/integrations/lark-cli",
)
_LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
_LARK_CLI_CONFIG_CONTAINER_PATH = "/mnt/integrations/lark-cli/config"
_LARK_CLI_DATA_CONTAINER_PATH = "/mnt/integrations/lark-cli/data"
_AIO_DEFAULT_MAX_SHELL_SESSIONS = 10
def _normalize_skills_container_path(container_path: str) -> str:
"""Return a canonical skills root that cannot overlap platform mounts."""
candidate = container_path
if not candidate or not candidate.startswith("/") or candidate.startswith("//"):
raise ValueError("The skills container path must be an absolute non-root path")
normalized = posixpath.normpath(candidate)
if normalized != candidate:
raise ValueError("The skills container path must not contain redundant separators, '.' or '..'")
root = PurePosixPath(normalized)
for reserved_path in _RESERVED_SANDBOX_MOUNT_PATHS:
reserved = PurePosixPath(reserved_path)
if root == reserved or root.is_relative_to(reserved) or reserved.is_relative_to(root):
raise ValueError(f"The skills container path {normalized!r} overlaps reserved sandbox path {reserved_path!r}")
return normalized
def _managed_skill_category_mount_paths(
skills_container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
) -> set[str]:
root = _normalize_skills_container_path(skills_container_path)
return {posixpath.join(root, category) for category in _MANAGED_SKILL_CATEGORY_NAMES}
def _provisioner_extra_mounts_payload(
extra_mounts: list[tuple[str, str, bool]] | None,
*,
skills_container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
provision_lark_cli_runtime: bool = False,
provision_lark_cli_broker: bool = False,
) -> list[dict[str, object]]:
"""Return only extra mounts the provisioner knows how to recreate safely.
When ``provision_lark_cli_runtime`` is set, the provisioner supplies the
lark-cli runtime via an init container + emptyDir, so the runtime extra mount
is dropped here to avoid a colliding hostPath/PVC mount at the same path. The
per-user config/locks/data mounts are still forwarded (they are mounted into
the sandbox in Pattern A). The config root remains read-only while its
nested locks mount is writable for lark-cli's coordination files.
When ``provision_lark_cli_broker`` is set (Pattern B, issue #4338), the
provisioner runs a broker sidecar that holds the credentials, so the
config/locks/data mounts are **forwarded** (the provisioner wires them into
the sidecar, not the sandbox) while the runtime mount is dropped. Nothing
changes in this payload beyond keeping those credential-related mounts
available for the provisioner to place; the runtime entry is dropped in
both modes.
"""
allowed_paths = _PROVISIONER_EXTRA_MOUNT_PATHS | _managed_skill_category_mount_paths(skills_container_path)
if not extra_mounts:
return []
drop_runtime = provision_lark_cli_runtime or provision_lark_cli_broker
payload: list[dict[str, object]] = []
for host_path, container_path, read_only in extra_mounts:
if container_path not in allowed_paths:
continue
if drop_runtime and container_path == _LARK_CLI_RUNTIME_CONTAINER_PATH:
continue
payload.append(
{
"host_path": host_path,
"container_path": container_path,
"read_only": read_only,
}
)
return payload
class RemoteSandboxBackend(SandboxBackend):
"""Backend that delegates sandbox lifecycle to the provisioner service.
All Pod creation, destruction, and discovery are handled by the
provisioner. This backend is a thin HTTP client.
Typical config.yaml::
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
provisioner_api_key: $PROVISIONER_API_KEY
"""
def __init__(
self,
provisioner_url: str,
api_key: str = "",
max_shell_sessions: int | None = None,
*,
required_shell_sessions: int = 0,
):
"""Initialize with the provisioner service URL and optional API key.
Args:
provisioner_url: URL of the provisioner service
(e.g., ``http://provisioner:8002``).
api_key: Value sent as ``X-API-Key`` header on every request.
Leave empty to send no authentication header.
max_shell_sessions: Optional AIO shell-session capacity forwarded
to each provisioned sandbox Pod.
required_shell_sessions: Minimum usable capacity, even when new Pods use the image default.
"""
self._provisioner_url = provisioner_url.rstrip("/")
self._api_key = api_key
self._max_shell_sessions = max_shell_sessions
self._required_shell_sessions = max(required_shell_sessions, max_shell_sessions or 0)
@property
def provisioner_url(self) -> str:
return self._provisioner_url
def _auth_headers(self) -> dict[str, str]:
return {"X-API-Key": self._api_key} if self._api_key else {}
def _requires_shell_capacity_replacement(self, payload: dict[str, object]) -> bool:
if self._required_shell_sessions == 0:
return False
reported = payload.get("max_shell_sessions", _AIO_DEFAULT_MAX_SHELL_SESSIONS)
try:
return int(reported) < self._required_shell_sessions
except (TypeError, ValueError):
return True
# ── SandboxBackend interface ──────────────────────────────────────────
def create(
self,
thread_id: str | None,
sandbox_id: str,
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
skills_container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
provision_lark_cli_runtime: bool = False,
provision_lark_cli_broker: bool = False,
) -> SandboxInfo:
"""Create a sandbox Pod + Service via the provisioner.
Calls ``POST /api/sandboxes`` which creates a dedicated Pod +
NodePort Service in k3s.
"""
return self._provisioner_create(
thread_id,
sandbox_id,
extra_mounts,
user_id=user_id,
skills_container_path=skills_container_path,
provision_lark_cli_runtime=provision_lark_cli_runtime,
provision_lark_cli_broker=provision_lark_cli_broker,
)
def destroy(self, info: SandboxInfo) -> None:
"""Destroy a sandbox Pod + Service via the provisioner."""
self._provisioner_destroy(info.sandbox_id)
def is_alive(self, info: SandboxInfo) -> bool:
"""Check whether the sandbox Pod is running."""
return self._provisioner_is_alive(info.sandbox_id)
def discover(self, sandbox_id: str) -> SandboxInfo | None:
"""Discover an existing sandbox via the provisioner.
Calls ``GET /api/sandboxes/{sandbox_id}`` and returns info if
the Pod exists.
"""
return self._provisioner_discover(sandbox_id)
def list_running(self) -> list[SandboxInfo]:
"""Return all sandboxes currently managed by the provisioner.
Calls ``GET /api/sandboxes`` so that ``AioSandboxProvider._reconcile_orphans()``
can adopt pods that were created by a previous process and were never
explicitly destroyed.
Without this, a process restart silently orphans all existing k8s Pods —
they stay running forever because the idle checker only
tracks in-process state.
"""
return self._provisioner_list()
# ── Provisioner API calls ─────────────────────────────────────────────
def _provisioner_list(self) -> list[SandboxInfo]:
"""GET /api/sandboxes → list all running sandboxes."""
try:
resp = requests.get(f"{self._provisioner_url}/api/sandboxes", headers=self._auth_headers(), timeout=10)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
logger.warning("Provisioner list_running returned non-dict payload: %r", type(data))
return []
sandboxes = data.get("sandboxes", [])
if not isinstance(sandboxes, list):
logger.warning("Provisioner list_running returned non-list sandboxes: %r", type(sandboxes))
return []
infos: list[SandboxInfo] = []
for sandbox in sandboxes:
if not isinstance(sandbox, dict):
logger.warning("Provisioner list_running entry is not a dict: %r", type(sandbox))
continue
sandbox_id = sandbox.get("sandbox_id")
sandbox_url = sandbox.get("sandbox_url")
if isinstance(sandbox_id, str) and sandbox_id and isinstance(sandbox_url, str) and sandbox_url:
infos.append(
SandboxInfo(
sandbox_id=sandbox_id,
sandbox_url=sandbox_url,
requires_replacement=self._requires_shell_capacity_replacement(sandbox),
)
)
logger.info("Provisioner list_running: %d sandbox(es) found", len(infos))
return infos
except requests.RequestException as exc:
logger.warning("Provisioner list_running failed: %s", exc)
return []
def _provisioner_create(
self,
thread_id: str | None,
sandbox_id: str,
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
skills_container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
provision_lark_cli_runtime: bool = False,
provision_lark_cli_broker: bool = False,
) -> SandboxInfo:
"""POST /api/sandboxes → create Pod + Service."""
effective_user_id = user_id or get_effective_user_id()
include_legacy_skills = user_should_see_legacy_skills(effective_user_id)
normalized_skills_container_path = _normalize_skills_container_path(skills_container_path)
payload = {
"sandbox_id": sandbox_id,
"thread_id": thread_id,
"user_id": effective_user_id,
"include_legacy_skills": include_legacy_skills,
"skills_container_path": normalized_skills_container_path,
"provision_lark_cli_runtime": provision_lark_cli_runtime,
"provision_lark_cli_broker": provision_lark_cli_broker,
}
if self._max_shell_sessions is not None:
payload["max_shell_sessions"] = self._max_shell_sessions
provisioner_extra_mounts = _provisioner_extra_mounts_payload(
extra_mounts,
skills_container_path=normalized_skills_container_path,
provision_lark_cli_runtime=provision_lark_cli_runtime,
provision_lark_cli_broker=provision_lark_cli_broker,
)
if provisioner_extra_mounts:
payload["extra_mounts"] = provisioner_extra_mounts
try:
resp = requests.post(
f"{self._provisioner_url}/api/sandboxes",
json=payload,
headers=self._auth_headers(),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if self._max_shell_sessions is not None and "max_shell_sessions" not in data:
raise RuntimeError("Provisioner did not report max_shell_sessions; Gateway/provisioner version skew prevents shell-capacity validation")
if self._requires_shell_capacity_replacement(data):
raise RuntimeError(f"Provisioner returned sandbox {sandbox_id} with insufficient shell-session capacity")
logger.info(f"Provisioner created sandbox {sandbox_id}: sandbox_url={data['sandbox_url']}")
return SandboxInfo(
sandbox_id=sandbox_id,
sandbox_url=data["sandbox_url"],
)
except requests.RequestException as exc:
logger.error(f"Provisioner create failed for {sandbox_id}: {exc}")
raise RuntimeError(f"Provisioner create failed: {exc}") from exc
def _provisioner_destroy(self, sandbox_id: str) -> None:
"""DELETE /api/sandboxes/{sandbox_id} → destroy Pod + Service."""
try:
resp = requests.delete(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=15,
)
if resp.ok:
logger.info(f"Provisioner destroyed sandbox {sandbox_id}")
else:
logger.warning(f"Provisioner destroy returned {resp.status_code}: {resp.text}")
except requests.RequestException as exc:
logger.warning(f"Provisioner destroy failed for {sandbox_id}: {exc}")
def _provisioner_is_alive(self, sandbox_id: str) -> bool:
"""GET /api/sandboxes/{sandbox_id} → check Pod phase."""
try:
resp = requests.get(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=10,
)
except requests.RequestException as exc:
raise RuntimeError(f"Provisioner health check failed for {sandbox_id}: {exc}") from exc
if resp.status_code == 404:
return False
if not resp.ok:
raise RuntimeError(f"Provisioner health check failed for {sandbox_id}: HTTP {resp.status_code} {resp.text}")
data = resp.json()
return data.get("status") == "Running"
def _provisioner_discover(self, sandbox_id: str) -> SandboxInfo | None:
"""GET /api/sandboxes/{sandbox_id} → discover existing sandbox."""
try:
resp = requests.get(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=10,
)
if resp.status_code == 404:
return None
resp.raise_for_status()
data = resp.json()
return SandboxInfo(
sandbox_id=sandbox_id,
sandbox_url=data["sandbox_url"],
requires_replacement=self._requires_shell_capacity_replacement(data),
)
except requests.RequestException as exc:
logger.debug(f"Provisioner discover failed for {sandbox_id}: {exc}")
return None