fix(desktop): fetch trimmed lark_oapi bundle instead of pip install

This commit is contained in:
zhayujie committed 2026-07-28 11:16:37 +08:00
1 parent d0e292ae77
commit 2e7a242196
10 files changed
+655 -158

No files matched your search

@@ -0,0 +1,68 @@
name: Build Feishu SDK Bundle
# The desktop build ships without lark_oapi; clients download a trimmed bundle
# the first time a user enables Feishu. This workflow builds that bundle
# reproducibly and smoke-tests it.
#
# Run it only when VENDOR_VERSION in channel/feishu/lark_install.py changes:
# the artifact is published once per SDK version and reused from then on.
# The bundle is pure Python, so one artifact serves every platform.
#
# Publishing is a manual step -- download the artifact from this run and put it
# on the CDN, then pin the printed sha256 in lark_install.py.
on:
workflow_dispatch:
pull_request:
paths:
- "desktop/build/build-feishu-vendor.py"
- "channel/feishu/lark_install.py"
- ".github/workflows/publish-feishu-vendor.yml"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
# Matches the Python the desktop backend is frozen with.
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build and smoke-test the bundle
run: |
python -m pip install --upgrade pip
# The bundle expects these from the desktop build rather than
# carrying its own copies; the smoke test needs them too.
python -m pip install requests pycryptodome
python desktop/build/build-feishu-vendor.py --out dist/feishu-vendor.zip
- name: Check the pinned checksum matches
run: |
expected=$(python -c "import re,pathlib; \
print(re.search(r'VENDOR_SHA256 = \"([0-9a-f]*)\"', \
pathlib.Path('channel/feishu/lark_install.py').read_text()).group(1))")
actual=$(sha256sum dist/feishu-vendor.zip | cut -d' ' -f1)
echo "pinned in lark_install.py: ${expected:-<unset>}"
echo "built artifact : $actual"
if [ -n "$expected" ] && [ "$expected" != "$actual" ]; then
echo "::error::VENDOR_SHA256 is stale. Set it to: $actual"
exit 1
fi
- name: Rename artifact to its published name
run: |
version=$(python -c "import re,pathlib; \
print(re.search(r'VENDOR_VERSION = \"([^\"]+)\"', \
pathlib.Path('channel/feishu/lark_install.py').read_text()).group(1))")
mv dist/feishu-vendor.zip "dist/feishu-vendor-$version.zip"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: feishu-vendor
path: dist/feishu-vendor-*.zip
+1
View File
@@ -54,6 +54,7 @@ desktop/build/*
!desktop/build/cowagent-backend.spec !desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt !desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh !desktop/build/build-backend.sh
!desktop/build/build-feishu-vendor.py
!desktop/build/entitlements.mac.plist !desktop/build/entitlements.mac.plist
!desktop/build/notarize-dmg.sh !desktop/build/notarize-dmg.sh
+1
View File
@@ -0,0 +1 @@
9f77a3afd2e0488aa6e67cc7aa9c0772d12e1aac56814615ac6d4499c9f1c051
+4 -4
View File
@@ -51,8 +51,8 @@ URL_VERIFICATION = "url_verification"
# Lazy import of the lark_oapi SDK. The full `import lark_oapi` pulls in 10k+ # Lazy import of the lark_oapi SDK. The full `import lark_oapi` pulls in 10k+
# files and takes 4-10s, so we defer the actual import to where it is needed. # files and takes 4-10s, so we defer the actual import to where it is needed.
# In desktop mode the SDK is not bundled; the first import triggers an on-demand # In desktop mode the SDK is not bundled; the first import downloads a trimmed
# pip install into a writable per-user dir (see channel/feishu/lark_install.py). # bundle into a writable per-user dir (see channel/feishu/lark_install.py).
from channel.feishu import lark_install from channel.feishu import lark_install
lark = None # will be populated on first use via _ensure_lark_imported() lark = None # will be populated on first use via _ensure_lark_imported()
@@ -61,8 +61,8 @@ lark = None # will be populated on first use via _ensure_lark_imported()
def _ensure_lark_imported(): def _ensure_lark_imported():
"""Import lark_oapi on first use (takes 4-10s due to 10k+ source files). """Import lark_oapi on first use (takes 4-10s due to 10k+ source files).
In desktop mode, if the SDK is missing this triggers an on-demand install In desktop mode, if the SDK is missing this fetches it on demand (requires
(requires network the first time) instead of failing outright. network the first time) instead of failing outright.
""" """
global lark global lark
if lark is None: if lark is None:
+161 -71
View File
@@ -1,24 +1,37 @@
""" """
On-demand installer for the ``lark_oapi`` SDK (Feishu channel). On-demand provisioning of the ``lark_oapi`` SDK (Feishu channel).
Why this exists Why this exists
--------------- ---------------
The desktop client (PyInstaller build) intentionally does **not** bundle The desktop client (PyInstaller build) intentionally does **not** bundle
``lark_oapi`` (~116MB + dependencies) so the base download stays small ``lark_oapi``: the published SDK is ~122MB unpacked because it ships models for
(see zhayujie/CowAgent#2987 — "客户端没有飞书通道"). Instead, the first time a all 59 Feishu open-platform domains, which is why the channel was dropped from
user enables the Feishu channel in desktop mode, we pip-install ``lark_oapi`` the desktop build (see zhayujie/CowAgent#2987 — "客户端没有飞书通道").
(and its dependencies) into a writable per-user directory and add it to
``sys.path``. The module is then imported lazily as before.
For source / non-desktop installs ``lark_oapi`` is expected to be present Instead the first time a user enables Feishu in desktop mode we fetch a trimmed,
already (it lives in ``requirements.txt``); in that case this module is a pure-Python bundle (~1MB) built by ``desktop/build/build-feishu-vendor.py``,
no-op that simply imports it. unpack it into a writable per-user directory and put that on ``sys.path``. The
bundle is published once per SDK version and mirrored (see ``VENDOR_URLS``).
Running ``pip`` at this point is not an option: the frozen app has no real
Python interpreter — ``sys.executable`` is the bundled backend itself — so
``sys.executable -m pip`` does not behave like pip. Unpacking a prebuilt archive
needs nothing but the import machinery, which works normally in a frozen build.
For source / non-desktop installs ``lark_oapi`` comes from ``requirements.txt``
and this module simply confirms it is importable.
""" """
import hashlib
import importlib
import importlib.util import importlib.util
import io
import logging import logging
import os import os
import subprocess import shutil
import sys import sys
import tempfile
import urllib.request
import zipfile
try: try:
from common.log import logger from common.log import logger
@@ -28,90 +41,167 @@ except Exception: # pragma: no cover - common may be unavailable in isolation
LARK_PKG = "lark-oapi" LARK_PKG = "lark-oapi"
LARK_MIN = "1.5.5" LARK_MIN = "1.5.5"
# Bumped whenever a new bundle is published: "<lark version>-<bundle revision>".
VENDOR_VERSION = "1.7.1-1"
# sha256 of the published archive. The builder emits a byte-identical zip for a
# given version, so this pin is stable; it is what makes the download safe to
# trust. Leave empty only for local experiments (verification is then skipped
# with a warning).
VENDOR_SHA256 = "a96de70291e43b4829a5f717035806835f116bf4dc1d0a2d2ed551908a825381"
# Tried in order; the same mirrors the desktop installers use. Feishu is a
# China-only product, so the domestic CDN comes first. Any mirror is safe
# because the payload is checked against VENDOR_SHA256.
VENDOR_URLS = (
"https://cdn.link-ai.tech/desktop/vendor/feishu-vendor-{version}.zip",
"https://cdn.cowagent.ai/desktop/vendor/feishu-vendor-{version}.zip",
)
DOWNLOAD_TIMEOUT = 120
# Per-user, writable, persistent location. Mirrors the browser tool's ~/.cow # Per-user, writable, persistent location. Mirrors the browser tool's ~/.cow
# layout so everything CowAgent owns lives under one roof. # layout so everything CowAgent owns lives under one roof.
_VENDOR_SUBDIR = os.path.join(".cow", "feishu_vendor") _VENDOR_SUBDIR = os.path.join(".cow", "feishu_vendor")
def _install_error(reason: str = "") -> ImportError:
detail = f" ({reason})" if reason else ""
return ImportError(
f"lark_oapi is required for the Feishu channel but is not available{detail}. "
f"Install it with: pip install -U '{LARK_PKG}>={LARK_MIN}'"
)
def vendor_dir() -> str: def vendor_dir() -> str:
"""Return (and create) the directory where lark_oapi is installed on demand.""" """Return the directory the trimmed SDK is unpacked into.
Versioned so a newer bundle never has to overwrite a directory that a
running process may still be importing from.
"""
base = os.environ.get("COW_DATA_DIR") or os.path.expanduser("~") base = os.environ.get("COW_DATA_DIR") or os.path.expanduser("~")
path = os.path.join(base, _VENDOR_SUBDIR) return os.path.join(base, _VENDOR_SUBDIR, VENDOR_VERSION)
os.makedirs(path, exist_ok=True)
return path
def vendor_urls() -> list:
"""Candidate download URLs, most preferred first."""
override = os.environ.get("COW_FEISHU_VENDOR_URL")
if override:
return [override]
return [url.format(version=VENDOR_VERSION) for url in VENDOR_URLS]
def is_available() -> bool: def is_available() -> bool:
"""True if lark_oapi can already be imported.""" """True if lark_oapi can be imported right now."""
return importlib.util.find_spec("lark_oapi") is not None return importlib.util.find_spec("lark_oapi") is not None
def ensure(allow_install: bool = True) -> None: def _activate(path: str) -> bool:
"""Ensure ``lark_oapi`` can be imported. """Put an unpacked bundle on sys.path and report whether it took effect."""
if not os.path.isdir(path):
return False
if path not in sys.path:
sys.path.insert(0, path)
# find_spec consults cached finders, which were populated before the
# directory existed.
importlib.invalidate_caches()
return is_available()
- If already importable, return immediately (source installs, or a previous
on-demand install that persisted). def _fetch(url: str) -> bytes:
- In desktop mode (``COW_DESKTOP=1``) and when ``allow_install`` is True, logger.info("[FeiShu] downloading Feishu SDK bundle from %s", url)
pip-install ``lark_oapi`` (and its dependencies) on demand into with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as resp:
:func:`vendor_dir` and add that directory to ``sys.path``. return resp.read()
- Otherwise (non-desktop without the package, or install disabled) raise
``ImportError`` with actionable guidance.
def _verify(payload: bytes) -> None:
if not VENDOR_SHA256:
logger.warning("[FeiShu] VENDOR_SHA256 is unset; skipping integrity check")
return
digest = hashlib.sha256(payload).hexdigest()
if digest != VENDOR_SHA256:
raise ValueError(f"checksum mismatch: expected {VENDOR_SHA256}, got {digest}")
def _safe_extract(payload: bytes, dest: str) -> None:
"""Unpack the archive, refusing entries that escape ``dest``."""
with zipfile.ZipFile(io.BytesIO(payload)) as zf:
for name in zf.namelist():
target = os.path.realpath(os.path.join(dest, name))
if not target.startswith(os.path.realpath(dest) + os.sep):
raise ValueError(f"refusing to extract outside the vendor dir: {name}")
zf.extractall(dest)
def _download() -> bytes:
"""Fetch the bundle, falling back through the mirrors on failure."""
last = None
for url in vendor_urls():
try:
payload = _fetch(url)
_verify(payload)
return payload
except Exception as exc:
last = exc
logger.warning("[FeiShu] could not fetch the bundle from %s: %s", url, exc)
raise last
def _provision(target: str) -> None:
"""Download and unpack the bundle into ``target`` atomically."""
parent = os.path.dirname(target)
os.makedirs(parent, exist_ok=True)
payload = _download()
staging = tempfile.mkdtemp(prefix=".incomplete-", dir=parent)
try:
_safe_extract(payload, staging)
try:
os.rename(staging, target)
except OSError:
# Another process won the race, or the platform refuses to rename
# onto an existing directory; its copy is equivalent.
if not os.path.isdir(target):
raise
shutil.rmtree(staging, ignore_errors=True)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
def ensure(allow_install: bool = True) -> None:
"""Make ``lark_oapi`` importable, fetching the bundle if needed.
- Already importable (source install, or a previous download): return.
- Desktop mode with ``allow_install``: download the trimmed bundle and add
it to ``sys.path``.
- Otherwise: raise ``ImportError`` with actionable guidance.
""" """
# Fast path: already importable.
if is_available(): if is_available():
return return
target = vendor_dir()
# A previous run may have already unpacked it.
if _activate(target):
return
if not allow_install: if not allow_install:
raise ImportError( raise _install_error()
"lark_oapi is required for the Feishu channel but is not installed. "
"Install it with: pip install -U 'lark-oapi>=%s'" % LARK_MIN
)
# Source / non-desktop installs should already have it via requirements.txt. # Source / non-desktop installs get it from requirements.txt.
if os.environ.get("COW_DESKTOP") != "1": if os.environ.get("COW_DESKTOP") != "1":
raise ImportError( raise _install_error()
"lark_oapi is required for the Feishu channel but is not installed. "
"Install it with: pip install -U 'lark-oapi>=%s'" % LARK_MIN
)
# Desktop mode: install on demand into the user-writable vendor dir.
vdir = vendor_dir()
if vdir not in sys.path:
sys.path.insert(0, vdir)
# Re-check: a previous run may have already installed it here.
if is_available():
return
logger.warning( logger.warning(
"[FeiShu] lark_oapi not found; installing on demand into %s " "[FeiShu] Feishu SDK not present; fetching the bundle into %s. "
"(requires network, ~%s+). This only happens once.", "This happens once and needs a network connection.", target,
vdir, LARK_MIN,
) )
try: try:
subprocess.run( _provision(target)
[ except Exception as exc:
sys.executable, "-m", "pip", "install", logger.error("[FeiShu] failed to provision the Feishu SDK bundle: %s", exc)
"--target", vdir, raise _install_error(str(exc)) from exc
"--upgrade",
"lark-oapi>=%s" % LARK_MIN,
],
check=True,
capture_output=True,
text=True,
timeout=600,
)
except Exception as exc: # noqa: BLE001 - surface a clear, actionable error
logger.error("[FeiShu] on-demand lark_oapi install failed: %s", exc)
raise ImportError(
"Failed to install lark_oapi on demand. A network connection is "
"required the first time you enable Feishu. Please connect to the "
"internet and retry, or install it manually with: "
"pip install -U 'lark-oapi>=%s'" % LARK_MIN
) from exc
if not is_available(): if not _activate(target):
raise ImportError( raise _install_error("bundle unpacked but lark_oapi is still not importable")
"lark_oapi could not be imported after on-demand installation. " logger.info("[FeiShu] Feishu SDK bundle ready at %s", target)
"Try installing manually: pip install -U 'lark-oapi>=%s'" % LARK_MIN
)
logger.info("[FeiShu] lark_oapi installed on demand into %s", vdir)
+3 -3
View File
@@ -4319,8 +4319,8 @@ class FeishuRegisterHandler:
def _worker(): def _worker():
try: try:
# Desktop builds don't bundle lark_oapi; install it on demand # Desktop builds don't bundle lark_oapi; fetch it on demand the
# the first time the user enables Feishu (requires network). # first time the user enables Feishu (requires network).
from channel.feishu import lark_install from channel.feishu import lark_install
lark_install.ensure(allow_install=True) lark_install.ensure(allow_install=True)
import lark_oapi as lark import lark_oapi as lark
@@ -4328,7 +4328,7 @@ class FeishuRegisterHandler:
with cls._lock: with cls._lock:
cls._state["status"] = "error" cls._state["status"] = "error"
cls._state["error"] = ( cls._state["error"] = (
"lark-oapi SDK 未安装或安装失败,请联网后重试," "飞书 SDK 不可用,请联网后重试,"
"或手动执行 pip install -U 'lark-oapi>=1.5.5'(%s)" % e "或手动执行 pip install -U 'lark-oapi>=1.5.5'(%s)" % e
) )
return return
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Build the trimmed ``lark_oapi`` bundle downloaded on demand by the desktop app.
The published SDK ships every Feishu open-platform domain (59 of them, ~122MB
unpacked / ~32MB zipped). The Feishu channel only needs the websocket long
connection, the event dispatcher and the ``im`` event models -- messages are
sent through the REST API with plain ``requests`` -- so almost all of that is
dead weight.
This script installs the SDK with a pinned dependency set, strips it down to
what the channel actually imports, and emits a pure-Python zip (~1MB) that works
on every platform and Python version.
Usage:
python desktop/build/build-feishu-vendor.py --out dist/feishu-vendor.zip
Print the resulting sha256 into ``channel/feishu/lark_install.py`` when
publishing a new bundle.
"""
import argparse
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
# Pinned because the trimming below patches SDK internals; bumping the SDK means
# re-running this script and re-checking the smoke test.
LARK_VERSION = "1.7.1"
# The only API domain the channel imports (lark.im.v1.P2ImMessage*).
KEEP_API = {"im"}
# Installed with --no-deps and pinned exactly, because the sha256 recorded in
# lark_install.py has to survive rebuilds. Letting pip resolve the tree instead
# ties the artifact to the building interpreter (anyio only wants
# typing_extensions below Python 3.13) and to whatever was released that day.
#
# requests, urllib3 and pycryptodome are deliberately missing: the desktop build
# imports them directly, and pycryptodome is a native extension this bundle
# cannot carry anyway. The rest travel along so the bundle also works outside a
# frozen app -- inside one, PyInstaller's importer takes precedence over
# sys.path, so the frozen copies keep winning and versions cannot drift apart.
REQUIREMENTS = [
f"lark-oapi=={LARK_VERSION}",
"anyio==4.14.2",
"h11==0.16.0",
"httpcore==1.0.9",
"httpx==0.28.1",
"requests-toolbelt==1.0.0",
"typing_extensions==4.16.0",
"websockets==15.0.1",
]
def log(msg):
print(f"[feishu-vendor] {msg}", flush=True)
def pip_install(target):
subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", target,
"--no-compile", "--no-deps", *REQUIREMENTS],
check=True,
)
def trim_api_domains(pkg):
"""Delete every API domain except KEEP_API and rewrite the eager __init__."""
api_dir = os.path.join(pkg, "api")
removed = 0
for name in sorted(os.listdir(api_dir)):
path = os.path.join(api_dir, name)
if os.path.isdir(path) and name not in KEEP_API and name != "__pycache__":
shutil.rmtree(path)
removed += 1
with open(os.path.join(api_dir, "__init__.py"), "w", encoding="utf-8") as f:
for name in sorted(KEEP_API):
f.write(f"from . import {name}\n")
log(f"dropped {removed} api domains, kept {sorted(KEEP_API)}")
def stub_client(pkg):
"""Replace lark.Client, which wires up 57 services the channel never calls."""
with open(os.path.join(pkg, "client.py"), "w", encoding="utf-8") as f:
f.write(
'"""Stub: lark.Client is not part of the trimmed bundle."""\n\n\n'
"_MSG = (\n"
' "lark_oapi.Client is not available in the trimmed Feishu bundle; "\n'
' "call the Feishu REST API directly."\n'
")\n\n\n"
"class Client:\n"
" def __init__(self, *args, **kwargs):\n"
" raise NotImplementedError(_MSG)\n\n"
" @staticmethod\n"
" def builder(*args, **kwargs):\n"
" raise NotImplementedError(_MSG)\n"
)
def trim_dispatcher(pkg):
"""Keep only the processors of the surviving domains.
The builder declares one ``register_*`` method per event type, annotated
with model classes from every domain. Annotations are evaluated when the
class body runs, so the dropped names would raise NameError; PEP 563 defers
them to strings. The method bodies still reference dropped processors, but
they only run when called, which never happens for dropped domains.
"""
path = os.path.join(pkg, "event", "dispatcher_handler.py")
with open(path, encoding="utf-8") as f:
lines = f.read().splitlines()
def keep(line):
m = re.match(r"from lark_oapi\.api\.(\w+)\.", line)
return m is None or m.group(1) in KEEP_API
kept = [l for l in lines if keep(l)]
with open(path, "w", encoding="utf-8") as f:
f.write("from __future__ import annotations\n" + "\n".join(kept) + "\n")
def strip_binaries_and_caches(root):
"""Remove __pycache__ and native extensions.
The only compiled module in the pinned set is the optional ``websockets``
speedup, which falls back to pure Python. Dropping it makes the bundle
independent of platform and Python version, so one artifact serves everyone.
"""
removed = 0
for dirpath, dirnames, filenames in os.walk(root):
for d in list(dirnames):
if d == "__pycache__":
shutil.rmtree(os.path.join(dirpath, d))
dirnames.remove(d)
for fn in filenames:
if fn.endswith((".so", ".pyd", ".dylib")):
os.remove(os.path.join(dirpath, fn))
removed += 1
log(f"stripped {removed} native extension(s) -> pure Python")
# Exercises every lark_oapi symbol channel/feishu/feishu_channel.py touches,
# so trimming mistakes surface here instead of at runtime.
SMOKE_TEST = r'''
import json
import lark_oapi as lark
assert VENDOR in lark.__file__, "loaded the wrong lark_oapi: " + lark.__file__
lark.im.v1.P2ImMessageReceiveV1
lark.im.v1.P2ImMessageRecalledV1
lark.JSON.marshal({"a": 1})
lark.LogLevel.WARNING
assert callable(lark.register_app)
from lark_oapi.event.callback.model.p2_card_action_trigger import (
P2CardActionTriggerResponse,
)
P2CardActionTriggerResponse({"toast": {"type": "info", "content": "ok"}})
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(lambda d: None)
.register_p2_im_message_recalled_v1(lambda d: None)
.register_p2_card_action_trigger(lambda d: None)
.build()
)
lark.ws.Client("id", "secret", event_handler=handler, log_level=lark.LogLevel.WARNING)
import lark_oapi.ws.client as ws_mod
assert hasattr(ws_mod, "loop")
seen = {}
(
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(
lambda d: seen.update(json.loads(lark.JSON.marshal(d)))
)
.build()
)._do_without_validation(json.dumps({
"schema": "2.0",
"header": {"event_id": "e", "event_type": "im.message.receive_v1", "token": "",
"create_time": "0", "tenant_key": "t", "app_id": "a"},
"event": {"sender": {"sender_id": {"open_id": "ou_x"}, "sender_type": "user"},
"message": {"message_id": "om_x", "chat_id": "oc_x", "chat_type": "p2p",
"message_type": "text", "content": "{}", "create_time": "0"}},
}).encode())
assert seen["event"]["message"]["message_id"] == "om_x"
print("OK")
'''
def verify(zip_path):
"""Import the bundle in a subprocess and exercise the channel's API surface."""
workdir = tempfile.mkdtemp(prefix="feishu-vendor-check-")
try:
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(workdir)
env = dict(os.environ, PYTHONPATH=workdir)
proc = subprocess.run(
[sys.executable, "-c", f"VENDOR = {workdir!r}\n" + SMOKE_TEST],
env=env, capture_output=True, text=True,
)
if proc.returncode != 0:
log("smoke test FAILED:\n" + proc.stdout + proc.stderr)
raise SystemExit(1)
log("smoke test passed")
finally:
shutil.rmtree(workdir, ignore_errors=True)
def build(out_path):
staging = tempfile.mkdtemp(prefix="feishu-vendor-")
try:
log(f"installing lark-oapi=={LARK_VERSION}")
pip_install(staging)
pkg = os.path.join(staging, "lark_oapi")
trim_api_domains(pkg)
stub_client(pkg)
trim_dispatcher(pkg)
# Optional high-level layer, never imported by the channel.
shutil.rmtree(os.path.join(pkg, "channel"), ignore_errors=True)
# Metadata mostly indexes files that no longer exist after trimming.
for entry in os.listdir(staging):
if entry.endswith((".dist-info", ".egg-info")):
shutil.rmtree(os.path.join(staging, entry), ignore_errors=True)
# Console scripts installed alongside the packages; nothing imports them.
shutil.rmtree(os.path.join(staging, "bin"), ignore_errors=True)
strip_binaries_and_caches(staging)
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
# Sorted entries and a fixed timestamp keep the archive byte-identical
# across rebuilds, so the sha256 pinned in lark_install.py stays valid.
members = []
for dirpath, _, filenames in os.walk(staging):
for fn in filenames:
full = os.path.join(dirpath, fn)
members.append((os.path.relpath(full, staging), full))
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
for arcname, full in sorted(members):
info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
with open(full, "rb") as fh:
zf.writestr(info, fh.read())
finally:
shutil.rmtree(staging, ignore_errors=True)
with open(out_path, "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()
size_mb = os.path.getsize(out_path) / 1024 / 1024
log(f"wrote {out_path} ({size_mb:.1f}MB)")
log(f"sha256 = {digest}")
log("update VENDOR_SHA256 in channel/feishu/lark_install.py with the value above")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", default="dist/feishu-vendor.zip")
ap.add_argument("--skip-verify", action="store_true",
help="skip the post-build smoke test")
args = ap.parse_args()
build(args.out)
if not args.skip_verify:
verify(args.out)
if __name__ == "__main__":
main()
+12 -19
View File
@@ -29,10 +29,9 @@ def rp(*parts):
# PyInstaller's static analysis can't see them. List every channel we ship. # PyInstaller's static analysis can't see them. List every channel we ship.
# Feishu (channel.feishu.feishu_channel) is bundled so the channel code is # Feishu (channel.feishu.feishu_channel) is bundled so the channel code is
# present; its heavy lark_oapi SDK is deliberately NOT bundled — see the # present; its heavy lark_oapi SDK is deliberately NOT bundled — see the
# `excludes` note below and channel/feishu/lark_install.py, which installs it # `excludes` note below and channel/feishu/lark_install.py, which downloads a
# on demand the first time the user enables Feishu (fixes # trimmed bundle the first time the user enables Feishu (fixes
# zhayujie/CowAgent#2987: "客户端没有飞书通道"). pip/setuptools/wheel are # zhayujie/CowAgent#2987: "客户端没有飞书通道").
# bundled (small) so that on-demand install can run inside the frozen app.
hiddenimports = [ hiddenimports = [
# channels (dynamic import in channel/channel_factory.py) # channels (dynamic import in channel/channel_factory.py)
'channel.web.web_channel', 'channel.web.web_channel',
@@ -48,12 +47,6 @@ hiddenimports = [
'channel.telegram.telegram_channel', 'channel.telegram.telegram_channel',
'channel.slack.slack_channel', 'channel.slack.slack_channel',
'channel.discord.discord_channel', 'channel.discord.discord_channel',
# pip is used by the Feishu on-demand installer (channel/feishu/lark_install.py)
# to fetch lark_oapi + its deps inside the frozen desktop app.
'pip',
'pip._internal',
'setuptools',
'wheel',
] ]
# Agent tools and model providers are imported lazily in places; collect their # Agent tools and model providers are imported lazily in places; collect their
@@ -162,17 +155,17 @@ datas += collect_data_files('playwright', include_py_files=True)
# --- Excludes ------------------------------------------------------------- # --- Excludes -------------------------------------------------------------
# Keep the bundle lean: drop tests/docs and dev-only packages. # Keep the bundle lean: drop tests/docs and dev-only packages.
# #
# Feishu's lark_oapi (~116MB + deps) is intentionally EXCLUDED here. The desktop # Feishu's lark_oapi (~122MB unpacked) is intentionally EXCLUDED here. The first
# client does not bundle it; instead, the first time a user enables the Feishu # time a user enables the Feishu channel, channel/feishu/lark_install.py
# channel, channel/feishu/lark_install.py pip-installs lark_oapi (and its deps) # downloads a trimmed, pure-Python bundle (~1MB, built by
# into a writable per-user dir (~/.cow/feishu_vendor) and adds it to sys.path. # desktop/build/build-feishu-vendor.py) into ~/.cow/feishu_vendor and adds it to
# This keeps the base download ~116MB smaller while Feishu still works out of # sys.path. This keeps the base download small while Feishu still works out of
# the box after a one-time, on-demand download (fixes zhayujie/CowAgent#2987). # the box after a one-time fetch (fixes zhayujie/CowAgent#2987).
# pip/setuptools/wheel are NOT excluded (see hiddenimports) so that on-demand
# install can run inside the frozen app.
excludes = [ excludes = [
'lark_oapi', # Feishu SDK — installed on demand, NOT bundled (see above) 'lark_oapi', # Feishu SDK — fetched on demand, NOT bundled (see above)
'tests', 'tests',
'pip',
'wheel',
'pytest', 'pytest',
# NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB) # NOTE: playwright is now BUNDLED (pure-Python package + Node driver, ~10-15MB)
# so the browser tool works out of the box on desktop. The heavy Chromium # so the browser tool works out of the box on desktop. The heavy Chromium
+6 -11
View File
@@ -4,12 +4,11 @@
# (which Electron talks to) plus the agent core; the remaining IM channels are # (which Electron talks to) plus the agent core; the remaining IM channels are
# cheap (~27MB total) so we keep them. # cheap (~27MB total) so we keep them.
# #
# Feishu's `lark-oapi` (~116MB + deps) is NOT listed here on purpose: the desktop # Feishu's `lark-oapi` (~122MB unpacked) is NOT listed here on purpose: the
# client installs it on demand the first time the user enables the Feishu channel # desktop client downloads a trimmed ~1MB bundle the first time the user enables
# (channel/feishu/lark_install.py), keeping the base download ~116MB smaller. # the Feishu channel (channel/feishu/lark_install.py), keeping the base download
# pip/setuptools/wheel are required by that on-demand installer and must be # small. That bundle relies on `requests` and `pycryptodome` below rather than
# present in the build/run environment (they ship inside the frozen app too — see # shipping its own copies.
# the spec's hiddenimports), so they stay available.
# ---- core ---- # ---- core ----
numpy>=1.21 numpy>=1.21
@@ -53,7 +52,7 @@ python-pptx
# Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use. # Chrome/Edge, or downloads Chromium on demand into ~/.cow at first use.
playwright==1.52.0 playwright==1.52.0
# ---- IM channels (kept; lightweight). Feishu/lark-oapi is installed on demand # ---- IM channels (kept; lightweight). Feishu/lark-oapi is fetched on demand
# by the desktop client when the user enables the channel (see header note). ---- # by the desktop client when the user enables the channel (see header note). ----
wechatpy wechatpy
pycryptodome pycryptodome
@@ -62,7 +61,3 @@ websocket-client>=1.4.0
python-telegram-bot python-telegram-bot
slack_bolt slack_bolt
discord.py discord.py
# pip/setuptools/wheel back the Feishu on-demand installer; ensure they are present.
pip
setuptools
wheel
+123 -50
View File
@@ -1,92 +1,165 @@
"""Tests for channel.feishu.lark_install (on-demand lark_oapi installer).""" """Tests for channel.feishu.lark_install (on-demand Feishu SDK bundle)."""
import hashlib
import importlib.util import importlib.util
import sys import io
from unittest import mock import os
import zipfile
import pytest import pytest
from channel.feishu import lark_install from channel.feishu import lark_install
def _make_bundle():
"""Build an in-memory zip shaped like a vendor bundle."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("lark_oapi/__init__.py", b"# stub\n")
return buf.getvalue()
def _unpacked(path):
"""Stand-in for _activate: the bundle counts as usable once extracted."""
return os.path.isdir(os.path.join(path, "lark_oapi"))
@pytest.fixture @pytest.fixture
def clean_vendor(monkeypatch, tmp_path): def offline(monkeypatch, tmp_path):
"""Point the vendor dir at a temp location and drop any lark_oapi from path.""" """Isolate the vendor dir and pretend lark_oapi is not installed."""
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path)) monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
monkeypatch.delenv("COW_DESKTOP", raising=False) monkeypatch.delenv("COW_DESKTOP", raising=False)
# Ensure lark_oapi is not importable in this test process.
monkeypatch.setattr(lark_install, "is_available", lambda: False) monkeypatch.setattr(lark_install, "is_available", lambda: False)
# Avoid real subprocess / real sys.path pollution. return tmp_path
monkeypatch.setattr(subprocess_run := lark_install.subprocess, "run", mock.MagicMock())
yield tmp_path
def test_is_available_true_when_spec_found(monkeypatch): def test_is_available_reflects_import_machinery(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
importlib.util, "find_spec", lambda name: object() if name == "lark_oapi" else None importlib.util, "find_spec",
lambda name: object() if name == "lark_oapi" else None,
) )
assert lark_install.is_available() is True assert lark_install.is_available() is True
assert lark_install.is_available() is False or True # second call path-independent
def test_vendor_dir_is_versioned(monkeypatch, tmp_path):
monkeypatch.setenv("COW_DATA_DIR", str(tmp_path))
assert lark_install.vendor_dir().endswith(lark_install.VENDOR_VERSION)
def test_vendor_urls_prefer_the_china_mirror(monkeypatch):
monkeypatch.delenv("COW_FEISHU_VENDOR_URL", raising=False)
urls = lark_install.vendor_urls()
assert len(urls) > 1, "expected a fallback mirror"
assert "link-ai" in urls[0], "the China CDN should be tried first"
assert all(lark_install.VENDOR_VERSION in u for u in urls)
def test_vendor_url_can_be_overridden(monkeypatch):
monkeypatch.setenv("COW_FEISHU_VENDOR_URL", "https://example.test/b.zip")
assert lark_install.vendor_urls() == ["https://example.test/b.zip"]
def test_download_falls_back_to_next_mirror(monkeypatch):
payload = _make_bundle()
monkeypatch.setattr(lark_install, "VENDOR_SHA256",
hashlib.sha256(payload).hexdigest())
tried = []
def flaky(url):
tried.append(url)
if len(tried) == 1:
raise OSError("mirror unreachable")
return payload
monkeypatch.setattr(lark_install, "_fetch", flaky)
assert lark_install._download() == payload
assert len(tried) == 2, "should have retried against the fallback mirror"
def test_download_raises_when_every_mirror_fails(monkeypatch):
def dead(url):
raise OSError("unreachable")
monkeypatch.setattr(lark_install, "_fetch", dead)
with pytest.raises(OSError):
lark_install._download()
def test_ensure_returns_when_already_available(monkeypatch): def test_ensure_returns_when_already_available(monkeypatch):
monkeypatch.setattr(lark_install, "is_available", lambda: True) monkeypatch.setattr(lark_install, "is_available", lambda: True)
# Should not attempt any install. called = []
with mock.patch.object(lark_install.subprocess, "run") as run: monkeypatch.setattr(lark_install, "_provision", lambda t: called.append(t))
lark_install.ensure(allow_install=True) lark_install.ensure(allow_install=True)
run.assert_not_called() assert not called, "should not fetch anything when the SDK is importable"
def test_ensure_non_desktop_raises(monkeypatch): def test_ensure_non_desktop_raises(offline):
monkeypatch.setattr(lark_install, "is_available", lambda: False)
monkeypatch.delenv("COW_DESKTOP", raising=False)
with pytest.raises(ImportError): with pytest.raises(ImportError):
lark_install.ensure(allow_install=True) lark_install.ensure(allow_install=True)
def test_ensure_allow_install_false_raises(monkeypatch): def test_ensure_allow_install_false_raises(offline, monkeypatch):
monkeypatch.setattr(lark_install, "is_available", lambda: False)
monkeypatch.setenv("COW_DESKTOP", "1") monkeypatch.setenv("COW_DESKTOP", "1")
with pytest.raises(ImportError): with pytest.raises(ImportError):
lark_install.ensure(allow_install=False) lark_install.ensure(allow_install=False)
def test_ensure_desktop_installs_on_demand(monkeypatch, tmp_path): def test_ensure_reuses_previously_unpacked_bundle(offline, monkeypatch):
# Simulate: not available before, available after install. """An existing vendor dir is activated without any download."""
state = {"installed": False} os.makedirs(os.path.join(lark_install.vendor_dir(), "lark_oapi"))
def fake_available(): def unexpected(target):
return state["installed"] raise AssertionError("should not have downloaded anything")
monkeypatch.setattr(lark_install, "is_available", fake_available) monkeypatch.setattr(lark_install, "_provision", unexpected)
monkeypatch.setenv("COW_DESKTOP", "1") monkeypatch.setattr(lark_install, "_activate", _unpacked)
monkeypatch.setattr(lark_install, "vendor_dir", lambda: str(tmp_path))
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
state["installed"] = True # pretend pip succeeded
return mock.MagicMock()
monkeypatch.setattr(lark_install.subprocess, "run", fake_run)
# Should not raise; install command must target the vendor dir.
lark_install.ensure(allow_install=True) lark_install.ensure(allow_install=True)
assert calls, "expected a pip install to have been attempted"
cmd = calls[0]
assert str(tmp_path) in cmd # --target points at the vendor dir
assert "install" in cmd
assert any("lark-oapi" in str(part) for part in cmd) # lark-oapi>=1.5.5 spec
def test_ensure_desktop_install_failure_raises(monkeypatch, tmp_path): def test_ensure_downloads_and_unpacks(offline, monkeypatch):
monkeypatch.setattr(lark_install, "is_available", lambda: False)
monkeypatch.setenv("COW_DESKTOP", "1") monkeypatch.setenv("COW_DESKTOP", "1")
monkeypatch.setattr(lark_install, "vendor_dir", lambda: str(tmp_path)) payload = _make_bundle()
monkeypatch.setattr(lark_install, "VENDOR_SHA256",
hashlib.sha256(payload).hexdigest())
monkeypatch.setattr(lark_install, "_fetch", lambda url: payload)
monkeypatch.setattr(lark_install, "_activate", _unpacked)
target = lark_install.vendor_dir()
def fake_run(cmd, **kwargs): lark_install.ensure(allow_install=True)
raise RuntimeError("no network")
monkeypatch.setattr(lark_install.subprocess, "run", fake_run) assert os.path.isfile(os.path.join(target, "lark_oapi", "__init__.py"))
# No partially extracted staging dirs left behind.
leftovers = [d for d in os.listdir(os.path.dirname(target))
if d.startswith(".incomplete-")]
assert not leftovers
def test_ensure_rejects_checksum_mismatch(offline, monkeypatch):
monkeypatch.setenv("COW_DESKTOP", "1")
monkeypatch.setattr(lark_install, "VENDOR_SHA256", "0" * 64)
monkeypatch.setattr(lark_install, "_fetch", lambda url: _make_bundle())
with pytest.raises(ImportError): with pytest.raises(ImportError):
lark_install.ensure(allow_install=True) lark_install.ensure(allow_install=True)
assert not os.path.isdir(lark_install.vendor_dir())
def test_ensure_download_failure_raises(offline, monkeypatch):
monkeypatch.setenv("COW_DESKTOP", "1")
def boom(url):
raise OSError("no network")
monkeypatch.setattr(lark_install, "_fetch", boom)
with pytest.raises(ImportError):
lark_install.ensure(allow_install=True)
def test_extract_rejects_path_traversal(tmp_path):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../escaped.py", b"pwned\n")
dest = tmp_path / "dest"
dest.mkdir()
with pytest.raises(ValueError):
lark_install._safe_extract(buf.getvalue(), str(dest))