Ryker_Feng 7aa314b4c1
feat: add Lark CLI integration (#3971)
* feat: add lark cli integration

* fix: polish lark integration actions

* feat: support lark incremental permissions

* fix: detect lark authorization completion

* fix: harden lark integration install

* feat: expand lark auth scopes and reuse host auth in sandbox

Default lark auth to least-privilege (recommend=false, base sign-in only)
and expose the full set of lark-cli --domain business domains as native
--domain grants instead of a 4-domain read-only mapping. Resolve the
skill pack from the latest larksuite/cli GitHub release at install time
with content-hash integrity, and surface version/runtime drift in status.

Share the per-user lark-cli config/data profile between the Gateway
Settings auth flow and agent conversations by mounting the integration
dirs into the AIO sandbox and injecting the matching env for lark-cli
commands, with an allowlisted extra_mounts path in the provisioner/K8s
backend and traversal guards on integration paths.

* style: fix lint issues from ruff and prettier

Sort imports in the provisioner PVC test and re-wrap two long i18n
description strings to satisfy backend ruff and frontend prettier CI.

* fix(lark): address managed integration review feedback

* fix(frontend): stabilize integrations settings e2e

* test(sandbox): isolate remote backend legacy visibility check

* test: fix backend unit failures after merge

* Harden Lark integration review fixes

* Format Lark integration E2E test

* fix(lark): harden sandbox credential exposure and status disclosure

Address willem_bd's security review on PR #3971:

- Mount the per-user lark-cli config dir (long-lived appSecret) read-only
  into the AIO sandbox; only the refreshable-token data dir stays writable.
- Redact host filesystem paths (install_path, cli.path) from
  GET /lark/status and the config/auth complete responses for non-admin
  callers, fail-closed on any auth error.
- Document the npm postinstall trade-off (--ignore-scripts is not viable
  because @larksuite/cli fetches its platform binary in postinstall).
- Document the sandbox credential trust boundary in AGENTS.md and README,
  pointing at the sidecar-broker follow-up (#4338).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-26 08:09:17 +08:00

1638 lines
65 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Managed Lark/Feishu CLI integration support.
The integration installs the official ``lark-*`` AI-agent skills into a
global read-only managed integration skill directory. It deliberately
does not use the ordinary custom-skill archive path: this is a trusted,
versioned first-party integration package, not user-authored mutable content.
Version resolution & integrity
-------------------------------
The installed skill-pack version follows the Gateway runtime ``lark-cli``
binary version (``lark-cli --version``). This keeps the managed skills aligned
with the server-side CLI that will execute them. ``FALLBACK_LARK_CLI_VERSION``
matches the Dockerfile/npm pin and is used only when the runtime binary is
unavailable or does not report a parseable version.
Integrity is enforced without pinning a per-version archive byte hash (GitHub
does not guarantee source-archive bytes are stable across their internal git
upgrades, and pinning conflicts with tracking latest). Instead:
* the download source is fixed to the official GitHub host over HTTPS and the
version only comes from the Gateway runtime CLI version or the pinned
fallback (no external URL injection);
* every archive member passes structural guards (zip-slip / symlink /
executable-binary / size / required-skill completeness / ``SKILL.md`` parse);
* a **content** SHA-256 over the extracted skill tree, after DeerFlow's shared
guidance is injected, is recorded in the manifest, so a reinstall whose
effective skill content changed is detectable/auditable even when GitHub
re-packs identical content with different archive bytes.
Runtime coupling: the npm-installed ``lark-cli`` binary version is pinned in
``backend/Dockerfile`` (``ARG LARK_CLI_NPM_VERSION``) and
``docker/docker-compose*.yaml`` as a bootstrap fallback. The admin install path
also manages a writable DeerFlow-owned Gateway CLI under
``.deer-flow/integrations/lark-cli/gateway-cli`` and prefers it over the system
PATH, so users do not need to run terminal installation commands. Reinstalling
the integration refreshes both the managed Gateway CLI and the skill pack to the
same version when network access is available. ``get_lark_integration_status``
surfaces ``latest_available_version`` and ``runtime_version_mismatch`` for
operators, and ``test_python_and_docker_lark_cli_versions_match`` pins the
fallback constant to the Dockerfile ARG so packaged deployments do not silently
diverge.
"""
from __future__ import annotations
import hashlib
import io
import json
import logging
import os
import posixpath
import re
import shutil
import subprocess
import tarfile
import tempfile
import threading
import time
import urllib.parse
import urllib.request
import zipfile
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any
try:
import fcntl
except ImportError: # pragma: no cover - Windows fallback
fcntl = None # type: ignore[assignment]
import msvcrt
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import Paths, get_paths
from deerflow.skills.installer import is_executable_binary_prefix, is_symlink_member, is_unsafe_zip_member
from deerflow.skills.parser import parse_skill_file
from deerflow.skills.permissions import make_skill_tree_sandbox_readable
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
logger = logging.getLogger(__name__)
INTEGRATION_ID = "lark-cli"
# Matches the Gateway image/npm pin. Used when the runtime binary is unavailable
# or reports an unparsable version.
FALLBACK_LARK_CLI_VERSION = "v1.0.65"
LARK_CLI_NPM_VERSION = FALLBACK_LARK_CLI_VERSION.removeprefix("v")
LARK_CLI_NPM_PACKAGE = "@larksuite/cli"
LARK_CLI_GITHUB_REPO = "larksuite/cli"
LARK_CLI_LATEST_RELEASE_API = f"https://api.github.com/repos/{LARK_CLI_GITHUB_REPO}/releases/latest"
LARK_CLI_SOURCE_ARCHIVE_ENV = "DEER_FLOW_LARK_CLI_SKILLS_ARCHIVE"
LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV = "DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR"
LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS = 60
LARK_CLI_NPM_INSTALL_TIMEOUT_SECONDS = 180
LARK_HTTP_TIMEOUT_SECONDS = 20
LARK_CONFIG_POLL_TIMEOUT_SECONDS = 45
LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS = 45
LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS = 5
LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS = 45
LARK_CLI_LATEST_VERSION_TTL_SECONDS = 3600
LARK_CLI_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
LARK_CLI_MAX_EXTRACTED_BYTES = 256 * 1024 * 1024
LARK_CLI_MAX_RUNTIME_ASSET_BYTES = 128 * 1024 * 1024
LARK_CLI_MANIFEST_FILE = ".deerflow-lark-cli-manifest.json"
LARK_CLI_SANDBOX_CONFIG_DIR = "/mnt/integrations/lark-cli/config"
LARK_CLI_SANDBOX_DATA_DIR = "/mnt/integrations/lark-cli/data"
LARK_CLI_SANDBOX_RUNTIME_DIR = "/mnt/integrations/lark-cli/runtime"
LARK_CLI_LINUX_ARCHES = ("amd64", "arm64")
LARK_CLI_RUNTIME_MANIFEST_FILE = ".deerflow-lark-cli-runtime.json"
# Arch-dispatch launcher for the sandbox runtime layout. Shared by the Gateway
# writer (`_write_lark_cli_sandbox_launcher`) and the `docker/lark-cli-init`
# init image so the two producers of `bin/lark-cli` never drift.
LARK_CLI_SANDBOX_LAUNCHER_SCRIPT = """#!/bin/sh
set -eu
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "Unsupported sandbox architecture: $(uname -m)" >&2; exit 126 ;;
esac
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$script_dir/../linux-$arch/lark-cli" "$@"
"""
_VERSION_TAG_RE = re.compile(r"v?\d+\.\d+\.\d+")
_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER = "<!-- deerflow-lark-cli-auth-guidance-v2 -->"
_DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS = ("<!-- deerflow-lark-cli-auth-guidance-v1 -->",)
_LARK_APP_REGISTRATION_PATH = "/oauth/v1/app/registration"
LARK_SKILL_NAMES: tuple[str, ...] = (
"lark-approval",
"lark-apps",
"lark-attendance",
"lark-base",
"lark-calendar",
"lark-contact",
"lark-doc",
"lark-drive",
"lark-event",
"lark-im",
"lark-mail",
"lark-markdown",
"lark-minutes",
"lark-note",
"lark-okr",
"lark-openapi-explorer",
"lark-shared",
"lark-sheets",
"lark-skill-maker",
"lark-slides",
"lark-task",
"lark-vc",
"lark-vc-agent",
"lark-whiteboard",
"lark-wiki",
"lark-workflow-meeting-summary",
"lark-workflow-standup-report",
)
LARK_SKILL_NAME_SET = frozenset(LARK_SKILL_NAMES)
_LARK_INSTALL_THREAD_LOCK = threading.Lock()
_LARK_RUNTIME_INSTALL_THREAD_LOCK = threading.Lock()
@dataclass(frozen=True)
class LarkCliProbe:
available: bool
path: str | None = None
version: str | None = None
error: str | None = None
@dataclass(frozen=True)
class LarkAuthProbe:
status: str
message: str | None = None
user: str | None = None
verified: bool = False
@dataclass(frozen=True)
class LarkIntegrationStatus:
installed: bool
version: str
manifest_version: str | None
latest_available_version: str | None
runtime_version_mismatch: bool
app_configured: bool
app_id: str | None
app_brand: str | None
skills_expected: int
skills_installed: int
installed_skills: tuple[str, ...]
enabled_skills: tuple[str, ...]
install_path: str
cli: LarkCliProbe
auth: LarkAuthProbe
sandbox_runtime_mode: str = "none"
sandbox_runtime_ready: bool = False
sandbox_runtime_detail: str | None = None
@dataclass(frozen=True)
class LarkInstallResult:
success: bool
installed_skills: tuple[str, ...]
status: LarkIntegrationStatus
message: str
@dataclass(frozen=True)
class LarkConfigStartResult:
verification_url: str
device_code: str
expires_in: int | None = None
interval: int | None = None
user_code: str | None = None
brand: str = "feishu"
@dataclass(frozen=True)
class LarkConfigCompleteResult:
success: bool
status: LarkIntegrationStatus
message: str
@dataclass(frozen=True)
class LarkAuthStartResult:
verification_url: str
device_code: str
expires_in: int | None = None
user_code: str | None = None
hint: str | None = None
@dataclass(frozen=True)
class LarkAuthCompleteResult:
success: bool
status: LarkIntegrationStatus
message: str
def lark_integration_root(_user_id: str | None = None) -> Path:
"""Return the shared root for globally installed managed Lark skills.
``_user_id`` is accepted temporarily for source compatibility with the
pre-global-install API; it does not influence the shared package path.
"""
return get_paths().integration_skills_dir() / INTEGRATION_ID
def lark_manifest_path(user_id: str) -> Path:
return lark_integration_root(user_id) / LARK_CLI_MANIFEST_FILE
def lark_skills_installed(user_id: str | None = None) -> bool:
"""Whether the managed Lark skill pack is installed.
Mirrors the ``installed`` field of :func:`get_lark_integration_status`
(manifest present and ``lark-shared`` extracted) without probing the CLI or
auth. Used to decide whether a sandbox should request the lark-cli runtime.
"""
root = lark_integration_root(user_id)
manifest = _read_manifest(root)
if not manifest:
return False
return "lark-shared" in _installed_lark_skill_names(root)
def lark_cli_config_dir(user_id: str) -> Path:
return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "config"
def lark_cli_data_dir(user_id: str) -> Path:
return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "data"
def ensure_lark_cli_credential_tree(user_id: str, *, paths: Paths | None = None) -> None:
"""Make the user's secret-bearing Lark CLI tree owner-only.
The CLI writes plaintext app secrets and OAuth tokens beneath this tree.
Reject links before changing modes so a compromised tree cannot redirect a
chmod or subsequent CLI write outside the user's integration directory.
"""
paths = paths or get_paths()
root = paths.user_dir(user_id) / "integrations" / INTEGRATION_ID
if root.is_symlink():
raise ValueError(f"Lark CLI credential path must not be a symlink: {root}")
root.mkdir(parents=True, exist_ok=True, mode=0o700)
root.chmod(0o700)
for required in (root / "config", root / "data"):
if required.is_symlink():
raise ValueError(f"Lark CLI credential path must not be a symlink: {required}")
required.mkdir(parents=True, exist_ok=True, mode=0o700)
for path in root.rglob("*"):
if path.is_symlink():
raise ValueError(f"Lark CLI credential path must not be a symlink: {path}")
if path.is_dir():
path.chmod(0o700)
elif path.is_file():
path.chmod(0o600)
else:
raise ValueError(f"Unsupported file type in Lark CLI credential tree: {path}")
def lark_cli_managed_gateway_dir() -> Path:
"""Gateway-scoped DeerFlow-managed lark-cli install root."""
return get_paths().base_dir / "integrations" / INTEGRATION_ID / "gateway-cli"
def lark_cli_managed_sandbox_dir() -> Path:
"""Gateway-visible source directory mounted into Linux AIO sandboxes."""
return get_paths().base_dir / "integrations" / INTEGRATION_ID / "sandbox-cli"
def _lark_cli_release_asset_name(version: str, arch: str) -> str:
tag = _normalize_lark_cli_version_tag(version)
if tag is None:
raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
if arch not in LARK_CLI_LINUX_ARCHES:
raise ValueError(f"Unsupported Lark CLI Linux architecture: {arch!r}")
return f"lark-cli-{tag.removeprefix('v')}-linux-{arch}.tar.gz"
def _lark_cli_release_asset_url(version: str, asset_name: str) -> str:
tag = _normalize_lark_cli_version_tag(version)
if tag is None:
raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
quoted_asset = urllib.parse.quote(asset_name, safe="")
return f"https://github.com/{LARK_CLI_GITHUB_REPO}/releases/download/{tag}/{quoted_asset}"
def _download_lark_release_asset(version: str, asset_name: str, *, max_bytes: int = LARK_CLI_MAX_RUNTIME_ASSET_BYTES) -> bytes:
"""Download one official release asset with a strict size bound."""
request = urllib.request.Request(
_lark_cli_release_asset_url(version, asset_name),
headers={"Accept": "application/octet-stream", "User-Agent": "deer-flow"},
)
try:
with urllib.request.urlopen(request, timeout=LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS) as response:
chunks: list[bytes] = []
total = 0
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > max_bytes:
raise ValueError(f"Lark CLI release asset {asset_name!r} is too large.")
chunks.append(chunk)
except ValueError:
raise
except Exception as exc: # noqa: BLE001 - network boundary
raise ValueError(f"Could not download official Lark CLI release asset {asset_name!r} for {version}.") from exc
return b"".join(chunks)
def _release_checksums(raw: bytes) -> dict[str, str]:
checksums: dict[str, str] = {}
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("Lark CLI release checksums are not valid UTF-8.") from exc
for line in text.splitlines():
parts = line.strip().split()
if len(parts) < 2 or not re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
continue
checksums[parts[-1].lstrip("*")] = parts[0].lower()
return checksums
def _extract_lark_cli_runtime_binary(archive: bytes, destination: Path) -> None:
"""Safely extract the single CLI executable from an official tar archive."""
candidate: bytes | None = None
total = 0
try:
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:*") as tf:
for member in tf.getmembers():
normalized = posixpath.normpath(member.name.replace("\\", "/"))
parts = PurePosixPath(normalized).parts
if normalized.startswith("/") or ".." in parts or member.issym() or member.islnk() or not (member.isdir() or member.isfile()):
raise ValueError(f"Unsafe Lark CLI runtime archive member: {member.name}")
if member.isfile():
total += member.size
if total > LARK_CLI_MAX_RUNTIME_ASSET_BYTES:
raise ValueError("Lark CLI runtime archive expands beyond the allowed size.")
if PurePosixPath(normalized).name == "lark-cli":
extracted = tf.extractfile(member)
if extracted is None or candidate is not None:
raise ValueError("Lark CLI runtime archive must contain exactly one lark-cli executable.")
candidate = extracted.read()
except tarfile.TarError as exc:
raise ValueError("Lark CLI runtime archive is not a valid tar archive.") from exc
if not candidate:
raise ValueError("Lark CLI runtime archive does not contain a lark-cli executable.")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(candidate)
destination.chmod(0o755)
def _write_lark_cli_sandbox_launcher(staging: Path) -> None:
launcher = staging / "bin" / "lark-cli"
launcher.parent.mkdir(parents=True, exist_ok=True)
launcher.write_text(LARK_CLI_SANDBOX_LAUNCHER_SCRIPT, encoding="utf-8")
launcher.chmod(0o755)
def _validate_lark_cli_sandbox_runtime(root: Path) -> None:
if root.is_symlink() or not root.is_dir():
raise ValueError("Managed Lark CLI sandbox runtime root must be a regular directory, not a symlink.")
for path in root.rglob("*"):
if path.is_symlink():
raise ValueError(f"Managed Lark CLI sandbox runtime must not contain a symlink: {path}")
if not (path.is_dir() or path.is_file()):
raise ValueError(f"Managed Lark CLI sandbox runtime contains an unsupported file type: {path}")
for relative in (Path("bin/lark-cli"), *(Path(f"linux-{arch}/lark-cli") for arch in LARK_CLI_LINUX_ARCHES)):
candidate = root / relative
if not candidate.is_file():
raise ValueError(f"Managed Lark CLI sandbox runtime is missing a regular file: {relative}")
if candidate.stat().st_mode & 0o111 == 0:
raise ValueError(f"Managed Lark CLI sandbox runtime file is not executable: {relative}")
def _read_json_object_file(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
try:
parsed = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return parsed if isinstance(parsed, dict) else None
@contextmanager
def _exclusive_install_lock(lock_path: Path, thread_lock):
"""Hold one advisory file lock plus its in-process counterpart."""
with thread_lock, lock_path.open("a+b") as lock_file:
lock_file.seek(0, os.SEEK_END)
if lock_file.tell() == 0:
lock_file.write(b"\0")
lock_file.flush()
lock_file.seek(0)
if fcntl is not None:
fcntl.flock(lock_file, fcntl.LOCK_EX)
else: # pragma: no cover - Windows fallback
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
try:
yield
finally:
lock_file.seek(0)
if fcntl is not None:
fcntl.flock(lock_file, fcntl.LOCK_UN)
else: # pragma: no cover - Windows fallback
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
def _ensure_managed_sandbox_lark_cli(version: str) -> Path:
"""Install verified official Linux binaries for AIO sandbox execution."""
tag = _normalize_lark_cli_version_tag(version)
if tag is None:
raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
target = lark_cli_managed_sandbox_dir()
parent = target.parent
parent.mkdir(parents=True, exist_ok=True)
with _exclusive_install_lock(parent / ".sandbox-cli.install.lock", _LARK_RUNTIME_INSTALL_THREAD_LOCK):
return _ensure_managed_sandbox_lark_cli_locked(tag, target, parent)
def _ensure_managed_sandbox_lark_cli_locked(tag: str, target: Path, parent: Path) -> Path:
manifest = _read_json_object_file(target / LARK_CLI_RUNTIME_MANIFEST_FILE)
if manifest and manifest.get("version") == tag:
_validate_lark_cli_sandbox_runtime(target)
return target
staging = Path(tempfile.mkdtemp(prefix=".installing-sandbox-cli-", dir=str(parent)))
backup: Path | None = None
try:
source_override = os.getenv(LARK_CLI_SANDBOX_RUNTIME_SOURCE_ENV)
if source_override:
source = Path(source_override)
_validate_lark_cli_sandbox_runtime(source)
shutil.copytree(source, staging, dirs_exist_ok=True, symlinks=False)
else:
checksums = _release_checksums(_download_lark_release_asset(tag, "checksums.txt", max_bytes=1024 * 1024))
for arch in LARK_CLI_LINUX_ARCHES:
asset_name = _lark_cli_release_asset_name(tag, arch)
archive = _download_lark_release_asset(tag, asset_name)
expected = checksums.get(asset_name)
actual = hashlib.sha256(archive).hexdigest()
if expected is None or actual != expected:
raise ValueError(f"Lark CLI release asset checksum mismatch: {asset_name}")
_extract_lark_cli_runtime_binary(archive, staging / f"linux-{arch}" / "lark-cli")
_write_lark_cli_sandbox_launcher(staging)
_validate_lark_cli_sandbox_runtime(staging)
(staging / LARK_CLI_RUNTIME_MANIFEST_FILE).write_text(
json.dumps({"version": tag}, indent=2) + "\n",
encoding="utf-8",
)
if target.exists():
backup = parent / f".replacing-sandbox-cli-{os.getpid()}"
if backup.exists():
shutil.rmtree(backup, ignore_errors=True)
target.rename(backup)
staging.rename(target)
if backup is not None:
shutil.rmtree(backup, ignore_errors=True)
return target
except Exception:
if backup is not None and backup.exists() and not target.exists():
backup.rename(target)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
def _lark_cli_managed_bin_dir() -> Path:
return lark_cli_managed_gateway_dir() / "node_modules" / ".bin"
def _lark_cli_managed_path() -> str | None:
for name in ("lark-cli", "lark-cli.cmd"):
candidate = _lark_cli_managed_bin_dir() / name
if candidate.exists():
return str(candidate)
return None
def lark_cli_env_overlay(user_id: str, *, sandbox_paths: bool = False) -> dict[str, str]:
"""Environment overlay for lark-cli using DeerFlow-managed credentials.
The directories are per-user so a local trusted-mode login cannot bleed
across accounts. Auth Proxy support can later replace these directories for
sandbox execution without changing the status API contract.
"""
if sandbox_paths:
config_dir: Path | str = LARK_CLI_SANDBOX_CONFIG_DIR
data_dir: Path | str = LARK_CLI_SANDBOX_DATA_DIR
else:
config_dir = lark_cli_config_dir(user_id)
data_dir = lark_cli_data_dir(user_id)
ensure_lark_cli_credential_tree(user_id)
overlay = {
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
"LARKSUITE_CLI_DATA_DIR": str(data_dir),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
}
if not sandbox_paths and _lark_cli_managed_path() is not None:
overlay["PATH"] = f"{_lark_cli_managed_bin_dir()}{os.pathsep}{os.environ.get('PATH', '')}"
elif sandbox_paths:
overlay["PATH"] = f"{LARK_CLI_SANDBOX_RUNTIME_DIR}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
return overlay
def lark_cli_env(user_id: str) -> dict[str, str]:
"""Full environment for Gateway-side lark-cli probes."""
return {**os.environ, **lark_cli_env_overlay(user_id)}
def probe_lark_cli() -> LarkCliProbe:
path = _resolve_lark_cli_path()
if path is None:
return LarkCliProbe(available=False, error="lark-cli is not installed on the Gateway")
return _probe_lark_cli_at_path(path)
def _probe_lark_cli_at_path(path: str) -> LarkCliProbe:
try:
result = subprocess.run(
[path, "--version"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
except Exception as exc: # noqa: BLE001 - probe boundary
return LarkCliProbe(available=False, path=path, error=str(exc))
output = (result.stdout or result.stderr or "").strip()
if result.returncode != 0:
return LarkCliProbe(available=False, path=path, error=output or f"exit code {result.returncode}")
return LarkCliProbe(available=True, path=path, version=output or None)
def probe_lark_auth(user_id: str, *, verify: bool = False) -> LarkAuthProbe:
"""Probe the user's Lark authorization state.
By default this only checks local token presence (``auth status --json``),
which is cheap and offline — suitable for the frequently-polled status
endpoint. Pass ``verify=True`` to add ``--verify`` for a live token check
against Lark; reserve that for the explicit "complete authorization" step
since it costs a network round-trip on every call.
"""
path = _resolve_lark_cli_path()
if path is None:
return LarkAuthProbe(status="unavailable", message="lark-cli is not installed on the Gateway")
app_config = read_lark_app_config(user_id)
if not app_config["configured"]:
return LarkAuthProbe(status="not_configured", message="Lark app is not configured")
args = [path, "auth", "status", "--json"]
if verify:
args.append("--verify")
try:
result = subprocess.run(
args,
check=False,
capture_output=True,
text=True,
timeout=8,
env=lark_cli_env(user_id),
)
except subprocess.TimeoutExpired:
return LarkAuthProbe(status="error", message="lark-cli auth status timed out")
except Exception as exc: # noqa: BLE001 - probe boundary
return LarkAuthProbe(status="error", message=str(exc))
raw = (result.stdout or result.stderr or "").strip()
data: dict[str, Any] | None = None
if raw:
try:
parsed = json.loads(raw)
data = parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
data = None
if result.returncode != 0:
message = _auth_error_message(data) if data else raw
return LarkAuthProbe(status="not_authorized", message=message or "Lark user authorization is not configured")
user = None
if data:
identities = data.get("identities")
if isinstance(identities, dict):
user_info = identities.get("user")
if isinstance(user_info, dict):
user = str(user_info.get("userName") or user_info.get("openId") or "") or None
if user is None and data.get("userName"):
user = str(data["userName"])
if verify:
return LarkAuthProbe(
status="authenticated",
user=user,
message="Lark/Feishu authorization is live-verified.",
verified=True,
)
return LarkAuthProbe(
status="authenticated",
user=user,
message="Lark/Feishu credentials are configured locally but not live-verified.",
verified=False,
)
def _resolve_sandbox_runtime_readiness(
config: AppConfig,
*,
probe: bool,
) -> tuple[str, bool, str | None]:
"""Resolve the sandbox lark-cli runtime mode and readiness.
Modes:
- ``none``: sandboxes don't run lark-cli (non-AIO provider).
- ``gateway-download``: local AIO — the Gateway stages a ``sandbox-cli`` dir
and bind-mounts it; ready when that dir validates.
- ``init-container``: remote provisioner — a lark-cli init image provisions
the runtime; ready when the provisioner reports the init image is configured.
``probe`` gates the (best-effort, short-timeout) provisioner capability call.
"""
if not _uses_aio_sandbox(config):
return "none", False, "Sandbox does not run lark-cli in this configuration."
if _uses_remote_provisioner(config):
if not probe:
return "init-container", False, None
init_image = _probe_provisioner_lark_cli_init_image(config)
if init_image is None:
return "init-container", False, "Could not reach the provisioner to confirm the lark-cli init image."
if not init_image:
return "init-container", False, "The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)."
return "init-container", True, None
# Local AIO: Gateway-download runtime dir.
runtime_dir = lark_cli_managed_sandbox_dir()
try:
_validate_lark_cli_sandbox_runtime(runtime_dir)
except (ValueError, OSError):
return "gateway-download", False, "The managed sandbox lark-cli runtime is not installed."
return "gateway-download", True, None
def get_lark_integration_status(
user_id: str,
config: AppConfig,
*,
verify_auth: bool = False,
check_latest: bool = False,
check_runtime: bool = False,
) -> LarkIntegrationStatus:
root = lark_integration_root(user_id)
manifest = _read_manifest(root)
app_config = read_lark_app_config(user_id)
installed_skills = tuple(sorted(_installed_lark_skill_names(root)))
enabled_skills = tuple(sorted(_enabled_lark_skill_names(user_id, config)))
manifest_version = str(manifest.get("version")) if manifest else None
cli = probe_lark_cli()
latest_available = _cached_latest_lark_cli_version() if check_latest else None
runtime_mode, runtime_ready, runtime_detail = _resolve_sandbox_runtime_readiness(config, probe=check_runtime)
return LarkIntegrationStatus(
installed=bool(manifest) and "lark-shared" in installed_skills,
version=manifest_version or FALLBACK_LARK_CLI_VERSION,
manifest_version=manifest_version,
latest_available_version=latest_available,
runtime_version_mismatch=_versions_drifted(manifest_version, cli.version),
app_configured=bool(app_config["configured"]),
app_id=app_config["app_id"],
app_brand=app_config["brand"],
skills_expected=len(LARK_SKILL_NAMES),
skills_installed=len(installed_skills),
installed_skills=installed_skills,
enabled_skills=enabled_skills,
install_path=str(root),
cli=cli,
auth=probe_lark_auth(user_id, verify=verify_auth),
sandbox_runtime_mode=runtime_mode,
sandbox_runtime_ready=runtime_ready,
sandbox_runtime_detail=runtime_detail,
)
def _normalize_version(value: str | None) -> str | None:
"""Extract a comparable ``major.minor.patch`` from a version-ish string."""
if not value:
return None
match = re.search(r"\d+\.\d+\.\d+", value)
return match.group(0) if match else None
def _versions_drifted(manifest_version: str | None, cli_version: str | None) -> bool:
"""True when both versions are known and their numeric cores differ.
The manifest records the installed skill-pack version; ``cli_version`` is
the Gateway runtime ``lark-cli`` binary. Unknown on either side means we
cannot claim a mismatch, so we stay quiet.
"""
left = _normalize_version(manifest_version)
right = _normalize_version(cli_version)
if left is None or right is None:
return False
return left != right
def _resolve_runtime_lark_cli_version() -> str:
"""Resolve the skill-pack version that matches the Gateway runtime CLI.
Managed Lark skills are executed by the server-side ``lark-cli`` binary, so
integration installs should align to that binary rather than blindly taking
GitHub's newest release. Packaged deployments install the pinned fallback in
the Gateway image; local/dev deployments can override this by putting a
newer ``lark-cli`` on the Gateway PATH and restarting the backend.
"""
cli = probe_lark_cli()
version = _normalize_version(cli.version)
return f"v{version}" if version is not None else FALLBACK_LARK_CLI_VERSION
def read_lark_app_config(user_id: str) -> dict[str, str | bool | None]:
ensure_lark_cli_credential_tree(user_id)
config_path = lark_cli_config_dir(user_id) / "config.json"
if not config_path.is_file():
return {"configured": False, "app_id": None, "brand": None}
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"configured": False, "app_id": None, "brand": None}
if not isinstance(data, dict):
return {"configured": False, "app_id": None, "brand": None}
apps = data.get("apps")
if not isinstance(apps, list) or not apps:
return {"configured": False, "app_id": None, "brand": None}
current = data.get("currentApp")
app = None
if isinstance(current, str) and current:
app = next((candidate for candidate in apps if isinstance(candidate, dict) and (candidate.get("name") == current or candidate.get("appId") == current)), None)
if app is None:
app = apps[0] if isinstance(apps[0], dict) else None
if not isinstance(app, dict):
return {"configured": False, "app_id": None, "brand": None}
app_id = str(app.get("appId") or "").strip()
app_secret = app.get("appSecret")
brand = str(app.get("brand") or "feishu").strip() or "feishu"
return {"configured": bool(app_id and app_secret), "app_id": app_id or None, "brand": brand}
def install_lark_integration(
user_id: str,
config: AppConfig,
*,
source_archive: str | Path | None = None,
) -> LarkInstallResult:
env_archive = os.getenv(LARK_CLI_SOURCE_ARCHIVE_ENV)
if source_archive is not None:
archive_path = Path(source_archive)
resolved_version = None
created_temp_archive = False
elif env_archive:
archive_path = Path(env_archive)
resolved_version = None
created_temp_archive = False
else:
cli = _ensure_managed_gateway_lark_cli()
runtime_version = _normalize_version(cli.version)
resolved_version = f"v{runtime_version}" if runtime_version is not None else FALLBACK_LARK_CLI_VERSION
archive_path = _download_lark_archive(resolved_version)
created_temp_archive = True
previous = _read_manifest(lark_integration_root(user_id))
previous_content_sha = str(previous.get("content_sha256")) if previous else None
try:
installed_skills, content_sha = _install_lark_skills_from_archive(user_id, archive_path, version=resolved_version)
finally:
if created_temp_archive:
try:
archive_path.unlink(missing_ok=True)
except OSError:
pass
if _uses_aio_sandbox(config) and not _uses_remote_provisioner(config):
installed_manifest = _read_manifest(lark_integration_root()) or {}
sandbox_version = str(installed_manifest.get("version") or resolved_version or FALLBACK_LARK_CLI_VERSION)
_ensure_managed_sandbox_lark_cli(sandbox_version)
status = get_lark_integration_status(user_id, config)
content_changed = previous_content_sha is not None and previous_content_sha != content_sha
message = f"Installed {len(installed_skills)} Lark/Feishu skills."
if content_changed:
message += " Skill content changed since the previous install."
return LarkInstallResult(
success=True,
installed_skills=installed_skills,
status=status,
message=message,
)
def _uses_aio_sandbox(config: AppConfig) -> bool:
sandbox = getattr(config, "sandbox", None)
use = getattr(sandbox, "use", None)
if use is None and isinstance(sandbox, dict):
use = sandbox.get("use")
return isinstance(use, str) and "aio_sandbox" in use.lower()
def _sandbox_config_value(config: AppConfig, key: str) -> str:
"""Read a sandbox config value as a string, tolerating dict/attr configs."""
sandbox = getattr(config, "sandbox", None)
value = getattr(sandbox, key, None)
if value is None and isinstance(sandbox, dict):
value = sandbox.get(key)
return str(value).strip() if value else ""
def _uses_remote_provisioner(config: AppConfig) -> bool:
"""True when sandboxes are provisioned by a remote provisioner (K8s mode)."""
return bool(_sandbox_config_value(config, "provisioner_url"))
def _probe_provisioner_lark_cli_init_image(config: AppConfig) -> bool | None:
"""Best-effort read of the provisioner's lark-cli init-image capability.
Returns True/False when the provisioner answers, or None when it can't be
reached. Used only to surface a sandbox-runtime readiness signal; failures
degrade to "not ready" rather than raising.
"""
base = _sandbox_config_value(config, "provisioner_url")
if not base:
return None
api_key = _sandbox_config_value(config, "provisioner_api_key")
headers = {"X-API-Key": api_key} if api_key else {}
url = f"{base.rstrip('/')}/api/capabilities"
try:
request = urllib.request.Request(url, headers={"User-Agent": "deer-flow", **headers})
with urllib.request.urlopen(request, timeout=5) as response:
payload = json.loads(response.read().decode("utf-8"))
return bool(payload.get("lark_cli_init_image"))
except Exception:
return None
def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStartResult:
"""Start the browser flow that creates/binds a Lark OAuth app for this user."""
parsed_brand = _normalize_lark_brand(brand)
begin_data = _request_lark_app_registration_begin(parsed_brand)
user_code = str(begin_data.get("user_code") or "").strip()
device_code = str(begin_data.get("device_code") or "").strip()
if not user_code or not device_code:
raise ValueError("Lark app registration did not return a user_code and device_code.")
verification_url = _build_lark_config_verification_url(parsed_brand, user_code)
return LarkConfigStartResult(
verification_url=verification_url,
device_code=device_code,
expires_in=_int_or_none(begin_data.get("expires_in")),
interval=_int_or_none(begin_data.get("interval")),
user_code=user_code,
brand=parsed_brand,
)
def complete_lark_config(
user_id: str,
config: AppConfig,
*,
device_code: str,
brand: str = "feishu",
interval: int | None = None,
expires_in: int | None = None,
) -> LarkConfigCompleteResult:
"""Complete app registration and persist app credentials through lark-cli."""
device_code = device_code.strip()
if not device_code:
raise ValueError("device_code is required.")
parsed_brand = _normalize_lark_brand(brand)
result = _poll_lark_app_registration(
device_code=device_code,
brand=parsed_brand,
interval=interval or 5,
expires_in=expires_in or 300,
)
if not result.get("client_secret") and _tenant_brand(result) == "lark":
# Lark CLI starts polling on the Feishu accounts host for both brands.
# For Lark tenants that response can include user_info.tenant_brand and
# client_id but omit client_secret; polling the Lark accounts host with
# the same device_code returns the complete app credentials.
result = _poll_lark_app_registration(
device_code=device_code,
brand="lark",
interval=interval or 5,
expires_in=expires_in or 300,
)
app_id = str(result.get("client_id") or "").strip()
app_secret = str(result.get("client_secret") or "").strip()
final_brand = _tenant_brand(result) or parsed_brand
if not app_id or not app_secret:
raise ValueError("Lark app registration succeeded but did not return app credentials.")
_save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=final_brand)
status = get_lark_integration_status(user_id, config)
return LarkConfigCompleteResult(
success=True,
status=status,
message="Lark/Feishu connection setup completed.",
)
def start_lark_auth(
user_id: str,
*,
domains: tuple[str, ...] = (),
scope: str | None = None,
recommend: bool = False,
) -> LarkAuthStartResult:
"""Start a non-blocking Lark device authorization flow.
The returned URL is safe to show in the browser UI or in a chat message.
``device_code`` must be sent back to :func:`complete_lark_auth` after the
user finishes authorization in Lark/Feishu.
"""
path = _require_lark_cli_path()
args = [path, "auth", "login", "--no-wait", "--json"]
if recommend:
args.append("--recommend")
if scope:
args.extend(["--scope", scope])
for domain in domains:
if domain:
args.extend(["--domain", domain])
data = _run_lark_cli_json(args, user_id=user_id, timeout=20)
verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip()
device_code = str(data.get("device_code") or "").strip()
if not verification_url or not device_code:
raise ValueError("lark-cli did not return a verification_url and device_code.")
return LarkAuthStartResult(
verification_url=verification_url,
device_code=device_code,
expires_in=_int_or_none(data.get("expires_in")),
user_code=str(data.get("user_code") or "") or None,
hint=str(data.get("hint") or "") or None,
)
def complete_lark_auth(
user_id: str,
config: AppConfig,
*,
device_code: str,
wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
) -> LarkAuthCompleteResult:
"""Complete a Lark device authorization flow after the user approves it."""
device_code = device_code.strip()
if not device_code:
raise ValueError("device_code is required.")
if not LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS <= wait_timeout_seconds <= LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS:
raise ValueError(f"wait_timeout_seconds must be between {LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS} and {LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS}.")
path = _require_lark_cli_path()
_run_lark_cli_json(
[path, "auth", "login", "--device-code", device_code, "--json"],
user_id=user_id,
timeout=wait_timeout_seconds,
allow_empty_success=True,
)
status = get_lark_integration_status(user_id, config, verify_auth=True)
return LarkAuthCompleteResult(
success=status.auth.status == "authenticated",
status=status,
message="Lark/Feishu authorization completed." if status.auth.status == "authenticated" else (status.auth.message or "Lark/Feishu authorization status is still pending."),
)
def _resolve_lark_cli_path() -> str | None:
return _lark_cli_managed_path() or shutil.which("lark-cli")
def _ensure_managed_gateway_lark_cli() -> LarkCliProbe:
"""Install/update the DeerFlow-managed Gateway lark-cli.
This is called by the admin install endpoint so non-technical users do not
need to install ``@larksuite/cli`` in a terminal. If npm/GitHub are not
reachable but an existing CLI is already available (managed or on PATH), we
keep using it and let the skill-pack install align to that runtime version.
"""
target_version = _resolve_latest_lark_cli_version()
current = probe_lark_cli()
current_version = _normalize_version(current.version)
if current.available and current_version == _normalize_version(target_version):
return current
try:
return _install_managed_gateway_lark_cli(target_version)
except Exception:
fallback = probe_lark_cli()
if fallback.available:
logger.warning("Could not update managed lark-cli; using existing Gateway lark-cli", exc_info=True)
return fallback
raise
def _install_managed_gateway_lark_cli(version: str) -> LarkCliProbe:
normalized = _normalize_lark_cli_version_tag(version)
if normalized is None:
raise ValueError(f"Invalid Lark CLI npm version: {version!r}")
npm_version = normalized.removeprefix("v")
npm = shutil.which("npm")
if npm is None:
raise FileNotFoundError("npm is not available on the Gateway; cannot install managed @larksuite/cli.")
install_root = lark_cli_managed_gateway_dir()
install_root.mkdir(parents=True, exist_ok=True)
# NOTE: this runs @larksuite/cli's install scripts (postinstall fetches the
# platform lark-cli binary), so `--ignore-scripts` is not viable here — the
# CLI would be unusable without it. The tradeoff: an admin-triggered install
# executes the official package's install scripts (and those of its deps)
# with Gateway privileges, so a supply-chain compromise of that package is
# the blast radius. This mirrors the pinned `npm install -g` in the Gateway
# Dockerfile; both are gated behind the admin install action.
result = subprocess.run(
[
npm,
"install",
"--prefix",
str(install_root),
"--no-audit",
"--no-fund",
f"{LARK_CLI_NPM_PACKAGE}@{npm_version}",
],
check=False,
capture_output=True,
text=True,
timeout=LARK_CLI_NPM_INSTALL_TIMEOUT_SECONDS,
env={**os.environ, "npm_config_update_notifier": "false"},
)
if result.returncode != 0:
raw = (result.stderr or result.stdout or "").strip()
raise ValueError(raw or f"npm install {LARK_CLI_NPM_PACKAGE}@{npm_version} exited with code {result.returncode}")
path = _lark_cli_managed_path()
if path is None:
raise FileNotFoundError("Managed lark-cli install completed, but no lark-cli binary was found.")
probe = _probe_lark_cli_at_path(path)
if not probe.available:
raise ValueError(probe.error or "Managed lark-cli install did not produce a runnable CLI.")
return probe
def _require_lark_cli_path() -> str:
path = _resolve_lark_cli_path()
if path is None:
raise FileNotFoundError("lark-cli is not installed on the Gateway. Install the managed Lark integration as an admin, or rebuild the Gateway image with @larksuite/cli installed.")
return path
def _normalize_lark_brand(brand: str) -> str:
return "lark" if brand.strip().lower() == "lark" else "feishu"
def _lark_endpoints(brand: str) -> dict[str, str]:
if _normalize_lark_brand(brand) == "lark":
return {
"open": "https://open.larksuite.com",
"accounts": "https://accounts.larksuite.com",
}
return {
"open": "https://open.feishu.cn",
"accounts": "https://accounts.feishu.cn",
}
def _request_lark_app_registration_begin(brand: str) -> dict[str, Any]:
# lark-cli uses the Feishu accounts endpoint for the begin step, then
# switches to the tenant brand only if the poll response indicates Lark.
accounts_url = _lark_endpoints("feishu")["accounts"] + _LARK_APP_REGISTRATION_PATH
body = urllib.parse.urlencode(
{
"action": "begin",
"archetype": "PersonalAgent",
"auth_method": "client_secret",
"request_user_info": "open_id tenant_brand",
}
).encode("utf-8")
data = _post_lark_form(accounts_url, body)
if "error" in data:
raise ValueError(str(data.get("error_description") or data.get("error") or "Lark app registration failed."))
return data
def _build_lark_config_verification_url(brand: str, user_code: str) -> str:
base = f"{_lark_endpoints(brand)['open']}/page/cli"
# lpv/ocv mirror the *runtime* lark-cli client version doing the auth, which
# is the server-side Gateway binary — not the latest available skill-pack
# version.
runtime_version = _resolve_runtime_lark_cli_version()
query = urllib.parse.urlencode(
{
"user_code": user_code,
"lpv": runtime_version,
"ocv": runtime_version,
"from": "cli",
}
)
return f"{base}?{query}"
def _poll_lark_app_registration(
*,
device_code: str,
brand: str,
interval: int,
expires_in: int,
) -> dict[str, Any]:
accounts_url = _lark_endpoints(brand)["accounts"] + _LARK_APP_REGISTRATION_PATH
deadline = time.monotonic() + min(max(expires_in, 1), LARK_CONFIG_POLL_TIMEOUT_SECONDS)
poll_interval = max(min(interval, 10), 1)
last_error = "authorization_pending"
while time.monotonic() < deadline:
body = urllib.parse.urlencode({"action": "poll", "device_code": device_code}).encode("utf-8")
data = _post_lark_form(accounts_url, body)
if not data.get("error") and data.get("client_id"):
return data
error = str(data.get("error") or "")
last_error = str(data.get("error_description") or error or "Lark app registration is still pending.")
if error == "authorization_pending":
time.sleep(poll_interval)
continue
if error == "slow_down":
poll_interval = min(poll_interval + 5, 30)
time.sleep(poll_interval)
continue
raise ValueError(last_error)
raise TimeoutError(f"Lark app registration is still pending: {last_error}")
def _post_lark_form(url: str, body: bytes) -> dict[str, Any]:
request = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
raw = response.read().decode("utf-8")
except Exception as exc: # noqa: BLE001 - network boundary
raise ValueError(f"Lark app registration request failed: {exc}") from exc
parsed = _parse_json_object(raw)
if parsed is None:
raise ValueError("Lark app registration returned non-JSON response.")
return parsed
def _tenant_brand(result: dict[str, Any]) -> str | None:
user_info = result.get("user_info")
if not isinstance(user_info, dict):
return None
brand = str(user_info.get("tenant_brand") or "").strip().lower()
return brand if brand in {"feishu", "lark"} else None
def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None:
path = _require_lark_cli_path()
try:
try:
result = subprocess.run(
[path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", _normalize_lark_brand(brand)],
input=app_secret + "\n",
check=False,
capture_output=True,
text=True,
timeout=15,
env=lark_cli_env(user_id),
)
except subprocess.TimeoutExpired as exc:
raise TimeoutError("Timed out while saving Lark connection setup.") from exc
finally:
ensure_lark_cli_credential_tree(user_id)
if result.returncode != 0:
raw = (result.stderr or result.stdout or "").strip()
parsed = _parse_json_object(raw)
message = _auth_error_message(parsed) if parsed else raw
raise ValueError(message or f"lark-cli config init exited with code {result.returncode}")
def _run_lark_cli_json(
args: list[str],
*,
user_id: str,
timeout: int,
allow_empty_success: bool = False,
) -> dict[str, Any]:
try:
try:
result = subprocess.run(
args,
check=False,
capture_output=True,
text=True,
timeout=timeout,
env=lark_cli_env(user_id),
)
except subprocess.TimeoutExpired as exc:
raise TimeoutError("Timed out waiting for Lark/Feishu authorization. Complete authorization in the browser, then try again.") from exc
finally:
# OAuth commands may create new plaintext token files after the
# pre-command environment guard has run. Re-harden every file even on
# timeout or CLI failure before returning control to the Gateway.
ensure_lark_cli_credential_tree(user_id)
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
raw = stdout or stderr
parsed = _parse_json_object(raw)
if result.returncode != 0:
message = _auth_error_message(parsed) if parsed else raw
raise ValueError(message or f"lark-cli exited with code {result.returncode}")
if not raw and allow_empty_success:
return {}
if parsed is None:
if allow_empty_success:
return {}
raise ValueError(raw or "lark-cli did not return JSON output.")
return parsed
def _parse_json_object(raw: str) -> dict[str, Any] | None:
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def _int_or_none(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _auth_error_message(data: dict[str, Any] | None) -> str | None:
if not data:
return None
error = data.get("error")
if isinstance(error, dict):
for key in ("message", "hint", "type"):
value = error.get(key)
if value:
return str(value)
for key in ("message", "msg", "hint"):
value = data.get(key)
if value:
return str(value)
return None
def _read_manifest(root: Path) -> dict[str, Any] | None:
path = root / LARK_CLI_MANIFEST_FILE
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _installed_lark_skill_names(root: Path) -> set[str]:
names: set[str] = set()
if not root.is_dir():
return names
for skill_name in LARK_SKILL_NAMES:
if (root / skill_name / SKILL_MD_FILE).is_file():
names.add(skill_name)
return names
def _enabled_lark_skill_names(user_id: str, config: AppConfig) -> set[str]:
from deerflow.skills.storage import get_or_new_user_skill_storage
try:
storage = get_or_new_user_skill_storage(user_id, app_config=config)
return {skill.name for skill in storage.load_skills(enabled_only=True) if skill.name in LARK_SKILL_NAME_SET}
except Exception:
return set()
def _resolve_latest_lark_cli_version() -> str:
"""Resolve the newest published ``larksuite/cli`` release tag.
Queries the official ``releases/latest`` API. Any failure (rate limit,
offline, air-gapped, malformed payload) falls back to
``FALLBACK_LARK_CLI_VERSION`` so an install can still proceed with a known
good version rather than aborting.
"""
try:
request = urllib.request.Request(
LARK_CLI_LATEST_RELEASE_API,
headers={"Accept": "application/vnd.github+json", "User-Agent": "deer-flow"},
)
with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw)
tag = str(data.get("tag_name") or "").strip() if isinstance(data, dict) else ""
version = _normalize_lark_cli_version_tag(tag)
if version is not None:
return version
except Exception: # noqa: BLE001 - version discovery is best-effort
pass
return FALLBACK_LARK_CLI_VERSION
def _cached_latest_lark_cli_version() -> str | None:
"""Best-effort latest version for status display, cached with a short TTL.
Returns ``None`` on failure so the status endpoint never blocks the UI on a
GitHub outage; the install path uses :func:`_resolve_latest_lark_cli_version`
which has its own fallback.
"""
now = time.monotonic()
cached = getattr(_cached_latest_lark_cli_version, "_cache", None)
if cached is not None and now - cached[0] < LARK_CLI_LATEST_VERSION_TTL_SECONDS:
return cached[1]
try:
request = urllib.request.Request(
LARK_CLI_LATEST_RELEASE_API,
headers={"Accept": "application/vnd.github+json", "User-Agent": "deer-flow"},
)
with urllib.request.urlopen(request, timeout=LARK_HTTP_TIMEOUT_SECONDS) as response:
data = json.loads(response.read().decode("utf-8"))
tag = str(data.get("tag_name") or "").strip() if isinstance(data, dict) else ""
version = _normalize_lark_cli_version_tag(tag)
except Exception: # noqa: BLE001 - status probe is best-effort
version = None
_cached_latest_lark_cli_version._cache = (now, version) # type: ignore[attr-defined]
return version
def _lark_archive_url(version: str) -> str:
tag = _normalize_lark_cli_version_tag(version)
if tag is None:
raise ValueError(f"Invalid Lark CLI version tag: {version!r}")
return f"https://codeload.github.com/{LARK_CLI_GITHUB_REPO}/zip/refs/tags/{tag}"
def _normalize_lark_cli_version_tag(value: str | None) -> str | None:
tag = (value or "").strip()
if not _VERSION_TAG_RE.fullmatch(tag):
return None
return tag if tag.startswith("v") else f"v{tag}"
def _download_lark_archive(version: str) -> Path:
fd, archive_name = tempfile.mkstemp(prefix="lark-cli-skills-", suffix=".zip")
os.close(fd)
archive_path = Path(archive_name)
url = _lark_archive_url(version)
try:
with urllib.request.urlopen(url, timeout=LARK_CLI_DOWNLOAD_TIMEOUT_SECONDS) as response:
total = 0
with archive_path.open("wb") as out:
while chunk := response.read(1024 * 1024):
total += len(chunk)
if total > LARK_CLI_MAX_ARCHIVE_BYTES:
raise ValueError("Lark CLI source archive is too large.")
out.write(chunk)
except ValueError:
archive_path.unlink(missing_ok=True)
raise
except Exception as exc: # noqa: BLE001 - network boundary
archive_path.unlink(missing_ok=True)
raise ValueError(f"Could not download the Lark skill pack ({version}) from GitHub. Check the Gateway's internet access, or pre-stage the archive via {LARK_CLI_SOURCE_ARCHIVE_ENV}.") from exc
return archive_path
def _content_sha256(root: Path, skill_names: set[str]) -> str:
"""SHA-256 over effective installed skill contents (not archive bytes).
The caller computes this after injecting DeerFlow's shared guidance, so the
digest covers both official extracted files and the guidance users/agents
actually read. It remains stable across GitHub re-packs of identical
content. Paths and bytes are hashed in sorted order for determinism.
"""
digest = hashlib.sha256()
for skill_name in sorted(skill_names):
skill_dir = root / skill_name
if not skill_dir.is_dir():
continue
for file_path in sorted(p for p in skill_dir.rglob("*") if p.is_file()):
rel = file_path.relative_to(root).as_posix()
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
digest.update(file_path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def _infer_lark_archive_version(zf: zipfile.ZipFile) -> str | None:
"""Infer version from GitHub source archive roots such as ``cli-1.0.65/``.
This keeps air-gapped / pre-staged archives from being mislabeled as the
fallback version when the archive itself clearly identifies its release.
"""
for info in zf.infolist():
normalized = posixpath.normpath(info.filename.replace("\\", "/"))
parts = PurePosixPath(normalized).parts
if not parts:
continue
match = re.fullmatch(r"cli-(\d+\.\d+\.\d+)", parts[0])
if match:
return f"v{match.group(1)}"
return None
def _install_lark_skills_from_archive(user_id: str, archive_path: Path, *, version: str | None = None) -> tuple[tuple[str, ...], str]:
if not archive_path.is_file():
raise FileNotFoundError(f"Lark CLI skills archive not found: {archive_path}")
parent = get_paths().integration_skills_dir()
parent.mkdir(parents=True, exist_ok=True)
with _lark_install_lock(parent):
return _install_lark_skills_from_archive_locked(archive_path, parent, version=version)
@contextmanager
def _lark_install_lock(parent: Path):
"""Serialize the cross-process atomic replacement of the global pack."""
with _exclusive_install_lock(parent / ".lark-cli.install.lock", _LARK_INSTALL_THREAD_LOCK):
yield
def _install_lark_skills_from_archive_locked(
archive_path: Path,
parent: Path,
*,
version: str | None = None,
) -> tuple[tuple[str, ...], str]:
target = parent / INTEGRATION_ID
staging_parent = Path(tempfile.mkdtemp(prefix=".installing-lark-cli-", dir=str(parent)))
staging_target = staging_parent / INTEGRATION_ID
staging_target.mkdir(parents=True, exist_ok=True)
backup: Path | None = None
try:
with zipfile.ZipFile(archive_path, "r") as zf:
archive_version = version or _infer_lark_archive_version(zf)
extracted = _extract_lark_skills(zf, staging_target)
_validate_extracted_lark_skills(staging_target, extracted)
_append_deerflow_lark_shared_guidance(staging_target)
content_sha = _content_sha256(staging_target, extracted)
_write_manifest(staging_target, extracted, version=archive_version, content_sha256=content_sha)
make_skill_tree_sandbox_readable(staging_target)
if target.exists():
backup = parent / f".replacing-{INTEGRATION_ID}-{os.getpid()}"
if backup.exists():
shutil.rmtree(backup)
target.rename(backup)
staging_target.rename(target)
if backup is not None:
# Best-effort: the new skills are already live after the rename, so a
# transient error deleting the old backup must not flip a successful
# install into a failure (the except-branch restore guard would also
# not fire because ``target`` now exists with the new content).
shutil.rmtree(backup, ignore_errors=True)
return tuple(sorted(extracted)), content_sha
except Exception:
if backup is not None and backup.exists() and not target.exists():
backup.rename(target)
raise
finally:
shutil.rmtree(staging_parent, ignore_errors=True)
def _extract_lark_skills(zf: zipfile.ZipFile, destination: Path) -> set[str]:
extracted: set[str] = set()
total_written = 0
dest_root = destination.resolve()
for info in zf.infolist():
if info.is_dir():
continue
if is_unsafe_zip_member(info) or is_symlink_member(info):
raise ValueError(f"Unsafe Lark CLI archive member: {info.filename!r}")
skill_name, relative = _resolve_lark_skill_member(info.filename)
if skill_name is None or relative is None:
continue
target = dest_root / skill_name / relative
if not target.resolve().is_relative_to(dest_root):
raise ValueError(f"Archive member escapes destination: {info.filename!r}")
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, target.open("wb") as out:
first_chunk = True
while chunk := src.read(65536):
if first_chunk and is_executable_binary_prefix(chunk):
raise ValueError(f"Archive contains executable binary member: {info.filename!r}")
first_chunk = False
total_written += len(chunk)
if total_written > LARK_CLI_MAX_EXTRACTED_BYTES:
raise ValueError("Lark CLI skills archive expands to too much data.")
out.write(chunk)
extracted.add(skill_name)
return extracted
def _resolve_lark_skill_member(raw_name: str) -> tuple[str | None, Path | None]:
normalized = posixpath.normpath(raw_name.replace("\\", "/"))
if normalized in {"", "."} or normalized.startswith("../"):
return None, None
parts = PurePosixPath(normalized).parts
if "skills" in parts:
idx = parts.index("skills")
if len(parts) <= idx + 2:
return None, None
skill_name = parts[idx + 1]
rel_parts = parts[idx + 2 :]
elif parts and parts[0] in LARK_SKILL_NAME_SET:
skill_name = parts[0]
rel_parts = parts[1:]
else:
return None, None
if skill_name not in LARK_SKILL_NAME_SET or not rel_parts:
return None, None
if any(part in {"", ".", ".."} for part in rel_parts):
raise ValueError(f"Unsafe Lark skill archive member: {raw_name!r}")
return skill_name, Path(*rel_parts)
def _validate_extracted_lark_skills(root: Path, extracted: set[str]) -> None:
missing = sorted(set(LARK_SKILL_NAMES) - extracted)
if missing:
raise ValueError(f"Lark CLI archive is missing required skills: {', '.join(missing)}")
for skill_name in LARK_SKILL_NAMES:
skill_file = root / skill_name / SKILL_MD_FILE
parsed = parse_skill_file(skill_file, SkillCategory.INTEGRATION, relative_path=Path(INTEGRATION_ID) / skill_name)
if parsed is None:
raise ValueError(f"Invalid Lark skill metadata: {skill_name}/{SKILL_MD_FILE}")
if parsed.name != skill_name:
raise ValueError(f"Lark skill directory {skill_name!r} declares name {parsed.name!r}")
def _append_deerflow_lark_shared_guidance(root: Path) -> None:
skill_file = root / "lark-shared" / SKILL_MD_FILE
content = skill_file.read_text(encoding="utf-8")
if _DEERFLOW_LARK_SHARED_GUIDANCE_MARKER in content:
return
for legacy_marker in _DEERFLOW_LARK_SHARED_GUIDANCE_LEGACY_MARKERS:
if legacy_marker in content:
content = content.split(legacy_marker, maxsplit=1)[0].rstrip()
break
guidance = f"""
{_DEERFLOW_LARK_SHARED_GUIDANCE_MARKER}
## DeerFlow 授权入口
在 DeerFlow 中,如果 `lark-cli auth status` 或业务命令提示未配置、未登录、token 过期或缺少用户授权:
1. 不要要求用户在终端执行 `lark-cli config init`、`lark-cli auth login` 或 `lark-cli auth login --device-code`。
2. 回复用户这个可点击链接:[打开飞书授权设置](?settings=integrations)。
3. 告诉用户在 **Settings → Integrations → Lark / Feishu CLI** 点击“连接飞书”,在浏览器里完成授权后再回来继续当前任务。
4. 如果错误中包含缺失的 `scope`、`permission_violations` 或建议的 `--domain`,告诉用户在该设置页选择对应权限域(例如日历选择 Calendar或把具体 scope 填入“Exact OAuth scope / 具体 OAuth scope”后重新授权。
只有在用户明确说明已经完成授权后,才继续调用具体的 `lark-cli` 业务命令。
"""
skill_file.write_text(content.rstrip() + guidance + "\n", encoding="utf-8")
def _write_manifest(root: Path, installed_skills: set[str], *, version: str | None, content_sha256: str) -> None:
resolved_version = version or FALLBACK_LARK_CLI_VERSION
manifest = {
"provider": INTEGRATION_ID,
"version": resolved_version,
"source": _lark_archive_url(resolved_version),
"content_sha256": content_sha256,
"installed_at": datetime.now(UTC).isoformat(),
"skills": sorted(installed_skills),
}
(root / LARK_CLI_MANIFEST_FILE).write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")