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/README.md b/README.md
index 5de92d3ad..81277fd54 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
Features |
Docs |
API |
-Cloud |
+Cloud |
Plugin Market |
Roadmap
@@ -65,7 +65,11 @@ 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.
+[](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 aad5c6efa..d49799cf6 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -24,7 +24,7 @@
特性 |
文档 |
API |
-Cloud |
+Cloud |
扩展市场 |
路线图
@@ -65,7 +65,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
### ☁️ LangBot Cloud(推荐)
-**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。
+[](https://cloud.langbot.app)
+
+[cloud.langbot.app](https://cloud.langbot.app)
+
+免部署,开箱即用。
### 一键启动
diff --git a/README_ES.md b/README_ES.md
index 04ebc78a3..d6749d5bb 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -64,7 +64,11 @@ 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.
+[](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 78d99c692..71cda2887 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -64,7 +64,11 @@ 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.
+[](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 b876bd4ee..d410e6efa 100644
--- a/README_JP.md
+++ b/README_JP.md
@@ -64,7 +64,11 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
### ☁️ LangBot Cloud(推奨)
-**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。
+[](https://cloud.langbot.app)
+
+[cloud.langbot.app](https://cloud.langbot.app)
+
+デプロイ不要、すぐに使えます。
### ワンライン起動
diff --git a/README_KO.md b/README_KO.md
index b28d3ec2c..df06da4ab 100644
--- a/README_KO.md
+++ b/README_KO.md
@@ -64,7 +64,11 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
### ☁️ LangBot Cloud (추천)
-**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용.
+[](https://cloud.langbot.app)
+
+[cloud.langbot.app](https://cloud.langbot.app)
+
+배포 없이 바로 사용.
### 원라인 실행
diff --git a/README_RU.md b/README_RU.md
index f6c1f8bce..ca65b6556 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -64,7 +64,11 @@ LangBot — это **платформа с открытым исходным к
### ☁️ LangBot Cloud (Рекомендуется)
-**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию.
+[](https://cloud.langbot.app)
+
+[cloud.langbot.app](https://cloud.langbot.app)
+
+Без развёртывания, готово к использованию.
### Запуск одной командой
diff --git a/README_TW.md b/README_TW.md
index 140515650..36d3c765a 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -66,7 +66,11 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
### ☁️ LangBot Cloud(推薦)
-**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。
+[](https://cloud.langbot.app)
+
+[cloud.langbot.app](https://cloud.langbot.app)
+
+免部署,開箱即用。
### 一鍵啟動
diff --git a/README_VI.md b/README_VI.md
index 356f577b3..2a412d992 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -64,7 +64,11 @@ 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.
+[](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
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/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/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": "/",
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
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/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 @@
+
\ No newline at end of file
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..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
@@ -42,6 +43,7 @@ _RUNTIME_POLICY_DEFAULTS = {
},
'plugin': {
'connect_timeout_seconds': 180.0,
+ 'certification': {'trusted_public_keys': {}},
'worker': {
'max_cpus': 1.0,
'max_memory_mb': 512,
@@ -214,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"""
@@ -267,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/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 223928058..92666801e 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,
@@ -150,6 +157,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."""
@@ -1622,6 +1653,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."""
@@ -1686,21 +1718,59 @@ 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,
task_context=task_context,
)
- return plugin_package, latest_version
+ return plugin_package, 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,
@@ -1733,6 +1803,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:
@@ -1752,6 +1823,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)
if task_context is not None:
task_context.set_current_action('inspecting plugin package')
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
@@ -1788,6 +1860,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')
if task_context is not None:
# The runtime installs the plugin's dependencies and starts it
# inside apply_plugin_installation. It does not stream
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/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}')
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/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',
+ )
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 = [