Merge origin/master into feat/marketplace-installed-state-and-search

Resolved one conflict in src/langbot/pkg/plugin/connector.py. Master added
archive admission (`_admit_plugin_archive` + the certification digest check
before Runtime apply) at the same points this branch added install-stage
reporting, so both sides are kept:

- the archive is admitted first, then the 'inspecting plugin package' stage is
  reported
- the certification digest is verified before Runtime apply, then the
  'installing or starting plugin' stage is reported

Verified after merge: py_compile + ruff, tsc --noEmit, 98/98 unit tests.
This commit is contained in:
TyperBody
2026-09-21 00:48:20 +08:00
38 changed files with 1287 additions and 222 deletions
+1
View File
@@ -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`:
+6 -2
View File
@@ -22,7 +22,7 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
<a href="https://langbot.app/docs/en/insight/features">Features</a>
<a href="https://langbot.app/docs/en/insight/guide">Docs</a>
<a href="https://langbot.app/docs/en/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://cloud.langbot.app">Cloud</a>
<a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -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.
[![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
+6 -2
View File
@@ -24,7 +24,7 @@
<a href="https://langbot.app/docs/zh/insight/features">特性</a>
<a href="https://langbot.app/docs/zh/insight/guide">文档</a>
<a href="https://langbot.app/docs/zh/tags/readme">API</a>
<a href="https://space.langbot.app/cloud">Cloud</a>
<a href="https://cloud.langbot.app">Cloud</a>
<a href="https://space.langbot.app">扩展市场</a>
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
@@ -65,7 +65,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
### ☁️ LangBot Cloud(推荐)
**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,开箱即用。
[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app)
[cloud.langbot.app](https://cloud.langbot.app)
免部署,开箱即用。
### 一键启动
+5 -1
View File
@@ -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.
[![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
+5 -1
View File
@@ -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.
[![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
+5 -1
View File
@@ -64,7 +64,11 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
### ☁️ LangBot Cloud(推奨)
**[LangBot Cloud](https://space.langbot.app/cloud)** — デプロイ不要、すぐに使えます。
[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app)
[cloud.langbot.app](https://cloud.langbot.app)
デプロイ不要、すぐに使えます。
### ワンライン起動
+5 -1
View File
@@ -64,7 +64,11 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
### ☁️ LangBot Cloud (추천)
**[LangBot Cloud](https://space.langbot.app/cloud)** — 배포 없이 바로 사용.
[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app)
[cloud.langbot.app](https://cloud.langbot.app)
배포 없이 바로 사용.
### 원라인 실행
+5 -1
View File
@@ -64,7 +64,11 @@ LangBot — это **платформа с открытым исходным к
### ☁️ LangBot Cloud (Рекомендуется)
**[LangBot Cloud](https://space.langbot.app/cloud)** — Без развёртывания, готово к использованию.
[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app)
[cloud.langbot.app](https://cloud.langbot.app)
Без развёртывания, готово к использованию.
### Запуск одной командой
+5 -1
View File
@@ -66,7 +66,11 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
### ☁️ LangBot Cloud(推薦)
**[LangBot Cloud](https://space.langbot.app/cloud)** — 免部署,開箱即用。
[![Deploy on LangBot Cloud](res/langbot-cloud.svg)](https://cloud.langbot.app)
[cloud.langbot.app](https://cloud.langbot.app)
免部署,開箱即用。
### 一鍵啟動
+5 -1
View File
@@ -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.
[![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
+74
View File
@@ -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: "<base64 encoded 32-byte Ed25519 public key>"
```
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.
+6 -2
View File
@@ -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 `<data>/.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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"title": "LangBot",
"icon": "images/icon-256.png",
"type": "url",
"type": "iframe",
"protocol": "http",
"port": "${wizard_port}",
"url": "/",
+1 -1
View File
@@ -3,7 +3,7 @@
"langbot.main": {
"title": "LangBot",
"icon": "images/icon-{0}.png",
"type": "url",
"type": "iframe",
"protocol": "http",
"port": "${wizard_port}",
"url": "/",
+10 -9
View File
@@ -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
+71 -51
View File
@@ -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
+9 -2
View File
@@ -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
+69 -24
View File
@@ -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
+1 -2
View File
@@ -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
+50 -32
View File
@@ -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
+20 -4
View File
@@ -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/<appname>/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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"defaults": {
"run-as": "root"
"run-as": "package"
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

@@ -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()
@@ -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(
+77 -7
View File
@@ -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
+234
View File
@@ -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
+85 -10
View File
@@ -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
+6
View File
@@ -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.
@@ -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,
}
@@ -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)
+24
View File
@@ -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}')
@@ -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()
@@ -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(
@@ -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',
)
Generated
+63 -63
View File
@@ -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 = [