mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
* feat(lark): sidecar credential broker for sandbox lark-cli (Pattern B) Removes the plaintext Lark credential mounts (appSecret + OAuth tokens) from the sandbox container. A long-running broker sidecar owns lark-cli and the per-user config/data dirs and serves the command surface over Pod loopback; the sandbox gets only a forwarding shim on PATH, so the raw credential files never exist in the sandbox filesystem. - lark_broker.py: stdlib-only loopback broker (argv passthrough with shell=False, server-injected credential env, bounded I/O) + shim script constant + install-shim mode. - docker/lark-cli-broker: init(install-shim) + serve image. - provisioner: LARK_CLI_BROKER_IMAGE + provision_lark_cli_broker → shim init container + lark-cli-broker sidecar (config/data mounted sidecar-only); credentials dropped from the sandbox container; /api/capabilities reports lark_cli_broker_image. Broker supersedes the Pattern A init-container binary when both are configured. - gateway: lark_cli_env_overlay(broker=True) omits config/data env; sandbox_lark_broker_active() TTL-cached mode resolver; broker added to sandbox_runtime_mode / readiness and the settings UI. Opt-in and off by default (empty LARK_CLI_BROKER_IMAGE ⇒ no change). Closes #4338 * fix(lark): address Pattern B broker review findings (#4501) Follow-up to the sidecar credential broker addressing the PR #4501 review: - shim: split the on-PATH lark-cli into a /bin/sh launcher + Python shim body so broker mode fails loudly (exit 127, actionable message) instead of ENOEXEC when the sandbox image ships no python3; interpreter pinnable via DEERFLOW_LARK_BROKER_PYTHON. Launcher bakes in the shim's absolute path since $0 is the bare command name when run off PATH. - broker: drop the dead cwd payload field (broker can't see the sandbox FS) and document the command-surface-only / no-file-IO limitation. - broker: return a structured 500 JSON on unexpected exec errors so the shim gets a meaningful message, not an opaque transport failure; set a handler socket timeout to bound slow/stuck connections. - broker: add an opt-in DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS denylist that refuses secret-dumping subcommands before spawning the binary, forwarded from the provisioner sidecar. - gateway: tighten the per-bash-call broker probe timeout (1.5s) and cache negatives longer (300s) so non-broker remote-provisioner users don't pay a latency hit; guard the mode cache with a lock; drop the dead _probe_provisioner_lark_cli_init_image wrapper. - docs: remove the broken design-doc link from the broker README. Adds tests for launcher python resolution, cwd omission, denylist enforcement, 500-on-error, hot-path probe timeout + negative caching, and provisioner denylist-env wiring.
308 lines
13 KiB
Python
308 lines
13 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 requests
|
|
|
|
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/skills/custom",
|
|
"/mnt/skills/integrations",
|
|
"/mnt/integrations/lark-cli/config",
|
|
"/mnt/integrations/lark-cli/data",
|
|
"/mnt/integrations/lark-cli/runtime",
|
|
}
|
|
|
|
_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"
|
|
|
|
|
|
def _provisioner_extra_mounts_payload(
|
|
extra_mounts: list[tuple[str, str, bool]] | None,
|
|
*,
|
|
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/data credential mounts are still forwarded (they are mounted
|
|
into the sandbox in Pattern A).
|
|
|
|
When ``provision_lark_cli_broker`` is set (Pattern B, issue #4338), the
|
|
provisioner runs a broker sidecar that holds the credentials, so the
|
|
config/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 config/data available for the provisioner to
|
|
place; the runtime entry is dropped in both modes.
|
|
"""
|
|
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 _PROVISIONER_EXTRA_MOUNT_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 = ""):
|
|
"""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.
|
|
"""
|
|
self._provisioner_url = provisioner_url.rstrip("/")
|
|
self._api_key = api_key
|
|
|
|
@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 {}
|
|
|
|
# ── 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,
|
|
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,
|
|
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))
|
|
|
|
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,
|
|
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)
|
|
payload = {
|
|
"sandbox_id": sandbox_id,
|
|
"thread_id": thread_id,
|
|
"user_id": effective_user_id,
|
|
"include_legacy_skills": include_legacy_skills,
|
|
"provision_lark_cli_runtime": provision_lark_cli_runtime,
|
|
"provision_lark_cli_broker": provision_lark_cli_broker,
|
|
}
|
|
provisioner_extra_mounts = _provisioner_extra_mounts_payload(
|
|
extra_mounts,
|
|
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()
|
|
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"],
|
|
)
|
|
except requests.RequestException as exc:
|
|
logger.debug(f"Provisioner discover failed for {sandbox_id}: {exc}")
|
|
return None
|