From 3bc0def0c13d088a6e927f426f7ceb0e07a9a3d5 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Thu, 17 Sep 2026 18:31:53 +0800 Subject: [PATCH 01/11] docs(readme): replace Cloud links with branded deployment CTA (#2550) --- README.md | 6 ++++-- README_CN.md | 6 ++++-- README_ES.md | 4 +++- README_FR.md | 4 +++- README_JP.md | 4 +++- README_KO.md | 4 +++- README_RU.md | 4 +++- README_TW.md | 4 +++- README_VI.md | 4 +++- res/langbot-cloud.svg | 1 + 10 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 res/langbot-cloud.svg diff --git a/README.md b/README.md index 5de92d3ad..0f51d8c02 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 FeaturesDocsAPI | -Cloud | +CloudPlugin MarketRoadmap @@ -65,7 +65,9 @@ Click the Star and Watch buttons in the top-right corner of the repository to ge ### ☁️ LangBot Cloud (Recommended) -**[LangBot Cloud](https://space.langbot.app/cloud)** — Zero deployment, ready to use. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +Zero deployment, ready to use. ### One-Line Launch diff --git a/README_CN.md b/README_CN.md index aad5c6efa..4a51bf958 100644 --- a/README_CN.md +++ b/README_CN.md @@ -24,7 +24,7 @@ 特性文档API | -Cloud | +Cloud扩展市场路线图 @@ -65,7 +65,9 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时 ### ☁️ LangBot Cloud(推荐) -**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。 +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +免部署,开箱即用。 ### 一键启动 diff --git a/README_ES.md b/README_ES.md index 04ebc78a3..8911c224a 100644 --- a/README_ES.md +++ b/README_ES.md @@ -64,7 +64,9 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit ### ☁️ LangBot Cloud (Recomendado) -**[LangBot Cloud](https://space.langbot.app/cloud)** — Sin despliegue, listo para usar. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +Sin despliegue, listo para usar. ### Lanzamiento en una línea diff --git a/README_FR.md b/README_FR.md index 78d99c692..a93692dff 100644 --- a/README_FR.md +++ b/README_FR.md @@ -64,7 +64,9 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p ### ☁️ LangBot Cloud (Recommandé) -**[LangBot Cloud](https://space.langbot.app/cloud)** — Sans déploiement, prêt à utiliser. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +Sans déploiement, prêt à utiliser. ### Lancement en une ligne diff --git a/README_JP.md b/README_JP.md index b876bd4ee..d3a3fdb00 100644 --- a/README_JP.md +++ b/README_JP.md @@ -64,7 +64,9 @@ LangBot は、AI搭載のインスタントメッセージングボットを構 ### ☁️ LangBot Cloud(推奨) -**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。 +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +デプロイ不要、すぐに使えます。 ### ワンライン起動 diff --git a/README_KO.md b/README_KO.md index b28d3ec2c..0679cb22b 100644 --- a/README_KO.md +++ b/README_KO.md @@ -64,7 +64,9 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈 ### ☁️ LangBot Cloud (추천) -**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +배포 없이 바로 사용. ### 원라인 실행 diff --git a/README_RU.md b/README_RU.md index f6c1f8bce..55b1d6437 100644 --- a/README_RU.md +++ b/README_RU.md @@ -64,7 +64,9 @@ LangBot — это **платформа с открытым исходным к ### ☁️ LangBot Cloud (Рекомендуется) -**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +Без развёртывания, готово к использованию. ### Запуск одной командой diff --git a/README_TW.md b/README_TW.md index 140515650..e946aa31e 100644 --- a/README_TW.md +++ b/README_TW.md @@ -66,7 +66,9 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時 ### ☁️ LangBot Cloud(推薦) -**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。 +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +免部署,開箱即用。 ### 一鍵啟動 diff --git a/README_VI.md b/README_VI.md index 356f577b3..7caad5b95 100644 --- a/README_VI.md +++ b/README_VI.md @@ -64,7 +64,9 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t ### ☁️ LangBot Cloud (Khuyên dùng) -**[LangBot Cloud](https://space.langbot.app/cloud)** — Không cần triển khai, sẵn sàng sử dụng. +[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) + +Không cần triển khai, sẵn sàng sử dụng. ### Khởi chạy một dòng diff --git a/res/langbot-cloud.svg b/res/langbot-cloud.svg new file mode 100644 index 000000000..d114fb53b --- /dev/null +++ b/res/langbot-cloud.svg @@ -0,0 +1 @@ +Deploy on LangBot Cloud \ No newline at end of file From db244084a91cf960ca17cec2d68876dd4b2113f7 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 08:29:51 +0800 Subject: [PATCH 02/11] Update website link in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f51d8c02..8677a65f6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- + LangBot @@ -18,7 +18,7 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) -Website | +WebsiteFeaturesDocsAPI | From e3ae3cc1ea8101bc887e1e7e1ecf8bcb549f4d30 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 08:30:22 +0800 Subject: [PATCH 03/11] Update links in README_CN.md --- README_CN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README_CN.md b/README_CN.md index 4a51bf958..e99460f85 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,5 +1,5 @@

- + LangBot @@ -20,7 +20,7 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) -官网 | +官网特性文档API | From 79b4fe20045249b2f7875b4ed78b4fac9fb38505 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 13:10:19 +0800 Subject: [PATCH 04/11] docs(readme): add text links below Cloud deployment buttons (#2551) --- README.md | 2 ++ README_CN.md | 2 ++ README_ES.md | 2 ++ README_FR.md | 2 ++ README_JP.md | 2 ++ README_KO.md | 2 ++ README_RU.md | 2 ++ README_TW.md | 2 ++ README_VI.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/README.md b/README.md index 8677a65f6..0686a794f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Click the Star and Watch buttons in the top-right corner of the repository to ge [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + Zero deployment, ready to use. ### One-Line Launch diff --git a/README_CN.md b/README_CN.md index e99460f85..f6f3ac625 100644 --- a/README_CN.md +++ b/README_CN.md @@ -67,6 +67,8 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时 [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + 免部署,开箱即用。 ### 一键启动 diff --git a/README_ES.md b/README_ES.md index 8911c224a..d6749d5bb 100644 --- a/README_ES.md +++ b/README_ES.md @@ -66,6 +66,8 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + Sin despliegue, listo para usar. ### Lanzamiento en una línea diff --git a/README_FR.md b/README_FR.md index a93692dff..71cda2887 100644 --- a/README_FR.md +++ b/README_FR.md @@ -66,6 +66,8 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + Sans déploiement, prêt à utiliser. ### Lancement en une ligne diff --git a/README_JP.md b/README_JP.md index d3a3fdb00..d410e6efa 100644 --- a/README_JP.md +++ b/README_JP.md @@ -66,6 +66,8 @@ LangBot は、AI搭載のインスタントメッセージングボットを構 [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + デプロイ不要、すぐに使えます。 ### ワンライン起動 diff --git a/README_KO.md b/README_KO.md index 0679cb22b..df06da4ab 100644 --- a/README_KO.md +++ b/README_KO.md @@ -66,6 +66,8 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈 [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + 배포 없이 바로 사용. ### 원라인 실행 diff --git a/README_RU.md b/README_RU.md index 55b1d6437..ca65b6556 100644 --- a/README_RU.md +++ b/README_RU.md @@ -66,6 +66,8 @@ LangBot — это **платформа с открытым исходным к [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + Без развёртывания, готово к использованию. ### Запуск одной командой diff --git a/README_TW.md b/README_TW.md index e946aa31e..36d3c765a 100644 --- a/README_TW.md +++ b/README_TW.md @@ -68,6 +68,8 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時 [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + 免部署,開箱即用。 ### 一鍵啟動 diff --git a/README_VI.md b/README_VI.md index 7caad5b95..2a412d992 100644 --- a/README_VI.md +++ b/README_VI.md @@ -66,6 +66,8 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t [![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app) +[cloud.langbot.app](https://cloud.langbot.app) + Không cần triển khai, sẵn sàng sử dụng. ### Khởi chạy một dòng From 34f1e3d56f841c994c208c60cec0cb5a8ddd452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csheetung=E2=80=9D?= <755855262@qq.com> Date: Fri, 18 Sep 2026 16:03:42 +0800 Subject: [PATCH 05/11] feat(fnos): run entirely without root and harden process lifecycle - Switch privilege model to run-as: package (zero root, per fnOS guide) - Borrow App Store python312 instead of bundling CPython; keep bundled uv - Move venv, HOME and caches onto the persistent data share - Start service via setsid; stop/upgrade kill the whole process group - Sweep stray runtime/box orphans in install/upgrade init hooks - Track LangBot 4.10.11 (manifest baseline + upstream merge) --- packaging/fnos/README.md | 8 +- packaging/fnos/build.sh | 19 ++-- packaging/fnos/cmd/install_callback | 122 +++++++++++++++----------- packaging/fnos/cmd/install_init | 11 ++- packaging/fnos/cmd/main | 93 +++++++++++++++----- packaging/fnos/cmd/uninstall_callback | 3 +- packaging/fnos/cmd/upgrade_callback | 82 ++++++++++------- packaging/fnos/cmd/upgrade_init | 24 ++++- packaging/fnos/config/privilege | 2 +- packaging/fnos/manifest | 2 +- 10 files changed, 238 insertions(+), 128 deletions(-) diff --git a/packaging/fnos/README.md b/packaging/fnos/README.md index 1bd672b67..193a184f1 100644 --- a/packaging/fnos/README.md +++ b/packaging/fnos/README.md @@ -2,6 +2,10 @@ This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities. +## Privilege Model + +Per the [fnOS privilege docs](https://developer.fnnas.com/docs/core-concepts/privilege/), the whole app runs as the dedicated package user (`run-as: package`; username auto-generated by fnOS from `appname`) — no root anywhere. The official App Store apps `nodejs_v22` and `python312` (declared in `manifest` via `install_dep_apps`) are borrowed from `/var/apps/` cross-app. `HOME` is pointed at `/.home` inside the persistent share so tool caches (uv, npm/npx MCP) stay writable regardless of the generated user's system home. + ## Directory Structure ``` @@ -10,10 +14,10 @@ packaging/fnos/ ├── build.sh # One-shot build script (shared by local and CI) ├── LICENSE ├── config/ -│ ├── privilege # Privilege config (run-as: root) +│ ├── privilege # Privilege config (run-as: package, no root) │ └── resource # Persistent data share declaration (langbot/data) ├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars) -│ ├── main # Service start/stop manager (start/stop/status, owns PID/log) +│ ├── main # Service start/stop manager (start/stop/status, owns PID/log); started via setsid, stop kills the whole process group │ ├── install_init # Pre-install hook │ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port │ ├── upgrade_init # Pre-upgrade hook diff --git a/packaging/fnos/build.sh b/packaging/fnos/build.sh index 74675e2a3..ef0f135fa 100644 --- a/packaging/fnos/build.sh +++ b/packaging/fnos/build.sh @@ -73,8 +73,11 @@ rsync -a \ [ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; } echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))" -# --- 2.5 Download bundled uv binaries (offline install on NAS) --- -echo "[2.5/5] Downloading bundled uv binaries..." +# --- 2.5 Download bundled uv binary (offline install on NAS) --- +# Python comes from the official python312 App Store app (see manifest +# install_dep_apps); only uv is carried in the package. The download is a +# hard requirement — the build fails without it (no fallback installs). +echo "[2.5/5] Downloading bundled uv binary..." UV_VERSION="0.12.9" mkdir -p "${FPK_DIR}/app/bin" for arch in x86_64 aarch64; do @@ -84,15 +87,13 @@ for arch in x86_64 aarch64; do continue fi tmp="$(mktemp -d)" - if curl -sSL -o "${tmp}/uv.tar.gz" \ + curl -fsSL -o "${tmp}/uv.tar.gz" \ "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \ && tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \ - && cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then - chmod +x "${out}" - echo " uv-${arch} downloaded (${UV_VERSION})" - else - echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2 - fi + && cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}" \ + && chmod +x "${out}" \ + && echo " uv-${arch} downloaded (${UV_VERSION})" \ + || { echo "ERROR: failed to download uv for ${arch}" >&2; rm -rf "${tmp}"; exit 1; } rm -rf "${tmp}" done diff --git a/packaging/fnos/cmd/install_callback b/packaging/fnos/cmd/install_callback index 4eac06acc..96b7cff79 100755 --- a/packaging/fnos/cmd/install_callback +++ b/packaging/fnos/cmd/install_callback @@ -19,7 +19,30 @@ cd "${APP_DIR}" || { } # --- Ensure data directory exists --- -mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true +mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# --- Writable HOME and tool caches --- +# Under run-as: package the generated user's system HOME and the app install +# dir (TRIM_APPDEST) can be read-only; uv aborts if it cannot initialise its +# cache. Point HOME / UV_CACHE_DIR at the persistent data share and keep the +# venv there too (UV_PROJECT_ENVIRONMENT), instead of inside APP_DIR. +VENV_DIR="${DATA_DIR}/.venv" +export HOME="${DATA_DIR}/.home" +export UV_CACHE_DIR="${DATA_DIR}/.cache/uv" +# Never download a managed CPython (the download host is unreachable on many +# NAS networks); use the distro Python only — fail loudly if it is missing. +export UV_PYTHON_DOWNLOADS=never +mkdir -p "${HOME}" "${UV_CACHE_DIR}" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# Real uv/pip errors are captured here for diagnosis (the installer popup +# only shows the short message we write to TRIM_TEMP_LOGFILE). +DEBUG_LOG="${DATA_DIR}/logs/install-debug.log" # --- Pre-seed config.yaml with the user-selected web port --- # Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by @@ -35,7 +58,10 @@ TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml" _patch_config() { local cfg_dir="$1" local cfg_file="${cfg_dir}/config.yaml" - mkdir -p "${cfg_dir}" 2>/dev/null || true + mkdir -p "${cfg_dir}" || { + echo "Cannot create config directory: ${cfg_dir}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then cp "${TEMPLATE_FILE}" "${cfg_file}" fi @@ -68,15 +94,27 @@ if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then exit 1 fi -# --- Python check --- -PYTHON_BIN="python3" -if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then - PYTHON_BIN="python" -fi -if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then - echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}" +# --- CPU architecture (must be resolved before locating bundled binaries) --- +ARCH=$(uname -m) +case "${ARCH}" in + x86_64|aarch64) ;; + *) + echo "Unsupported CPU architecture: ${ARCH}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + ;; +esac + +# --- Python: official python312 App Store app (same borrow pattern as Node.js) --- +PYTHON_APP="python312" +PYTHON_BIN="/var/apps/${PYTHON_APP}/target/bin/python3" +if [ ! -d "/var/apps/${PYTHON_APP}" ]; then + echo "未找到官方 Python 环境:请先在应用中心安装 ${PYTHON_APP},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}" exit 1 fi +[ -x "${PYTHON_BIN}" ] || { + echo "Python 解释器缺失或不可执行:${PYTHON_BIN}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) if [ -z "${PY_VER}" ]; then @@ -90,55 +128,32 @@ if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 1 exit 1 fi -# --- Resolve uv: bundled binary first, then online fallbacks --- -UV_BIN="" -ARCH=$(uname -m) -case "${ARCH}" in - x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;; - aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;; - *) BUNDLED_UV="" ;; -esac - -if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then - mkdir -p "${TRIM_PKGVAR}/bin" - cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv" - UV_BIN="${TRIM_PKGVAR}/bin/uv" -fi - -if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then - UV_BIN="uv" -fi - -if [ -z "${UV_BIN}" ]; then - "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ - "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \ - curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true - export PATH="${HOME}/.local/bin:${PATH}" - if command -v uv >/dev/null 2>&1; then - UV_BIN="uv" - elif [ -x "${HOME}/.local/bin/uv" ]; then - UV_BIN="${HOME}/.local/bin/uv" - fi -fi - -if [ -z "${UV_BIN}" ]; then - echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}" +# --- Resolve uv: run the bundled binary in place (single canonical path) --- +UV_BIN="${TRIM_APPDEST}/bin/uv-${ARCH}" +[ -x "${UV_BIN}" ] || { + echo "Bundled uv binary missing or not executable: ${UV_BIN}" > "${TRIM_TEMP_LOGFILE}" exit 1 -fi +} -# --- Create venv via uv --- -if [ ! -d ".venv" ]; then - "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { - echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}" +# --- Create venv via uv (on the writable data share, not APP_DIR) --- +if [ ! -d "${VENV_DIR}" ]; then + { + echo "== whoami: $(id 2>&1)" + echo "== APP_DIR perms: $(ls -ld "${APP_DIR}" 2>&1)" + echo "== DATA_DIR perms: $(ls -ld "${DATA_DIR}" 2>&1)" + echo "== HOME=${HOME} UV_CACHE_DIR=${UV_CACHE_DIR}" + "${UV_BIN}" venv "${VENV_DIR}" --python "${PYTHON_BIN}" + } >> "${DEBUG_LOG}" 2>&1 || { + echo "Failed to create Python virtual environment via uv. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } fi -# --- Sync dependencies --- -"${UV_BIN}" sync --extra seekdb || { - echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}" +# --- Sync dependencies (install into the relocated venv) --- +if ! UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1; then + echo "Dependency sync failed. Check network connectivity. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 -} +fi # --- Verify frontend dist --- if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then @@ -146,4 +161,9 @@ if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then exit 1 fi +# --- Prepare a writable HOME inside the data dir for tool caches (uv, +# npm/npx MCP). Everything already runs as the package user (run-as: +# package), so no chown is needed — files created here belong to it. +mkdir -p "${DATA_DIR}/.home" 2>/dev/null || true + exit 0 diff --git a/packaging/fnos/cmd/install_init b/packaging/fnos/cmd/install_init index 2e940ee03..22134488d 100755 --- a/packaging/fnos/cmd/install_init +++ b/packaging/fnos/cmd/install_init @@ -1,5 +1,12 @@ #!/bin/bash -# cmd/install_init - pre-install hook -# Nothing special to do before extraction. +# cmd/install_init - pre-install hook (runs before files are applied) +# Sweep stray processes from a previous failed/killed install: orphans whose +# parent was hard-killed keep running and hold the runtime/box ws ports, +# which breaks the new instance. No match is the normal case on a clean +# install — pkill exits 1. + +RUN_USER=$(id -un) +pkill -KILL -u "${RUN_USER}" -f "appcenter/langbot" 2>/dev/null || true +pkill -KILL -u "${RUN_USER}" -f "appshare/langbot" 2>/dev/null || true exit 0 diff --git a/packaging/fnos/cmd/main b/packaging/fnos/cmd/main index a3181c383..6dfdc6afd 100755 --- a/packaging/fnos/cmd/main +++ b/packaging/fnos/cmd/main @@ -27,27 +27,48 @@ if [ -z "${DATA_DIR}" ]; then DATA_DIR="${TRIM_PKGVAR}/data" fi export LANGBOT_DATA_ROOT="${DATA_DIR}" -mkdir -p "${DATA_DIR}" 2>/dev/null || true +mkdir -p "${DATA_DIR}" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} -# --- Locate Python --- -PYTHON_BIN="python3" -! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" +# --- Writable HOME / tool caches / venv on the data share --- +# Must match cmd/install_callback: under run-as: package neither the system +# HOME nor TRIM_APPDEST are guaranteed writable, and the venv lives at +# ${DATA_DIR}/.venv instead of inside the app dir. +export HOME="${DATA_DIR}/.home" +export UV_CACHE_DIR="${DATA_DIR}/.cache/uv" +# Never download a managed CPython — distro Python only (see install_callback) +export UV_PYTHON_DOWNLOADS=never +export UV_PROJECT_ENVIRONMENT="${DATA_DIR}/.venv" +mkdir -p "${HOME}" "${UV_CACHE_DIR}" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} -# --- Locate uv --- -# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv -UV_BIN="${TRIM_PKGVAR}/bin/uv" -if [ ! -x "${UV_BIN}" ]; then - UV_BIN="uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1; then - UV_BIN="${HOME}/.local/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${HOME}/.cargo/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${APP_DIR}/.venv/bin/uv" -fi +# --- CPU architecture (must be resolved before locating bundled binaries) --- +ARCH=$(uname -m) +case "${ARCH}" in + x86_64|aarch64) ;; + *) + echo "Unsupported CPU architecture: ${ARCH}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + ;; +esac + +# --- Locate Python: official python312 App Store app (same borrow pattern as Node.js) --- +PYTHON_BIN="/var/apps/python312/target/bin/python3" +[ -x "${PYTHON_BIN}" ] || { + echo "Python interpreter missing or not executable: ${PYTHON_BIN} (install the python312 app)" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# --- Locate uv: bundled binary at its single canonical path in the app dir --- +UV_BIN="${TRIM_APPDEST}/bin/uv-${ARCH}" +[ -x "${UV_BIN}" ] || { + echo "Bundled uv binary missing or not executable: ${UV_BIN}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} case $1 in start) @@ -69,7 +90,7 @@ case $1 in exit 1 } - if [ ! -d ".venv" ]; then + if [ ! -d "${DATA_DIR}/.venv" ]; then echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}" exit 1 fi @@ -79,6 +100,9 @@ case $1 in # fresh data/ with a default 5300 config gets recreated inside target/ on # every install/upgrade. The symlink keeps everything on the persistent # share; it is recreated here on each start (upgrades wipe target/). + # Requires the package user to have write permission on the app dir — if + # fnOS ever mounts it read-only, this fails loudly instead of silently + # running against an ephemeral data directory. APP_DATA="${APP_DIR}/data" if [ -L "${APP_DATA}" ]; then # already a symlink; re-point if the persistent dir changed @@ -87,7 +111,7 @@ case $1 in # legacy real dir (created by LangBot before this fix): merge into the # persistent dir without overwriting newer files already there mkdir -p "${DATA_DIR}" - cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/" + cp -an "${APP_DATA}/." "${DATA_DIR}/." || cp -a "${APP_DATA}/." "${DATA_DIR}/" rm -rf "${APP_DATA}" ln -s "${DATA_DIR}" "${APP_DATA}" else @@ -146,7 +170,22 @@ except Exception: # (--standalone-runtime would require an external runtime at # ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.) # --standalone-box omitted: Box sandbox defaults off, users enable via Web UI - nohup "${UV_BIN}" run --no-sync main.py \ + # + # Privilege model: the whole app runs as the generated package user + # (run-as: package, see config/privilege) — no root anywhere. HOME / + # UV_CACHE_DIR / UV_PROJECT_ENVIRONMENT are exported at the top and point + # at the persistent share so tool caches (uv, npm/npx) and the relocated + # venv stay writable regardless of the generated user's system home. + # + # Process-group lifecycle: setsid makes the main process a session/group + # leader, so PID == PGID. "stop" kills the whole group — stdio children + # (plugin runtime, Box) die with the parent and can never survive as + # orphans holding their ws ports after a crash, stop or upgrade. + command -v setsid >/dev/null 2>&1 || { + echo "setsid not found (util-linux required for process-group lifecycle)" > "${TRIM_TEMP_LOGFILE}" + exit 1 + } + setsid nohup "${UV_BIN}" run --no-sync main.py \ > "${LOG_FILE}" 2>&1 & echo $! > "${PID_FILE}" @@ -165,12 +204,18 @@ except Exception: if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ]; then - kill "${PID}" 2>/dev/null + # Started with setsid, so PID == PGID: kill the whole group so + # stdio children (plugin runtime, Box) die with the parent. The + # plain-PID kill covers instances started before the setsid + # change (group kill is a no-op for them). + kill -TERM -- "-${PID}" 2>/dev/null + kill -TERM "${PID}" 2>/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "${PID}" 2>/dev/null || break sleep 1 done - kill -9 "${PID}" 2>/dev/null + kill -KILL -- "-${PID}" 2>/dev/null + kill -KILL "${PID}" 2>/dev/null fi rm -f "${PID_FILE}" fi diff --git a/packaging/fnos/cmd/uninstall_callback b/packaging/fnos/cmd/uninstall_callback index 2baee90d9..2c982078c 100755 --- a/packaging/fnos/cmd/uninstall_callback +++ b/packaging/fnos/cmd/uninstall_callback @@ -7,8 +7,7 @@ if [ "${wizard_keep_data:-yes}" = "no" ]; then # 应用运行数据(pid、日志等) if [ -n "${TRIM_PKGVAR}" ]; then rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \ - "${TRIM_PKGVAR:?}"/langbot.log \ - "${TRIM_PKGVAR:?}"/bin 2>/dev/null || true + "${TRIM_PKGVAR:?}"/langbot.log 2>/dev/null || true fi # 共享数据目录(langbot/data) diff --git a/packaging/fnos/cmd/upgrade_callback b/packaging/fnos/cmd/upgrade_callback index 9288c866c..f5b51c508 100755 --- a/packaging/fnos/cmd/upgrade_callback +++ b/packaging/fnos/cmd/upgrade_callback @@ -8,6 +8,20 @@ APP_DIR="${TRIM_APPDEST}/langbot" DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" [ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data" +# Writable HOME / caches / venv on the data share (must match +# cmd/install_callback — venv is relocated under DATA_DIR). +VENV_DIR="${DATA_DIR}/.venv" +export HOME="${DATA_DIR}/.home" +export UV_CACHE_DIR="${DATA_DIR}/.cache/uv" +# Never download a managed CPython — distro Python only (see install_callback) +export UV_PYTHON_DOWNLOADS=never +export UV_PROJECT_ENVIRONMENT="${VENV_DIR}" +mkdir -p "${HOME}" "${UV_CACHE_DIR}" "${DATA_DIR}/logs" || { + echo "Data share not writable: ${DATA_DIR}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} +DEBUG_LOG="${DATA_DIR}/logs/upgrade-debug.log" + # Apply port from upgrade wizard (config persists across upgrades; this # only rewrites it when the user changed the value in the upgrade wizard) CONFIG_FILE="${DATA_DIR}/config.yaml" @@ -26,52 +40,56 @@ cd "${APP_DIR}" || { exit 1 } -# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin) -UV_BIN="${TRIM_PKGVAR}/bin/uv" -if [ ! -x "${UV_BIN}" ]; then - UV_BIN="uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1; then - UV_BIN="${HOME}/.local/bin/uv" -fi -if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - UV_BIN="${HOME}/.cargo/bin/uv" -fi +# --- CPU architecture (must be resolved before locating bundled binaries) --- +ARCH=$(uname -m) +case "${ARCH}" in + x86_64|aarch64) ;; + *) + echo "Unsupported CPU architecture: ${ARCH}" > "${TRIM_TEMP_LOGFILE}" + exit 1 + ;; +esac -PYTHON_BIN="python3" -! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" +# uv: bundled binary at its single canonical path in the app dir +UV_BIN="${TRIM_APPDEST}/bin/uv-${ARCH}" +[ -x "${UV_BIN}" ] || { + echo "Bundled uv binary missing or not executable: ${UV_BIN}" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} -# Re-sync deps -if [ -d ".venv" ]; then - "${UV_BIN}" sync --extra seekdb 2>/dev/null || { - echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}" +# Python: official python312 App Store app (same borrow pattern as Node.js) +PYTHON_BIN="/var/apps/python312/target/bin/python3" +[ -x "${PYTHON_BIN}" ] || { + echo "Python interpreter missing or not executable: ${PYTHON_BIN} (install the python312 app)" > "${TRIM_TEMP_LOGFILE}" + exit 1 +} + +# Re-sync deps (venv lives at ${DATA_DIR}/.venv, see install_callback) +if [ -d "${VENV_DIR}" ]; then + UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1 || { + echo "Dependency sync failed after upgrade. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } else - # Venv was lost, recreate via uv - if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then - "${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \ - "${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || { - echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}" - exit 1 - } - export PATH="${HOME}/.local/bin:${PATH}" - UV_BIN="uv" - fi - "${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || { - echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}" + # Venv was lost, recreate it with the bundled uv on the data share + "${UV_BIN}" venv "${VENV_DIR}" --python "${PYTHON_BIN}" >> "${DEBUG_LOG}" 2>&1 || { + echo "Failed to recreate virtual environment. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } - "${UV_BIN}" sync --extra seekdb || { - echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}" + UV_PROJECT_ENVIRONMENT="${VENV_DIR}" "${UV_BIN}" sync --extra seekdb >> "${DEBUG_LOG}" 2>&1 || { + echo "Dependency sync failed. See ${DEBUG_LOG}" > "${TRIM_TEMP_LOGFILE}" exit 1 } fi # Verify frontend dist still present if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then - echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" + echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}" exit 1 fi +# Everything runs as the package user (run-as: package); the venv recreated +# above and the config rewritten via sed already belong to it. Cache HOME is +# prepared at the top of this script (see cmd/install_callback). + exit 0 diff --git a/packaging/fnos/cmd/upgrade_init b/packaging/fnos/cmd/upgrade_init index fdac71831..9fc46a9b9 100755 --- a/packaging/fnos/cmd/upgrade_init +++ b/packaging/fnos/cmd/upgrade_init @@ -1,20 +1,36 @@ #!/bin/bash -# cmd/upgrade_init - pre-upgrade hook -# Stop the running LangBot process before files are replaced. +# cmd/upgrade_init - pre-upgrade hook (runs before files are replaced) +# 1. Stop the running LangBot instance — whole process group, so stdio +# children (plugin runtime, Box) die with the parent. +# 2. Sweep stray processes from a previous crash/upgrade: orphans whose +# parent was hard-killed keep running and hold the runtime/box ws ports, +# which breaks the next start. PID_FILE="${TRIM_PKGVAR}/langbot.pid" if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then - kill "${PID}" 2>/dev/null + # Instances started with setsid have PID == PGID; the plain-PID kill + # covers instances started before that change. + kill -TERM -- "-${PID}" 2>/dev/null + kill -TERM "${PID}" 2>/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "${PID}" 2>/dev/null || break sleep 1 done - kill -9 "${PID}" 2>/dev/null + kill -KILL -- "-${PID}" 2>/dev/null + kill -KILL "${PID}" 2>/dev/null fi rm -f "${PID_FILE}" fi +# Stray sweep: match only our volume paths (venv/uv under the app dir on +# @appcenter, venv/HOME/caches under the data share on @appshare). This +# script itself runs from /var/apps//cmd/, so it never matches +# itself. No match is the normal case on a clean upgrade — pkill exits 1. +RUN_USER=$(id -un) +pkill -KILL -u "${RUN_USER}" -f "appcenter/langbot" 2>/dev/null || true +pkill -KILL -u "${RUN_USER}" -f "appshare/langbot" 2>/dev/null || true + exit 0 diff --git a/packaging/fnos/config/privilege b/packaging/fnos/config/privilege index e21db569b..2aafe2347 100644 --- a/packaging/fnos/config/privilege +++ b/packaging/fnos/config/privilege @@ -1,5 +1,5 @@ { "defaults": { - "run-as": "root" + "run-as": "package" } } diff --git a/packaging/fnos/manifest b/packaging/fnos/manifest index 98699b795..a05b5be1a 100644 --- a/packaging/fnos/manifest +++ b/packaging/fnos/manifest @@ -1,5 +1,5 @@ appname=langbot -version=4.10.10 +version=4.10.11 display_name=LangBot desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。 platform=all From d705861d259b831c76c53b0e2917a7020adf6259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csheetung=E2=80=9D?= <755855262@qq.com> Date: Fri, 18 Sep 2026 17:05:47 +0800 Subject: [PATCH 06/11] fix(fnos): open LangBot embedded inside fnOS desktop instead of external browser Change desktop entry type from 'url' (opens external browser) to 'iframe' (embeds the web UI inside the fnOS desktop window), per the official Application Entry documentation at developer.fnnas.com/docs/core-concepts/app-entry. --- packaging/fnos/app/desktop/langbot.main.url | 2 +- packaging/fnos/app/ui/config | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/fnos/app/desktop/langbot.main.url b/packaging/fnos/app/desktop/langbot.main.url index 513870fe8..1717216cc 100644 --- a/packaging/fnos/app/desktop/langbot.main.url +++ b/packaging/fnos/app/desktop/langbot.main.url @@ -1,7 +1,7 @@ { "title": "LangBot", "icon": "images/icon-256.png", - "type": "url", + "type": "iframe", "protocol": "http", "port": "${wizard_port}", "url": "/", diff --git a/packaging/fnos/app/ui/config b/packaging/fnos/app/ui/config index d45765dd4..2908f45de 100644 --- a/packaging/fnos/app/ui/config +++ b/packaging/fnos/app/ui/config @@ -3,7 +3,7 @@ "langbot.main": { "title": "LangBot", "icon": "images/icon-{0}.png", - "type": "url", + "type": "iframe", "protocol": "http", "port": "${wizard_port}", "url": "/", From d61926af8507555163f01c6375919c5f7e6a45d6 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 21:01:02 +0800 Subject: [PATCH 07/11] Update website link in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0686a794f..81277fd54 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- + LangBot @@ -18,7 +18,7 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 python [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) -Website | +WebsiteFeaturesDocsAPI | From 4b86d979dd97d6a85b20db5a2e0f6ee65527e519 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Fri, 18 Sep 2026 21:01:31 +0800 Subject: [PATCH 08/11] Update links in README_CN.md --- README_CN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README_CN.md b/README_CN.md index f6f3ac625..d49799cf6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,5 +1,5 @@

- + LangBot @@ -20,7 +20,7 @@ [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) [![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot) -官网 | +官网特性文档API | From 20a04a77bf3c2617663ac3048b8694aaafde8534 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 20 Sep 2026 18:54:45 +0800 Subject: [PATCH 09/11] feat(plugin): enforce certified archive admission (#2553) * feat(plugin): add certified admission policy * feat(plugin): enforce certified archive admission * chore(plugin): pin certified SDK beta * fix(plugin): consume SDK beta 5 * style(plugin): format certification admission --- ARCHITECTURE.md | 1 + docs/architecture/certified-plugins.md | 74 ++++++ pyproject.toml | 2 +- .../pkg/api/http/controller/groups/plugins.py | 3 + src/langbot/pkg/core/stages/load_config.py | 1 + src/langbot/pkg/plugin/archive.py | 84 ++++++- src/langbot/pkg/plugin/certification.py | 234 ++++++++++++++++++ src/langbot/pkg/plugin/connector.py | 50 ++++ src/langbot/templates/config.yaml | 6 + .../integration/api/test_plugins_security.py | 33 +++ .../plugin/test_certified_plugin_admission.py | 169 +++++++++++++ .../plugin/test_certified_plugin_policy.py | 153 ++++++++++++ .../plugin/test_connector_reconcile.py | 6 + uv.lock | 126 +++++----- 14 files changed, 871 insertions(+), 71 deletions(-) create mode 100644 docs/architecture/certified-plugins.md create mode 100644 src/langbot/pkg/plugin/certification.py create mode 100644 tests/integration/plugin/test_certified_plugin_admission.py create mode 100644 tests/unit_tests/plugin/test_certified_plugin_policy.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b5defb82..552a104dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,6 +158,7 @@ In this repo: - `pkg/plugin/handler.py` exposes LangBot actions to the runtime and calls runtime actions for plugin operations. - `pkg/provider/tools/loaders/plugin.py` exposes plugin Tool components to LLM runners. - Pipeline handlers emit SDK events such as normal-message events and prompt-processing events. +- [Certified plugin policy](docs/architecture/certified-plugins.md) defines Core's archive-fact, admission, and tenant-log-visibility boundary; the SDK remains responsible for certificate verification. In `langbot-plugin-sdk`: diff --git a/docs/architecture/certified-plugins.md b/docs/architecture/certified-plugins.md new file mode 100644 index 000000000..f4c9400ac --- /dev/null +++ b/docs/architecture/certified-plugins.md @@ -0,0 +1,74 @@ +# Certified Plugins + +## Admission boundary + +Core verifies a plugin archive **before** artifact storage, `PluginSetting` +persistence, or a Plugin Runtime apply request. It calls the SDK public +`langbot_plugin.certification.verify_archive()` API, which reads the strict +certificate envelope from the ZIP comment and verifies the signed normalized +ZIP digest without extracting the payload. + +Core retains the normalized digest (`normalized_zip_digest()`), verification +state, declared shared-runtime profile, key ID, selected admission profile, and +stable admission code in the durable plugin `install_info._certification` +record. The record belongs to the installation row; no schema migration is +needed for this additive JSON metadata. + +## Trusted issuer configuration + +Configure the non-secret Ed25519 public-key ring in `data/config.yaml`: + +```yaml +plugin: + certification: + trusted_public_keys: + issuer-2026-q3: "" +``` + +Key IDs must match the SDK envelope. Values are standard base64 raw public +keys, not private/signing keys. An invalid key-ring configuration is rejected +rather than weakening verification. Keep active issuer keys during a rotation +until archives signed by retired IDs are no longer installed. + +## Admission matrix + +| Deployment | SDK verification | Explicit `administrator_force` | Result | +| --- | --- | --- | --- | +| Cloud | valid envelope declaring `shared-runtime-v1` | any | admitted to the shared profile | +| Cloud | absent | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED` | +| Cloud | malformed, untrusted, invalid, or non-shared | any | reject before storage with `CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID` | +| OSS | absent legacy envelope | any | admitted to the dedicated profile | +| OSS | valid envelope declaring `shared-runtime-v1` | any | selected shared profile | +| OSS | malformed or invalid declaration | false | reject with `CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED` | +| OSS | malformed or invalid declaration | true | admitted to the dedicated profile | + +`administrator_force` is deliberately strict: it is recognized only when the +install request carries boolean `true`. The local upload endpoint accepts the +multipart field `administrator_force=true`; GitHub and marketplace install +payloads carry the same field. The existing resource-manage authorization fence +protects those endpoints. A force never creates a Cloud dedicated fallback. + +## Runtime and logs + +The current Plugin Runtime control protocol has one process-wide runtime profile +per Core instance. In Cloud that existing profile is `shared`; Cloud admission +therefore prevents an archive that did not select `shared-runtime-v1` from +reaching its apply API. In OSS the existing `oss_dev` runtime remains the +dedicated compatibility profile. Core records the selected profile for every +installation so a future multi-runtime control protocol can consume it without +re-verifying an already persisted archive. + +The existing public plugin-log boundary already applies the immutable +installation binding (including workspace UUID) through +`RuntimeConnectionHandler.installation_scope()` before requesting logs. This is +the actual tenant exposure boundary, so valid shared certificates use that +binding-scoped transport; Core does not invent a second log stream or expose +process-wide log output. Dedicated and invalid/legacy installations use the +same existing installation scope. + +## SDK versioning + +Core intentionally continues to declare `langbot-plugin==0.5.8` until the SDK +beta containing this public certification API is released. Local development +and the integration tests may install the SDK source checkout, but this Core +change does not publish or pin a prerelease. diff --git a/pyproject.toml b/pyproject.toml index b8880a6b6..2914a6ec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.5.8", + "langbot-plugin==0.6.0b5", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 77d710693..9c048bd71 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -923,10 +923,13 @@ class PluginsRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'file is required') file_bytes = file.read() + form = await quart.request.form + administrator_force = form.get('administrator_force', '').strip().lower() == 'true' execution_context = await self.ap.plugin_connector.require_workspace_context(request_context) data = { 'plugin_file': file_bytes, + 'administrator_force': administrator_force, } ctx = taskmgr.TaskContext.new() diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index e34c740bd..3d62d85d9 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -42,6 +42,7 @@ _RUNTIME_POLICY_DEFAULTS = { }, 'plugin': { 'connect_timeout_seconds': 180.0, + 'certification': {'trusted_public_keys': {}}, 'worker': { 'max_cpus': 1.0, 'max_memory_mb': 512, diff --git a/src/langbot/pkg/plugin/archive.py b/src/langbot/pkg/plugin/archive.py index f3a8511a4..28b23cbff 100644 --- a/src/langbot/pkg/plugin/archive.py +++ b/src/langbot/pkg/plugin/archive.py @@ -1,7 +1,10 @@ from __future__ import annotations +import hashlib import io import zipfile +from dataclasses import dataclass +from enum import Enum import yaml @@ -14,6 +17,34 @@ _PLUGIN_METADATA_MAX_BYTES = 1024 * 1024 _PLUGIN_REQUIREMENTS_MAX_ENTRIES = 1000 +class ArchiveCertificateState(str, Enum): + """Syntactic certificate declaration state; this is not verification.""" + + ABSENT = 'absent' + MALFORMED = 'malformed' + DECLARED = 'declared' + + +@dataclass(frozen=True) +class ArchiveCertificateDeclaration: + """Bounded, verifier-facing certificate declaration from ``manifest.yaml``.""" + + state: ArchiveCertificateState + runtime_profile: str | None = None + payload: dict[str, object] | None = None + + +@dataclass(frozen=True) +class PluginArchiveInspection: + """Validated archive metadata plus unverified certificate declaration facts.""" + + manifest: dict + requirements: list[str] + names: list[str] + artifact_digest: str + certificate: ArchiveCertificateDeclaration + + def _read_plugin_archive_member( archive: zipfile.ZipFile, member: zipfile.ZipInfo, @@ -29,12 +60,30 @@ def _read_plugin_archive_member( return content -def inspect_plugin_archive_metadata( - file_bytes: bytes, - *, - require_manifest: bool = True, -) -> tuple[dict, list[str], list[str]]: - """Validate archive size metadata and read only bounded preview fields.""" +def _inspect_certificate_declaration(manifest: dict) -> ArchiveCertificateDeclaration: + declaration = manifest.get('certification') + if declaration is None: + return ArchiveCertificateDeclaration(ArchiveCertificateState.ABSENT) + if not isinstance(declaration, dict): + return ArchiveCertificateDeclaration(ArchiveCertificateState.MALFORMED) + + runtime_profile = declaration.get('runtime_profile') + payload = declaration.get('certificate') + if not isinstance(runtime_profile, str) or not runtime_profile.strip() or not isinstance(payload, dict): + return ArchiveCertificateDeclaration(ArchiveCertificateState.MALFORMED) + return ArchiveCertificateDeclaration( + ArchiveCertificateState.DECLARED, + runtime_profile=runtime_profile, + payload=payload, + ) + + +def inspect_plugin_archive(file_bytes: bytes, *, require_manifest: bool = True) -> PluginArchiveInspection: + """Validate an archive and expose certificate declaration facts for a verifier. + + Certificate signatures and issuer trust are deliberately not evaluated here; + callers must pass the declaration and artifact digest to an SDK verifier. + """ with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive: members = archive.infolist() @@ -95,4 +144,25 @@ def inspect_plugin_archive_metadata( for line in content.splitlines() if line.strip() and not line.strip().startswith('#') ][:_PLUGIN_REQUIREMENTS_MAX_ENTRIES] - return manifest, requirements, names + + return PluginArchiveInspection( + manifest=manifest, + requirements=requirements, + names=names, + artifact_digest=hashlib.sha256(file_bytes).hexdigest(), + certificate=_inspect_certificate_declaration(manifest), + ) + + +def inspect_plugin_archive_metadata( + file_bytes: bytes, + *, + require_manifest: bool = True, +) -> tuple[dict, list[str], list[str]]: + """Legacy tuple API for archive metadata callers. + + Use ``inspect_plugin_archive`` when certificate declaration facts are needed. + """ + + inspection = inspect_plugin_archive(file_bytes, require_manifest=require_manifest) + return inspection.manifest, inspection.requirements, inspection.names diff --git a/src/langbot/pkg/plugin/certification.py b/src/langbot/pkg/plugin/certification.py new file mode 100644 index 000000000..d1a233847 --- /dev/null +++ b/src/langbot/pkg/plugin/certification.py @@ -0,0 +1,234 @@ +"""Pure certified-plugin facts and admission policies. + +This module intentionally does not verify signatures. An SDK-backed verifier +must produce ``CertificateFacts`` from an inspected archive before admission. +""" + +from __future__ import annotations + +import base64 +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import Enum + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from langbot_plugin.certification import normalized_zip_digest, verify_archive + + +SHARED_RUNTIME_V1 = 'shared-runtime-v1' +DEDICATED_RUNTIME = 'dedicated' + + +class CertificateVerification(str, Enum): + ABSENT = 'absent' + MALFORMED = 'malformed' + INVALID = 'invalid' + VALID = 'valid' + + +class DeploymentMode(str, Enum): + CLOUD = 'cloud' + OSS = 'oss' + + +class AdmissionDisposition(str, Enum): + SHARED_ELIGIBLE = 'shared_eligible' + DEDICATED_ALLOWED = 'dedicated_allowed' + REJECTED = 'rejected' + ADMINISTRATOR_FORCE_REQUIRED = 'administrator_force_required' + + +class AdmissionCode(str, Enum): + SHARED_ELIGIBLE = 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE' + CLOUD_CERTIFICATE_REQUIRED = 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED' + CLOUD_CERTIFICATE_INVALID = 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID' + OSS_LEGACY_DEDICATED = 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED' + OSS_FORCE_REQUIRED = 'CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED' + OSS_FORCED_DEDICATED = 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED' + OSS_CERTIFIED_DEDICATED = 'CERTIFIED_PLUGIN_OSS_CERTIFIED_DEDICATED' + + +class PluginLogVisibility(str, Enum): + TENANT_SCOPED = 'tenant_scoped' + DETAILED_PROCESS = 'detailed_process' + + +@dataclass(frozen=True) +class CertificateFacts: + """Certificate result supplied by an archive verifier. + + ``VALID`` means the verifier has validated both the certificate and its + binding to the immutable artifact digest in ``PluginCertificationFacts``. + """ + + verification: CertificateVerification + runtime_profile: str | None = None + certificate_id: str | None = None + + @property + def is_valid_shared_runtime(self) -> bool: + return self.verification is CertificateVerification.VALID and self.runtime_profile == SHARED_RUNTIME_V1 + + @property + def is_declared(self) -> bool: + return self.verification is not CertificateVerification.ABSENT + + +@dataclass(frozen=True) +class PluginCertificationFacts: + """Immutable Core-side facts for one plugin installation artifact.""" + + installation_uuid: str + artifact_digest: str + certificate: CertificateFacts + + def __post_init__(self) -> None: + if len(self.artifact_digest) != 64 or any( + character not in '0123456789abcdef' for character in self.artifact_digest.lower() + ): + raise ValueError('artifact_digest must be a lowercase-or-uppercase SHA-256 hex digest') + + +@dataclass(frozen=True) +class VerifiedArchiveCertificate: + """SDK verification facts bound to the comment-normalized ZIP digest.""" + + normalized_digest: str + certificate: CertificateFacts + + def for_installation(self, installation_uuid: str) -> PluginCertificationFacts: + return PluginCertificationFacts( + installation_uuid=installation_uuid, + artifact_digest=self.normalized_digest, + certificate=self.certificate, + ) + + +def trusted_public_key_ring(config: object) -> dict[str, Callable[[bytes, bytes], bool]]: + """Build the non-secret Ed25519 verifier ring from instance configuration. + + ``plugin.certification.trusted_public_keys`` is a mapping of key IDs to + standard base64-encoded 32-byte Ed25519 public keys. Configuration errors + are explicit so an operator never silently gets a weaker trust policy. + """ + + if config is None: + return {} + if not isinstance(config, Mapping): + raise ValueError('plugin.certification.trusted_public_keys must be a mapping') + + ring: dict[str, Callable[[bytes, bytes], bool]] = {} + for raw_key_id, raw_public_key in config.items(): + key_id = str(raw_key_id).strip() + if not key_id or not isinstance(raw_public_key, str): + raise ValueError('plugin.certification.trusted_public_keys entries must have string IDs and values') + try: + public_key_bytes = base64.b64decode(raw_public_key.encode('ascii'), validate=True) + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + except (UnicodeEncodeError, ValueError) as exc: + raise ValueError(f'plugin.certification trusted public key {key_id!r} is invalid') from exc + + def verify(payload: bytes, signature: bytes, *, verifier: Ed25519PublicKey = public_key) -> bool: + try: + verifier.verify(signature, payload) + except (InvalidSignature, TypeError, ValueError): + return False + return True + + ring[key_id] = verify + return ring + + +def verify_plugin_archive_certificate( + archive: bytes, + *, + trusted_public_keys: object, +) -> VerifiedArchiveCertificate: + """Use the SDK ZIP-comment API and retain its normalized-digest binding.""" + + verification = verify_archive(archive, trusted_public_key_ring(trusted_public_keys).get) + envelope = verification.envelope + runtime_profile = envelope.shared_runtime if envelope is not None else None + certificate_id = envelope.key_id if envelope is not None else None + state = { + 'absent': CertificateVerification.ABSENT, + 'malformed': CertificateVerification.MALFORMED, + 'valid': CertificateVerification.VALID, + }.get(verification.status, CertificateVerification.INVALID) + return VerifiedArchiveCertificate( + normalized_digest=normalized_zip_digest(archive), + certificate=CertificateFacts( + verification=state, + runtime_profile=runtime_profile, + certificate_id=certificate_id, + ), + ) + + +@dataclass(frozen=True) +class PluginAdmissionDecision: + disposition: AdmissionDisposition + code: AdmissionCode + runtime_profile: str + + +def decide_plugin_admission( + *, + deployment: DeploymentMode | str, + facts: PluginCertificationFacts, + administrator_force: bool = False, +) -> PluginAdmissionDecision: + """Apply Cloud fail-closed and OSS administrator-force admission rules.""" + + mode = DeploymentMode(deployment) + certificate = facts.certificate + if certificate.is_valid_shared_runtime: + return PluginAdmissionDecision( + AdmissionDisposition.SHARED_ELIGIBLE, + AdmissionCode.SHARED_ELIGIBLE, + SHARED_RUNTIME_V1, + ) + + if mode is DeploymentMode.CLOUD: + code = ( + AdmissionCode.CLOUD_CERTIFICATE_REQUIRED + if certificate.verification is CertificateVerification.ABSENT + else AdmissionCode.CLOUD_CERTIFICATE_INVALID + ) + return PluginAdmissionDecision(AdmissionDisposition.REJECTED, code, DEDICATED_RUNTIME) + + if certificate.verification is CertificateVerification.ABSENT: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_LEGACY_DEDICATED, + DEDICATED_RUNTIME, + ) + + if certificate.verification is CertificateVerification.VALID: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_CERTIFIED_DEDICATED, + DEDICATED_RUNTIME, + ) + + if administrator_force: + return PluginAdmissionDecision( + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionCode.OSS_FORCED_DEDICATED, + DEDICATED_RUNTIME, + ) + + return PluginAdmissionDecision( + AdmissionDisposition.ADMINISTRATOR_FORCE_REQUIRED, + AdmissionCode.OSS_FORCE_REQUIRED, + DEDICATED_RUNTIME, + ) + + +def decide_plugin_log_visibility(facts: PluginCertificationFacts) -> PluginLogVisibility: + """Select the minimum log visibility compatible with a verified shared runtime.""" + + if facts.certificate.is_valid_shared_runtime: + return PluginLogVisibility.TENANT_SCOPED + return PluginLogVisibility.DETAILED_PROCESS diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 7df06aefe..3648d3fce 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -22,6 +22,13 @@ from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from ..core import app from . import handler from .archive import inspect_plugin_archive_metadata +from .certification import ( + AdmissionDisposition, + PluginCertificationFacts, + VerifiedArchiveCertificate, + decide_plugin_admission, + verify_plugin_archive_certificate, +) from .github import ( validate_github_plugin_install_info, validate_github_release_asset_url, @@ -1683,6 +1690,45 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): ) return plugin_package, latest_version + def _admit_plugin_archive( + self, + file_bytes: bytes, + install_info: dict[str, Any], + ) -> tuple[dict[str, Any], VerifiedArchiveCertificate]: + """Verify and admit one archive before it can reach durable storage or Runtime.""" + + certification_config = self.ap.instance_config.data.get('plugin', {}).get('certification', {}) + if not isinstance(certification_config, dict): + raise ValueError('plugin.certification must be a mapping') + verified = verify_plugin_archive_certificate( + file_bytes, + trusted_public_keys=certification_config.get('trusted_public_keys', {}), + ) + facts = PluginCertificationFacts( + installation_uuid='pending-installation', + artifact_digest=verified.normalized_digest, + certificate=verified.certificate, + ) + decision = decide_plugin_admission( + deployment=getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss'), + facts=facts, + administrator_force=install_info.get('administrator_force') is True, + ) + if decision.disposition not in { + AdmissionDisposition.DEDICATED_ALLOWED, + AdmissionDisposition.SHARED_ELIGIBLE, + }: + raise ValueError(decision.code.value) + certification_info = { + 'normalized_digest': facts.artifact_digest, + 'verification': facts.certificate.verification.value, + 'certificate_runtime_profile': facts.certificate.runtime_profile, + 'certificate_id': facts.certificate.certificate_id, + 'runtime_profile': decision.runtime_profile, + 'admission_code': decision.code.value, + } + return {**install_info, '_certification': certification_info}, verified + async def install_plugin( self, install_source: PluginInstallSource, @@ -1719,6 +1765,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): else: raise ValueError(f'Unsupported plugin install source: {install_source.value}') + install_info, verified_certificate = self._admit_plugin_archive(file_bytes, install_info) manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context) if not manifest_author or not manifest_name: raise ValueError('Plugin package manifest identity is missing') @@ -1749,6 +1796,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): plugin_author=plugin_author, plugin_name=plugin_name, ) + certification_facts = verified_certificate.for_installation(binding.installation_uuid) + if certification_facts.artifact_digest != install_info['_certification']['normalized_digest']: + raise RuntimeError('Plugin certification digest changed before Runtime apply') await self._apply_desired_state( PluginInstallationDesiredState(binding=binding, enabled=True), artifact_package=file_bytes, diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 24efaba09..df1828216 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -261,6 +261,12 @@ plugin: runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' enable_marketplace: true display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' + certification: + # Non-secret Ed25519 issuer key ring used to verify the SDK ZIP-comment + # certification envelope. Values are standard base64-encoded raw public + # keys; add keys during issuer rotation and remove retired IDs only after + # every affected archive has been upgraded. + trusted_public_keys: {} worker: # Instance-wide maximum for every plugin installation. Plugin # manifests cannot raise or override these limits. diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py index 5f79f716a..52442e371 100644 --- a/tests/integration/api/test_plugins_security.py +++ b/tests/integration/api/test_plugins_security.py @@ -3,11 +3,13 @@ from __future__ import annotations import copy +import io from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, call import pytest import quart +from quart.datastructures import FileStorage pytestmark = pytest.mark.integration @@ -287,3 +289,34 @@ async def test_github_install_rejects_internal_asset_url_before_task_creation( assert response.status_code == 400 assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg'] application.task_mgr.create_user_task.assert_not_called() + + +@pytest.mark.asyncio +async def test_local_install_forwards_explicit_administrator_force(plugin_security_api): + application, client, _ = plugin_security_api + execution_context = SimpleNamespace( + instance_uuid='instance-test', + workspace_uuid=WORKSPACE_UUID, + placement_generation=1, + ) + application.persistence_mgr.tenant_scope = None + application.plugin_connector.require_workspace_context = AsyncMock(return_value=execution_context) + application.plugin_connector.install_plugin = AsyncMock() + application.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id='task-certification')) + + response = await client.post( + '/api/v1/plugins/install/local', + headers=_headers('manager-token'), + files={ + 'file': FileStorage(stream=io.BytesIO(b'archive'), filename='plugin.lbpkg'), + }, + form={'administrator_force': 'true'}, + ) + + assert response.status_code == 200 + operation = application.task_mgr.create_user_task.call_args.args[0] + await operation + assert application.plugin_connector.install_plugin.await_args.args[1] == { + 'plugin_file': b'archive', + 'administrator_force': True, + } diff --git a/tests/integration/plugin/test_certified_plugin_admission.py b/tests/integration/plugin/test_certified_plugin_admission.py new file mode 100644 index 000000000..330e59e06 --- /dev/null +++ b/tests/integration/plugin/test_certified_plugin_admission.py @@ -0,0 +1,169 @@ +"""Certified archive admission through the public Core installation API.""" + +from __future__ import annotations + +import base64 +import hashlib +import io +import zipfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import yaml +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from langbot.pkg.api.http.context import ExecutionContext +from langbot.pkg.plugin.connector import PluginRuntimeConnector +from langbot_plugin.entities.io.context import InstallationBinding +from langbot_plugin.runtime.plugin.mgr import PluginInstallSource + + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('deployment', 'archive_kind', 'administrator_force', 'expected_profile'), + [ + ('cloud', 'signed_shared', False, 'shared-runtime-v1'), + ('oss', 'signed_shared', False, 'shared-runtime-v1'), + ('oss', 'legacy', False, 'dedicated'), + ('oss', 'invalid_shared', True, 'dedicated'), + ], +) +async def test_install_plugin_admits_archive_before_persistence_and_applies_selected_profile( + deployment: str, + archive_kind: str, + administrator_force: bool, + expected_profile: str, +) -> None: + package, trusted_public_keys = _archive(archive_kind) + connector, execution_context, binding = _connector(deployment, trusted_public_keys) + + await connector.install_plugin( + PluginInstallSource.LOCAL, + { + 'plugin_file': package, + 'administrator_force': administrator_force, + }, + ) + + connector._store_artifact_package.assert_awaited_once_with( + execution_context, + hashlib.sha256(package).hexdigest(), + package, + ) + persisted_info = connector._persist_installation_package.await_args.kwargs['install_info'] + assert persisted_info['_certification']['runtime_profile'] == expected_profile + assert persisted_info['_certification']['normalized_digest'] == _normalized_digest(package) + connector.handler.apply_plugin_installation.assert_awaited_once_with( + binding, + artifact_package=package, + enabled=True, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('archive_kind', ['legacy', 'invalid_shared']) +async def test_cloud_rejects_untrusted_archive_before_storage_persistence_or_runtime_apply(archive_kind: str) -> None: + package, trusted_public_keys = _archive(archive_kind) + connector, _execution_context, _binding = _connector('cloud', trusted_public_keys) + + with pytest.raises(ValueError, match='CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_'): + await connector.install_plugin(PluginInstallSource.LOCAL, {'plugin_file': package}) + + connector._store_artifact_package.assert_not_awaited() + connector._persist_installation_package.assert_not_awaited() + connector.handler.apply_plugin_installation.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_oss_requires_explicit_administrator_force_for_declared_invalid_archive() -> None: + package, trusted_public_keys = _archive('invalid_shared') + connector, _execution_context, _binding = _connector('oss', trusted_public_keys) + + with pytest.raises(ValueError, match='CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED'): + await connector.install_plugin(PluginInstallSource.LOCAL, {'plugin_file': package}) + + connector._store_artifact_package.assert_not_awaited() + connector._persist_installation_package.assert_not_awaited() + connector.handler.apply_plugin_installation.assert_not_awaited() + + +def _connector(deployment: str, trusted_public_keys: dict[str, str]): + package_digest = 'a' * 64 + execution_context = ExecutionContext( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + ) + binding = InstallationBinding( + instance_uuid='instance-a', + workspace_uuid='workspace-a', + placement_generation=1, + installation_uuid='00000000-0000-4000-8000-000000000001', + runtime_revision=1, + artifact_digest=package_digest, + ) + app = SimpleNamespace( + instance_config=SimpleNamespace( + data={ + 'plugin': { + 'enable': True, + 'certification': {'trusted_public_keys': trusted_public_keys}, + } + } + ), + deployment=SimpleNamespace(mode=deployment), + logger=Mock(), + ) + connector = PluginRuntimeConnector(app, AsyncMock()) + connector.handler = SimpleNamespace( + register_installation_binding=Mock(), + apply_plugin_installation=AsyncMock(return_value={'state': 'running'}), + ) + connector._current_execution_context = AsyncMock(return_value=execution_context) + connector._store_artifact_package = AsyncMock() + connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) + connector._wait_for_installed_plugin_ready = AsyncMock() + return connector, execution_context, binding + + +def _archive(kind: str) -> tuple[bytes, dict[str, str]]: + manifest = { + 'metadata': {'author': 'certified', 'name': 'example', 'version': '1.0.0'}, + 'execution': {'sharedRuntime': 'shared-runtime-v1'}, + } + if kind == 'legacy': + manifest.pop('execution') + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as package: + package.writestr('manifest.yaml', yaml.safe_dump(manifest)) + raw_archive = archive.getvalue() + if kind == 'legacy': + return raw_archive, {} + + from langbot_plugin.certification import create_envelope, write_envelope + + signing_key = Ed25519PrivateKey.generate() + signed_archive = write_envelope( + raw_archive, + create_envelope( + raw_archive, + 'wrong-key' if kind == 'invalid_shared' else 'ephemeral', + signing_key.sign, + ), + ) + trusted_key = signing_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + return signed_archive, {'ephemeral': base64.b64encode(trusted_key).decode('ascii')} + + +def _normalized_digest(archive: bytes) -> str: + from langbot_plugin.certification import normalized_zip_digest + + return normalized_zip_digest(archive) diff --git a/tests/unit_tests/plugin/test_certified_plugin_policy.py b/tests/unit_tests/plugin/test_certified_plugin_policy.py new file mode 100644 index 000000000..521d0380a --- /dev/null +++ b/tests/unit_tests/plugin/test_certified_plugin_policy.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile + +import pytest + + +@pytest.mark.parametrize( + ('deployment', 'certificate', 'force', 'expected_disposition', 'expected_code'), + [ + ('cloud', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'), + ('cloud', ('absent', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_REQUIRED'), + ('cloud', ('malformed', None), False, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'), + ('cloud', ('invalid', 'shared-runtime-v1'), True, 'rejected', 'CERTIFIED_PLUGIN_CLOUD_CERTIFICATE_INVALID'), + ('oss', ('absent', None), False, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_LEGACY_DEDICATED'), + ('oss', ('valid', 'shared-runtime-v1'), False, 'shared_eligible', 'CERTIFIED_PLUGIN_SHARED_ELIGIBLE'), + ('oss', ('invalid', 'shared-runtime-v1'), False, 'administrator_force_required', 'CERTIFIED_PLUGIN_OSS_FORCE_REQUIRED'), + ('oss', ('invalid', 'shared-runtime-v1'), True, 'dedicated_allowed', 'CERTIFIED_PLUGIN_OSS_FORCED_DEDICATED'), + ], +) +def test_admission_policy_enforces_certification_matrix( + deployment: str, + certificate: tuple[str, str | None], + force: bool, + expected_disposition: str, + expected_code: str, +) -> None: + from langbot.pkg.plugin.certification import ( + CertificateFacts, + CertificateVerification, + PluginCertificationFacts, + decide_plugin_admission, + ) + + verification, runtime_profile = certificate + facts = PluginCertificationFacts( + installation_uuid='00000000-0000-4000-8000-000000000001', + artifact_digest='a' * 64, + certificate=CertificateFacts( + verification=CertificateVerification(verification), + runtime_profile=runtime_profile, + ), + ) + + decision = decide_plugin_admission( + deployment=deployment, + facts=facts, + administrator_force=force, + ) + + assert decision.disposition.value == expected_disposition + assert decision.code.value == expected_code + assert decision.runtime_profile == ( + 'shared-runtime-v1' if expected_disposition == 'shared_eligible' else 'dedicated' + ) + + +def test_archive_inspection_preserves_legacy_tuple_and_exposes_certificate_facts() -> None: + from langbot.pkg.plugin.archive import ( + ArchiveCertificateState, + inspect_plugin_archive, + inspect_plugin_archive_metadata, + ) + + manifest = { + 'kind': 'Plugin', + 'metadata': {'name': 'example'}, + 'certification': { + 'runtime_profile': 'shared-runtime-v1', + 'certificate': {'issuer': 'sdk-test', 'signature': 'not-verified-by-core'}, + }, + } + archive_bytes = _archive_bytes(manifest) + + inspection = inspect_plugin_archive(archive_bytes) + + assert inspection.artifact_digest == hashlib.sha256(archive_bytes).hexdigest() + assert inspection.certificate.state is ArchiveCertificateState.DECLARED + assert inspection.certificate.runtime_profile == 'shared-runtime-v1' + assert inspection.certificate.payload == manifest['certification']['certificate'] + assert inspect_plugin_archive_metadata(archive_bytes) == ( + inspection.manifest, + inspection.requirements, + inspection.names, + ) + + +@pytest.mark.parametrize( + ('certification', 'expected_state'), + [ + (None, 'absent'), + ({'runtime_profile': 123, 'certificate': {}}, 'malformed'), + ({'runtime_profile': 'shared-runtime-v1'}, 'malformed'), + ], +) +def test_archive_inspection_reports_nonverifying_certificate_states( + certification: object, + expected_state: str, +) -> None: + from langbot.pkg.plugin.archive import inspect_plugin_archive + + manifest: dict[str, object] = {'kind': 'Plugin', 'metadata': {'name': 'example'}} + if certification is not None: + manifest['certification'] = certification + + inspection = inspect_plugin_archive(_archive_bytes(manifest)) + + assert inspection.certificate.state.value == expected_state + + +@pytest.mark.parametrize( + ('verification', 'expected_visibility'), + [ + ('valid', 'tenant_scoped'), + ('absent', 'detailed_process'), + ('malformed', 'detailed_process'), + ('invalid', 'detailed_process'), + ], +) +def test_log_visibility_policy_only_scopes_valid_shared_certifications( + verification: str, + expected_visibility: str, +) -> None: + from langbot.pkg.plugin.certification import ( + CertificateFacts, + CertificateVerification, + PluginCertificationFacts, + decide_plugin_log_visibility, + ) + + facts = PluginCertificationFacts( + installation_uuid='00000000-0000-4000-8000-000000000001', + artifact_digest='a' * 64, + certificate=CertificateFacts( + verification=CertificateVerification(verification), + runtime_profile='shared-runtime-v1', + ), + ) + + visibility = decide_plugin_log_visibility(facts) + + assert visibility.value == expected_visibility + + +def _archive_bytes(manifest: dict[str, object]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + import yaml + + archive.writestr('manifest.yaml', yaml.safe_dump(manifest)) + return buffer.getvalue() diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py index d4d9b6b5b..f1c2ac4cb 100644 --- a/tests/unit_tests/plugin/test_connector_reconcile.py +++ b/tests/unit_tests/plugin/test_connector_reconcile.py @@ -272,6 +272,12 @@ async def test_local_install_persists_verified_package_before_runtime_apply(): connector._inspect_plugin_package = Mock(return_value=('author', 'plugin')) connector._store_artifact_package = AsyncMock() connector._persist_installation_package = AsyncMock(return_value=(binding, None, False)) + connector._admit_plugin_archive = Mock( + return_value=( + {'_certification': {'normalized_digest': digest}}, + SimpleNamespace(for_installation=lambda _installation_uuid: SimpleNamespace(artifact_digest=digest)), + ) + ) connector._wait_for_installed_plugin_ready = AsyncMock() await connector.install_plugin( diff --git a/uv.lock b/uv.lock index 107889d6b..e323b1763 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, @@ -1099,34 +1099,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime" }, + { name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft" }, + { name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cufile = [ - { name = "nvidia-cufile" }, + { name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti" }, + { name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand" }, + { name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver" }, + { name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx" }, + { name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, ] [[package]] @@ -2180,7 +2180,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.5.8" }, + { name = "langbot-plugin", specifier = "==0.6.0b5" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2250,7 +2250,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.5.8" +version = "0.6.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2271,9 +2271,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/27/c23c5bab4137e755096f7ad32afcec834cb9b2e08129beb8bf797c1b8c5b/langbot_plugin-0.6.0b5.tar.gz", hash = "sha256:8cd1024ec1a8a131b8afe48dd3ff6c002626d6ab885ab99b8078985e4a8f53be", size = 613773, upload-time = "2026-09-20T10:18:18.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b0c7c009e166650d82bcb58784e89a64ec04de5156f95da7fb7d59d2c4a3/langbot_plugin-0.6.0b5-py3-none-any.whl", hash = "sha256:15c5a60765db34e5a0216eab205178fb06c6447d286f648008aa89207e75d667", size = 408564, upload-time = "2026-09-20T10:18:16.967Z" }, ] [[package]] @@ -3301,7 +3301,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -3340,7 +3340,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -3352,7 +3352,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -3382,9 +3382,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -3396,7 +3396,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -4489,7 +4489,7 @@ name = "pylibseekdb" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pymysql" }, + { name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" }, @@ -5257,10 +5257,10 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -5307,7 +5307,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5378,14 +5378,14 @@ name = "sentence-transformers" version = "5.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, + { name = "scipy", marker = "python_full_version >= '3.14'" }, + { name = "torch", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "transformers", marker = "python_full_version >= '3.14'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } wheels = [ @@ -5758,21 +5758,21 @@ name = "torch" version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions" }, + { name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "filelock", marker = "python_full_version >= '3.14'" }, + { name = "fsspec", marker = "python_full_version >= '3.14'" }, + { name = "jinja2", marker = "python_full_version >= '3.14'" }, + { name = "networkx", marker = "python_full_version >= '3.14'" }, + { name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.14'" }, + { name = "sympy", marker = "python_full_version >= '3.14'" }, + { name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, @@ -5814,15 +5814,15 @@ name = "transformers" version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, + { name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, + { name = "numpy", marker = "python_full_version >= '3.14'" }, + { name = "packaging", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "regex", marker = "python_full_version >= '3.14'" }, + { name = "safetensors", marker = "python_full_version >= '3.14'" }, + { name = "tokenizers", marker = "python_full_version >= '3.14'" }, + { name = "tqdm", marker = "python_full_version >= '3.14'" }, + { name = "typer", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } wheels = [ @@ -6083,9 +6083,9 @@ name = "valkey-glide" version = "2.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "protobuf" }, - { name = "sniffio" }, + { name = "anyio", marker = "sys_platform != 'win32'" }, + { name = "protobuf", marker = "sys_platform != 'win32'" }, + { name = "sniffio", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } wheels = [ From 942a302808b5ed25962f2fabe84930292f1766ba Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 20 Sep 2026 22:22:32 +0800 Subject: [PATCH 10/11] fix(plugin): load certification key ring from env (#2554) --- src/langbot/pkg/core/stages/load_config.py | 26 ++++++++++++++++++++++ tests/unit_tests/core/test_load_config.py | 24 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index 3d62d85d9..f88b5d668 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import copy +import json from typing import Any from langbot.pkg.utils import bounded_executor, constants import yaml @@ -215,6 +216,30 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict: return cfg +def _apply_certification_key_ring_env(cfg: dict) -> dict: + """Load the public certification key ring from one strict JSON env value. + + The generic environment override intentionally skips dictionaries. This + narrow exception keeps trusted issuer keys deployable without relying on a + mutable persisted config file, while rejecting malformed input instead of + silently running with an empty trust ring. + """ + raw = os.getenv('PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON') + if raw is None: + return cfg + try: + key_ring = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError('PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON must be valid JSON') from exc + if not isinstance(key_ring, dict) or any( + not isinstance(key_id, str) or not key_id.strip() or not isinstance(key, str) or not key.strip() + for key_id, key in key_ring.items() + ): + raise ValueError('PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON must be a non-empty string-to-string mapping') + cfg['plugin']['certification']['trusted_public_keys'] = key_ring + return cfg + + @stage.stage_class('LoadConfigStage') class LoadConfigStage(stage.BootingStage): """Load config file stage""" @@ -268,6 +293,7 @@ class LoadConfigStage(stage.BootingStage): # Apply environment variable overrides to data/config.yaml ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data) + ap.instance_config.data = _apply_certification_key_ring_env(ap.instance_config.data) blocking_config = ap.instance_config.data['system']['blocking_executor'] ap.blocking_executor = bounded_executor.configure_bounded_default_executor( diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py index 03dd7cc5d..051597875 100644 --- a/tests/unit_tests/core/test_load_config.py +++ b/tests/unit_tests/core/test_load_config.py @@ -427,3 +427,27 @@ class TestApplyEnvOverridesToConfig: result = load_config._apply_env_overrides_to_config(cfg) assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com' + + +class TestCertificationKeyRingEnv: + def test_applies_string_key_mapping_from_strict_json(self): + load_config = get_load_config_module() + cfg = load_config._complete_runtime_policy_defaults({}) + with patch.dict( + os.environ, + {'PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON': '{"ed25519:issuer":"YWJj"}'}, + clear=True, + ): + result = load_config._apply_certification_key_ring_env(cfg) + assert result['plugin']['certification']['trusted_public_keys'] == {'ed25519:issuer': 'YWJj'} + + def test_rejects_malformed_or_non_mapping_key_ring(self): + load_config = get_load_config_module() + for value in ('not-json', '[]', '{"":"YWJj"}', '{"ed25519:issuer": 1}'): + cfg = load_config._complete_runtime_policy_defaults({}) + with patch.dict(os.environ, {'PLUGIN__CERTIFICATION__TRUSTED_PUBLIC_KEYS_JSON': value}, clear=True): + try: + load_config._apply_certification_key_ring_env(cfg) + except ValueError: + continue + raise AssertionError(f'invalid certification key ring was accepted: {value!r}') From 52f5699533791defd27a29a05d0e7a54fe7f9c7c Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 20 Sep 2026 23:59:50 +0800 Subject: [PATCH 11/11] fix(plugin): honor requested marketplace version (#2555) --- src/langbot/pkg/plugin/connector.py | 45 ++++++++++++++----- .../plugin/test_marketplace_plugin_version.py | 43 ++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 tests/unit_tests/plugin/test_marketplace_plugin_version.py diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py index 3648d3fce..4b48ff4b5 100644 --- a/src/langbot/pkg/plugin/connector.py +++ b/src/langbot/pkg/plugin/connector.py @@ -139,6 +139,30 @@ def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]: return payload +def _select_marketplace_plugin_version( + versions: Any, + *, + requested_version: str | None, + plugin_author: str, + plugin_name: str, +) -> str: + if not isinstance(versions, list) or not versions: + raise ValueError(f'Plugin {plugin_author}/{plugin_name} has no versions') + + if requested_version is None: + candidate = versions[0] + if not isinstance(candidate, dict) or not candidate.get('version'): + raise ValueError(f'Plugin {plugin_author}/{plugin_name} has no versions') + return str(candidate['version']) + + for candidate in versions: + if isinstance(candidate, dict) and str(candidate.get('version') or '') == requested_version: + return requested_version + raise ValueError( + f'Plugin {plugin_author}/{plugin_name} version {requested_version} is not available in marketplace' + ) + + class PluginRuntimeNotConnectedError(RuntimeError): """Raised when plugin runtime operations are requested before connection.""" @@ -1611,6 +1635,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): execution_context: ExecutionContext, plugin_author: str, plugin_name: str, + plugin_version: str | None, task_context: taskmgr.TaskContext | None, ) -> tuple[bytes | None, str | None]: """Return a plugin package, or install an MCP/skill and return none.""" @@ -1675,20 +1700,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): subject='Marketplace plugin versions', ) versions = versions_payload.get('data', {}).get('versions', []) - if ( - not isinstance(versions, list) - or not versions - or not isinstance(versions[0], dict) - or not versions[0].get('version') - ): - raise ValueError(f'Plugin {plugin_author}/{plugin_name} has no versions') - latest_version = str(versions[0]['version']) + requested_version = str(plugin_version or '').strip() + version = _select_marketplace_plugin_version( + versions, + requested_version=requested_version or None, + plugin_author=plugin_author, + plugin_name=plugin_name, + ) _download_status, plugin_package = await _marketplace_get( client, - f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}', + f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}', max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES, ) - return plugin_package, latest_version + return plugin_package, version def _admit_plugin_archive( self, @@ -1746,6 +1770,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector): execution_context, plugin_author, plugin_name, + str(install_info.get('plugin_version') or '') or None, task_context, ) if file_bytes is None: diff --git a/tests/unit_tests/plugin/test_marketplace_plugin_version.py b/tests/unit_tests/plugin/test_marketplace_plugin_version.py new file mode 100644 index 000000000..bddfe8c8d --- /dev/null +++ b/tests/unit_tests/plugin/test_marketplace_plugin_version.py @@ -0,0 +1,43 @@ +import pytest + +from langbot.pkg.plugin.connector import _select_marketplace_plugin_version + + +@pytest.mark.parametrize( + ('requested_version', 'expected'), + [ + (None, '0.1.4'), + ('0.1.3', '0.1.3'), + ], +) +def test_select_marketplace_plugin_version(requested_version, expected): + assert ( + _select_marketplace_plugin_version( + [{'version': '0.1.4'}, {'version': '0.1.3'}], + requested_version=requested_version, + plugin_author='langbot-team', + plugin_name='RunnerDemo', + ) + == expected + ) + + +def test_select_marketplace_plugin_version_rejects_requested_missing_version(): + with pytest.raises(ValueError, match='version 0.1.2 is not available'): + _select_marketplace_plugin_version( + [{'version': '0.1.4'}], + requested_version='0.1.2', + plugin_author='langbot-team', + plugin_name='RunnerDemo', + ) + + +@pytest.mark.parametrize('versions', [[], [{'unexpected': 'value'}], 'not-a-list']) +def test_select_marketplace_plugin_version_rejects_invalid_latest(versions): + with pytest.raises(ValueError, match='has no versions'): + _select_marketplace_plugin_version( + versions, + requested_version=None, + plugin_author='langbot-team', + plugin_name='RunnerDemo', + )